From bc40d1c284f7a9302251386d88b89d3e313a60cd Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:21:46 -0500 Subject: [PATCH 01/25] test: capture cross-platform behavioral and visual baseline --- docs/cross-platform/audit.md | 153 + .../visual-baseline.test.tsx.snap | 11325 ++++++++++++++++ src/ui/alarm-tui-integration.test.tsx | 58 +- src/ui/visual-baseline.test.tsx | 228 + 4 files changed, 11759 insertions(+), 5 deletions(-) create mode 100644 docs/cross-platform/audit.md create mode 100644 src/ui/__snapshots__/visual-baseline.test.tsx.snap create mode 100644 src/ui/visual-baseline.test.tsx diff --git a/docs/cross-platform/audit.md b/docs/cross-platform/audit.md new file mode 100644 index 0000000..526d116 --- /dev/null +++ b/docs/cross-platform/audit.md @@ -0,0 +1,153 @@ +# Cross-platform design and baseline audit + +The initiative starts at `6d8cc2dc7ef8a02e2bf8a2406800e6f6cbce3ab9` +(`Release 0.2.3`). All implementation belongs to +`codex/comprehensive-cross-platform-support` and one draft pull request into +`Ciphore/RadioCLI:master`. Release publication and merging are owner actions. + +## Design + +Host identity, runtime availability, integration implementation, and observed +readiness are separate facts. A platform profile identifies the kernel, runtime, +architecture, libc where detectable, and environments such as Termux and WSL. +A central adapter policy selects native integrations. Capability results explain +both availability and its prerequisites; selecting an adapter is not proof that +its service, desktop, audio device, or executable works. + +The existing playback, scheduler, terminal, and storage modules remain the owners +of their operations. They consume the platform layer rather than adding new OS +branches in UI or command handlers. Commands use executable/argument arrays; +shell boundaries are explicit and tested. Optional operations must report actual +failure and leave unrelated playback, browsing, and local-library operations +usable. + +Pure detection with injected host facts was chosen over a new inheritance +hierarchy: it fits the existing injectable adapters and makes capability +decisions testable without pretending a mocked host is a native installation. +Native verification remains a separate requirement. + +Existing store filenames, migration precedence, alarm occurrence semantics, +private control authentication, and normal Unicode/truecolor rendering are the +compatibility baseline. Environment-selected accessibility and constrained +terminal modes must not rewrite saved user preferences. + +## Baseline inspection + +Repository configuration, README, security/contribution guidance, architecture, +design, reliability, installation, alarm, packaging, roadmap, and CI documents +were inspected before implementation. No repository-specific `AGENTS.md` or shared +repository memory was present. The initial local-only editor configuration was +removed at the owner's subsequent request for a clean checkout. + +The original CI runs Node 22 and 24 on Ubuntu, macOS, and Windows. Live data, +playback, and fresh packed-install checks are skipped on pull requests. Therefore +an ordinary successful PR check does not establish packed-install or playback +coverage. All existing action references are immutable commits. + +On native macOS 26.5.2 arm64 with Node 26.8.1/npm 11.19.0: + +| Baseline check | Result | +| --- | --- | +| `npm ci`; `npm --prefix apps/docs ci` | Passed; runtime tree reports two low-severity advisories, docs tree none | +| `npm run check` | Passed | +| `npm run lint` | Initial whitespace failure came from untracked editor files; clean-tree rerun passed | +| `npm run test` | 890 passed, 16 failed in the alarm TUI integration file under concurrent build load | +| Isolated alarm TUI test rerun | All 34 passed; timing and external side effects require harness hardening | +| `npm run build` | Passed | +| `npm run smoke:data` | Passed against live Radio Browser, including station URL resolution | +| `npm run smoke:mcp` | Passed; 31 tools through built stdio server | +| `npm run pack:check`; `npm run check:package` | Passed | +| `npm run fresh:check` | Passed against an installed npm tarball and live provider check | +| `npm run docs:check`; `npm run docs:build` | Passed | + +Node 26 is supplementary evidence. Node 22/24 runs, native playback checks, +deterministic visual captures, and the final package matrix are recorded in the +verification report as they are completed. An interrupted playback attempt during +dependency replacement is not playback evidence. + +A second baseline on Node 22.23.2 reproduced the alarm-test race (888 passed, +18 failed). The replacement test harness keeps the existing 34 tests, +uses React `act` with controlled timers, and isolates provider discovery, +update checks, and local-session registration. Batched keyboard input and the +20 ms lone-Escape distinction remain exercised. + +The baseline adds 578 deterministic Ink captures before rendering changes: +every screen and settings group at 100×30, 50×16, and 24×8, Unicode/ASCII and +color/no-color variants, all receiver styles at full size, representative small +receivers, and focused ANSI palette/selection captures. Time, timezone, station +data, and animation frame are fixed; harmless trailing padding is omitted. +These are terminal-renderer captures on macOS, not evidence of another OS's +font, terminal, or desktop integration. Native ffplay also passed the playback +smoke test with a generated local silence WAV; no AirPlay receiver was verified. + +## Platform-sensitive operation inventory + +| Operation | Existing implementation and audit focus | +| --- | --- | +| Executable discovery | `player/command.ts`, `agent/launcher.ts`, `alarms/{scheduler,inhibitor,terminal-launcher}.ts`: consolidate executable permission checks, Windows suffixes/registry paths, Unix prefix directories, and explicit player overrides | +| Installation | `setup.ts`, `player/backend-install.ts`: keep confirmation/dry-run, share distro/package facts, distinguish FreeBSD `pkg` from Termux `pkg`, verify package names and privilege requirements | +| Paths and persistence | `storage/store.ts`, `providers/cache.ts`, alarm runtime/health/presence/Guard stores, `agent/session.ts`: preserve current and legacy paths; test non-ASCII/spaces, private modes, atomic replacement, corruption and read-only failures | +| Shell boundaries | `setup.ts`, `update-check.ts`, scheduler/terminal and MCP adapters, `scripts/fresh-check.mjs`: argument arrays by default; verify POSIX, Windows, XML, and systemd escaping independently | +| Playback and IPC | `player/player-controller.ts`, `mpv-ipc-client.ts`: preserve readiness, pause/mute/volume/metadata and cleanup; test actual Unix sockets and Windows named pipes; bound socket path length | +| Browser and clipboard | `ui/system-actions.ts`: unknown Unix currently assumes desktop tools; asynchronous spawn failures can be reported as success; use actual completion/failure and session-aware candidates | +| Terminal reopening | `alarms/terminal-launcher.ts`, `agent/launcher.ts`: preserve saved descriptors; check executable/session availability, Unix desktop terminals and explicit headless limitations | +| Scheduling and alarm controls | `alarms/{scheduler,runner,active-session,setup-verification,guard}.ts`: preserve occurrence locks, DST, missed-run grace, retry/fallback/snooze, authentication and runner-safe cleanup; verify native removal instead of swallowing failures | +| Sleep, wake, system volume | `alarms/{inhibitor,system-volume}.ts`: retain macOS/Windows integrations; Linux tools require the actual service; no invented wake or per-process Termux wake-lock guarantees | +| Network, DNS, proxies, TLS | provider adapters, update checker, authenticated local HTTP clients: retain hostname resolution and TLS checks; separate public requests from direct loopback control; prove proxy behavior on the selected Node version | +| Offline and bandwidth | provider cache/manager, UI startup, update checker: local state and cached data remain usable; an explicit offline policy prevents directory/update requests without pretending remote audio is offline | +| Terminal capabilities | `ui/display-context.ts`, ASCII conversion, receiver animation, CLI/setup rendering: preserve rich defaults; honor NO_COLOR, limited color, locale, screen reader, dumb/headless and small-terminal behavior | +| AirPlay | discovery, player/worker, agent service and settings: keep macOS-only, explicit output selection, optional dependency safety gates and current-session semantics | +| MCP | install/launcher/session/server: preserve opt-in settings and upgrade-stable registrations; no unsupported terminal launch reported as success; local tokens must never reach an HTTP proxy | +| Packaging and architecture | package manifests/locks, fresh install/build scripts, Homebrew template and CI: package-lock native entries are not runtime proof; retain strict checking and audit gates | + +## Commit sequence and verification gates + +1. **Baseline audit and test coverage:** make UI tests deterministic, capture + unchanged full/compact/micro frames, and record baseline command evidence. +2. **Platform capability architecture:** introduce tested profile, adapter, + path, and capability contracts; move existing decisions without changing + existing-platform behavior or persisted schema. +3. **Existing-platform regression hardening:** add regression tests before fixes + for false success, executable/permission and IPC boundaries, native scheduler + cleanup, private proxy bypass, and constrained storage. +4. **BSD and non-systemd Linux:** add verified package plans and Unix desktop + capability paths. Expose unavailable reliable background scheduling explicitly. +5. **Portable terminals and accessibility:** integrate locale/ASCII/color, + screen-reader and degraded-rendering policy; compare normal rendering with + the captured baseline and retain keyboard/focus coverage. +6. **Additional Unix and Termux targets:** implement only verifiable command and + path adapters, diagnose runtime/optional integration limitations, and keep + unverified native paths experimental. +7. **Architecture coverage:** model runtime-specific architecture limits and test + unsupported combinations without equating compilation with runtime support. +8. **CI and packaging:** preserve the original matrix; add deterministic packed + smoke/playback checks, genuine native/VM extended jobs where available, and + immutable action pins. Document the execution method for each job. +9. **Documentation and compatibility audit:** reconcile platform tiers with + evidence, run the complete suite, fetch/integrate upstream safely, review the + merge-base diff and dependencies, and finish the single draft PR with risks, + visual evidence, remaining experimental work, and rollback instructions. + +Every commit must typecheck, build, and pass relevant tests. The complete final +suite includes lint, tests, builds, documentation, package audit/lint, packed +installation, data, playback, and MCP smoke checks. No test or security gate is +removed to make a platform pass. + +## Evidence and scope rules + +- **First-class:** required native CI, an installation path, prerequisites, and + release-blocking package/startup tests for the documented combination. +- **Supported:** repeatable functional verification on the actual platform, + with its limits documented. +- **Experimental/community:** implemented paths with incomplete native evidence + or an incomplete upstream runtime/dependency story. +- **Unsupported:** blocked runtime or unavailable capability is diagnosed + explicitly. This can apply to one integration without disabling the app. + +BSD `at` polling and generic cron/service supervision do not automatically meet +the current timing, session, and catch-up contract. Android JobScheduler and +Doze also cannot be presented as equivalent native alarm delivery. Native +scheduler support remains a separate capability from general CLI/TUI support. +Haiku, illumos/SmartOS, AIX, musl, and uncommon architectures require exact runtime +and playback evidence before any promotion in tier. Windows XP and separate +legacy applications are outside the initiative. diff --git a/src/ui/__snapshots__/visual-baseline.test.tsx.snap b/src/ui/__snapshots__/visual-baseline.test.tsx.snap new file mode 100644 index 0000000..ea2f36d --- /dev/null +++ b/src/ui/__snapshots__/visual-baseline.test.tsx.snap @@ -0,0 +1,11325 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`palette baseline > 'alarm selection' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mAlarms[22m[38;2;138;150;168m ──────────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mScheduler: Ready[39m [49m +[48;2;7;10;15m[38;2;116;242;138mNext wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m Create alarm [49m +[1m[48;2;7;10;15m[38;2;116;242;138m› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM[39m[22m [49m +[48;2;7;10;15m Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194mNative scheduling is ready; the terminal does not need to stay open after save.[39m [49m +[1m[48;2;116;242;138m[30m BETA · Experimental. Use a secondary device for critical wake-ups. [39m[22m[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'amber/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;176;0mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'amber/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;176;0mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;176;0mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;176;0m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;214;138;0m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;176;0mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;176;0mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;176;0m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;176;0m2[39m [38;2;170;180;194mLongest streak: [38;2;255;176;0m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;176;0m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;176;0m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;176;0m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;176;0m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;95;55;0m [48;2;9;13;20m [48;2;154;98;0m [48;2;9;13;20m [48;2;214;138;0m [48;2;9;13;20m [48;2;255;176;0m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'blue/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;83;168;255mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'blue/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;83;168;255mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;83;168;255mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;83;168;255m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;51;136;221m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;83;168;255mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;83;168;255mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;83;168;255m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;83;168;255m2[39m [38;2;170;180;194mLongest streak: [38;2;83;168;255m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;83;168;255m2 days[39m [38;2;170;180;194mStations listened: [38;2;83;168;255m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;83;168;255m2/371[39m [38;2;170;180;194mStations counted after: [38;2;83;168;255m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;18;56;95m [48;2;9;13;20m [48;2;31;111;235m [48;2;9;13;20m [48;2;51;136;221m [48;2;9;13;20m [48;2;83;168;255m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'copper/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;208;135;112mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'copper/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;208;135;112mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;135;112mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;208;135;112m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;184;111;82m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;135;112mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;208;135;112mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;208;135;112m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;208;135;112m2[39m [38;2;170;180;194mLongest streak: [38;2;208;135;112m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;208;135;112m2 days[39m [38;2;170;180;194mStations listened: [38;2;208;135;112m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;208;135;112m2/371[39m [38;2;170;180;194mStations counted after: [38;2;208;135;112m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;68;40;31m [48;2;9;13;20m [48;2;127;79;55m [48;2;9;13;20m [48;2;184;111;82m [48;2;9;13;20m [48;2;208;135;112m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'coral/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;126;107mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'coral/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;126;107mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;126;107mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;126;107m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;214;92;73m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;126;107mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;126;107mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;126;107m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;126;107m2[39m [38;2;170;180;194mLongest streak: [38;2;255;126;107m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;126;107m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;126;107m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;126;107m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;126;107m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;76;32;24m [48;2;9;13;20m [48;2;154;64;50m [48;2;9;13;20m [48;2;214;92;73m [48;2;9;13;20m [48;2;255;126;107m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'cyan/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;34;211;238mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'cyan/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;34;211;238mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;34;211;238mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;34;211;238m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;34;184;207m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;34;211;238mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;34;211;238mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;34;211;238m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;34;211;238m2[39m [38;2;170;180;194mLongest streak: [38;2;34;211;238m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;34;211;238m2 days[39m [38;2;170;180;194mStations listened: [38;2;34;211;238m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;34;211;238m2/371[39m [38;2;170;180;194mStations counted after: [38;2;34;211;238m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;14;59;66m [48;2;9;13;20m [48;2;22;138;158m [48;2;9;13;20m [48;2;34;184;207m [48;2;9;13;20m [48;2;34;211;238m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'default receiver' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;116;242;138m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [1m[38;2;116;242;138mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;116;242;138mPLAYING[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⢀⣤⣠⣤⣄ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⣤⢾⣓⡋⣰⣾⠉⠛⠙⢻⣤ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⢠⣞⠃⠈⣱⡿⠃ ⢳⡆⠈⠹⠳⢦⡄ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠿⣿⣿⣶⠻⠉ ⠠⣽⠓⠿⢶⣆ ⢽ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⣠⣾⠾⠙⠚⡙⠚⠳⠿⠳⠂⠘⠋⠁ ⠘⠂ ⣼⠃ ⠈ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⣀⡀⣄⣤⣦⠞⠃ ⠁ ⠘⣳⠄⢨⡏ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠰⠟⠉⠉⣽⡟⠉⠰⣷⠄⢀⡀ ⢠⡆ ⢸⣿⣾⣅ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠻⢷⣩⣀⡀ ⣠⡏ ⠘⢿⣿⡄ ⣿⠿⣾⡿⣴⢶⡴⠾⢿⣗⣠⡌⢹⣿⠚⠛⠺⣿⠍ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠟⠿⣿⢾⠞⠋ ⢰⣿⣯ ⢯ ⢀⡀⢀⠙⠓⢿⣿⢿⠆ ⠈⣹⣷ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⢼⠟⢿⣷⣜⣷⣤⣄⡀ ⠹⠞⢿⣿⣷⣷⡿⠋⠈ ⢽⣿⡆ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠈⠳⣇⣤⡈⠙⢹⣟⣹⢷⡀ ⠟⠛⠋ ⢩⣿⣿ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠘⣿⢇⡀ ⠈⠿⠂⠙ ⠛ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠻⣿⣷⣆⡀ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠓⠛⠓⠛⠙⠛⠙⠛⠁ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138mBjork - Joga[39m [33m★ Favorite[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'green/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;116;242;138mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'green/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;116;242;138mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;116;242;138m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;57;211;83m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;116;242;138mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;116;242;138mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;116;242;138m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;116;242;138m2[39m [38;2;170;180;194mLongest streak: [38;2;116;242;138m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;116;242;138m2 days[39m [38;2;170;180;194mStations listened: [38;2;116;242;138m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;116;242;138m2/371[39m [38;2;170;180;194mStations counted after: [38;2;116;242;138m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;14;68;41m [48;2;9;13;20m [48;2;38;166;65m [48;2;9;13;20m [48;2;57;211;83m [48;2;9;13;20m [48;2;116;242;138m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'ice/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;185;246;255mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'ice/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;185;246;255mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;185;246;255mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;185;246;255m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;134;220;232m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;185;246;255mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;185;246;255mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;185;246;255m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;185;246;255m2[39m [38;2;170;180;194mLongest streak: [38;2;185;246;255m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;185;246;255m2 days[39m [38;2;170;180;194mStations listened: [38;2;185;246;255m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;185;246;255m2/371[39m [38;2;170;180;194mStations counted after: [38;2;185;246;255m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;36;71;77m [48;2;9;13;20m [48;2;74;149;160m [48;2;9;13;20m [48;2;134;220;232m [48;2;9;13;20m [48;2;185;246;255m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'lime/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;163;230;53mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'lime/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;163;230;53mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;163;230;53mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;163;230;53m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;132;179;43m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;163;230;53mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;163;230;53mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;163;230;53m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;163;230;53m2[39m [38;2;170;180;194mLongest streak: [38;2;163;230;53m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;163;230;53m2 days[39m [38;2;170;180;194mStations listened: [38;2;163;230;53m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;163;230;53m2/371[39m [38;2;170;180;194mStations counted after: [38;2;163;230;53m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;47;61;18m [48;2;9;13;20m [48;2;95;125;31m [48;2;9;13;20m [48;2;132;179;43m [48;2;9;13;20m [48;2;163;230;53m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'mono scene' 1`] = ` +"[1m[48;2;7;10;15m[38;2;208;208;208mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;208;208;208m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [1m[38;2;208;208;208mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;208;208;208mPLAYING[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;10;10;14m[38;2;10;10;14m [38;2;92;92;92m˙[38;2;10;10;14m [38;2;216;216;216m│[38;2;10;10;14m [38;2;92;92;92m˙[38;2;10;10;14m [38;2;255;255;255m•[38;2;10;10;14m [38;2;216;216;216m│[38;2;10;10;14m [38;2;106;106;106m˙˙[38;2;10;10;14m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;11;11;16m[38;2;11;11;16m [38;2;216;216;216m│[38;2;11;11;16m [38;2;216;216;216m│[38;2;11;11;16m [48;2;17;17;22m[38;2;88;88;96m▀▀▀▀[48;2;11;11;16m[38;2;11;11;16m [38;2;106;106;106m˙[38;2;11;11;16m [38;2;240;240;240m••[38;2;11;11;16m [38;2;106;106;106m˙[38;2;11;11;16m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;13;13;19m[38;2;13;13;19m [48;2;86;86;96m[38;2;255;255;255m•[38;2;86;86;96m [38;2;255;255;255m•[48;2;13;13;19m[38;2;13;13;19m [38;2;216;216;216m│[38;2;13;13;19m [48;2;17;17;22m[38;2;72;72;88m│[38;2;190;190;190m·[38;2;74;74;82m│[38;2;226;226;226m·[48;2;13;13;19m[38;2;13;13;19m [38;2;106;106;106m˙[38;2;240;240;240m•[1m●[22m[38;2;13;13;19m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;14;14;21m[38;2;14;14;21m [48;2;86;86;96m[38;2;86;86;96m [38;2;255;255;255m•[48;2;14;14;21m[38;2;14;14;21m [38;2;43;43;52m│[38;2;14;14;21m [48;2;17;17;22m[38;2;255;255;255m╱◆╲[48;2;14;14;21m[38;2;14;14;21m [48;2;17;17;22m[38;2;72;72;88m│[38;2;120;120;120m▪[38;2;74;74;82m│[38;2;43;43;52m░[48;2;14;14;21m[38;2;14;14;21m [38;2;240;240;240m••[38;2;14;14;21m [38;2;92;92;92m˙[38;2;14;14;21m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;16;16;23m[38;2;16;16;23m [48;2;86;86;96m[38;2;255;255;255m•••[48;2;16;16;23m[38;2;16;16;23m [48;2;25;25;32m[38;2;74;74;82m▀▀▀▀▀[48;2;16;16;23m[38;2;16;16;23m [48;2;17;17;22m[38;2;255;255;255m╱◆╲[48;2;16;16;23m[38;2;16;16;23m [48;2;25;25;32m[38;2;74;74;82m▀▀▀▀▀▀[48;2;16;16;23m[38;2;16;16;23m [48;2;17;17;22m[38;2;48;48;55m▄[38;2;226;226;226m·[38;2;48;48;55m▄▄[48;2;16;16;23m[38;2;16;16;23m [38;2;92;92;92m˙[38;2;16;16;23m [38;2;106;106;106m˙˙˙[38;2;16;16;23m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m▀▀▀[48;2;18;18;25m[38;2;18;18;25m [38;2;255;255;255m▀[48;2;17;17;22m[38;2;216;216;216m▀[38;2;255;255;255m▀[38;2;216;216;216m▀[38;2;255;255;255m▀[38;2;216;216;216m▀[48;2;18;18;25m[38;2;255;255;255m▀[38;2;18;18;25m [38;2;92;92;92m˙[38;2;18;18;25m [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m█[48;2;18;18;25m[38;2;18;18;25m [48;2;17;17;22m[38;2;216;216;216m╱╱◆╲╲[48;2;18;18;25m[38;2;18;18;25m [38;2;92;92;92m˙[38;2;18;18;25m [48;2;25;25;32m[38;2;226;226;226m·[38;2;25;25;32m [38;2;190;190;190m•[38;2;74;74;82m│[38;2;25;25;32m [48;2;18;18;25m[38;2;18;18;25m [48;2;17;17;22m[38;2;17;17;22m [38;2;72;72;88m│[38;2;17;17;22m [38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [48;2;18;18;25m[38;2;18;18;25m [48;2;17;17;22m[38;2;74;74;82m▀▀▀[48;2;18;18;25m[38;2;18;18;25m [38;2;43;43;52m│[38;2;18;18;25m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;17;17;22m [38;2;120;120;120m▪[38;2;74;74;82m│[38;2;17;17;22m [48;2;19;19;28m[38;2;19;19;28m [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;216;216;216m╱╱╱◆╲╲╲[48;2;19;19;28m[38;2;19;19;28m [48;2;25;25;32m[38;2;74;74;82m█[38;2;120;120;120m▪[38;2;43;43;52m░[38;2;25;25;32m [38;2;74;74;82m│[38;2;226;226;226m·[38;2;25;25;32m [38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;17;17;22m [38;2;72;72;88m│[38;2;190;190;190m·[38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;74;74;82m▀▀▀▀[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;17;17;22m [38;2;226;226;226m•[38;2;74;74;82m│[38;2;226;226;226m·[38;2;17;17;22m [48;2;21;21;30m[38;2;21;21;30m [48;2;25;25;32m[38;2;74;74;82m█[38;2;52;52;60m▄▄[38;2;25;25;32m [38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;17;17;22m [38;2;216;216;216m│[38;2;74;74;82m│[38;2;43;43;52m░[38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;25;25;32m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;52;52;60m▄[38;2;190;190;190m•[38;2;52;52;60m▄[38;2;190;190;190m•[38;2;25;25;32m [38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;120;120;120m▪[38;2;48;48;55m▄[38;2;190;190;190m·[38;2;48;48;55m▄[38;2;120;120;120m▪[38;2;17;17;22m [48;2;21;21;30m[38;2;21;21;30m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;21;21;30m[38;2;43;43;52m│[38;2;21;21;30m [48;2;17;17;22m[38;2;74;74;82m█[38;2;43;43;52m░[38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;74;74;82m█[38;2;43;43;52m░[38;2;17;17;22m [38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m▀▀▀▀▀▀[48;2;28;28;37m[38;2;28;28;37m [48;2;32;32;40m[38;2;32;32;40m░[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;17;17;22m [38;2;120;120;120m▪[38;2;74;74;82m│[38;2;43;43;52m░[38;2;17;17;22m [38;2;74;74;82m█[48;2;28;28;37m[38;2;28;28;37m [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;226;226;226m•[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;48;48;55m▄▄[38;2;216;216;216m│[38;2;48;48;55m▄▄[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;25;25;32m [38;2;226;226;226m·[38;2;74;74;82m│[38;2;25;25;32m [38;2;74;74;82m█[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;120;120;120m▪[38;2;43;43;52m░[38;2;17;17;22m [38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [48;2;28;28;37m[38;2;28;28;37m [48;2;32;32;40m[38;2;32;32;40m░[48;2;28;28;37m[38;2;28;28;37m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;28;28;37m[38;2;28;28;37m [48;2;25;25;32m[38;2;25;25;32m [38;2;43;43;52m░[38;2;74;74;82m│[38;2;190;190;190m•[38;2;25;25;32m [48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;43;43;52m░[38;2;74;74;82m█[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;48;48;55m▄▄[38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;25;25;32m[38;2;74;74;82m▀▀▀[48;2;31;31;41m[38;2;31;31;41m [48;2;17;17;22m[38;2;17;17;22m [38;2;74;74;82m│[38;2;17;17;22m [48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m│[38;2;17;17;22m [38;2;226;226;226m·[38;2;74;74;82m█[48;2;31;31;41m[38;2;31;31;41m [48;2;25;25;32m[38;2;74;74;82m█[38;2;52;52;60m▄▄[38;2;25;25;32m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;226;226;226m•[38;2;74;74;82m│[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[38;2;120;120;120m·[48;2;25;25;32m[38;2;74;74;82m█[38;2;52;52;60m▄▄[38;2;43;43;52m░[38;2;52;52;60m▄[38;2;226;226;226m·[38;2;25;25;32m [38;2;74;74;82m█[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;17;17;22m[38;2;190;190;190m•[38;2;48;48;55m▄[38;2;43;43;52m░[38;2;48;48;55m▄▄[38;2;43;43;52m░[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;43;43;52m░[38;2;25;25;32m [38;2;74;74;82m│[38;2;25;25;32m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;43;43;52m░[38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m█[48;2;35;35;44m[38;2;35;35;44m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;226;226;226m·[38;2;74;74;82m│[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;48;48;55m▄[38;2;17;17;22m [38;2;48;48;55m▄[38;2;226;226;226m•[38;2;17;17;22m [38;2;48;48;55m▄[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;43;43;52m░[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░░[48;2;17;17;22m[38;2;74;74;82m█[38;2;120;120;120m▪[38;2;17;17;22m [38;2;74;74;82m│[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m│[38;2;25;25;32m [38;2;74;74;82m█[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;43;43;52m░[38;2;226;226;226m·[38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;190;190;190m•[38;2;52;52;60m▄[38;2;25;25;32m [38;2;52;52;60m▄▄[38;2;25;25;32m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;74;74;82m█[38;2;190;190;190m•[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;17;17;22m [38;2;208;208;208m░[48;2;32;32;40m[38;2;32;32;40m░[48;2;25;25;32m[38;2;208;208;208m░ [38;2;25;25;32m [48;2;38;38;48m[38;2;38;38;48m [48;2;17;17;22m[38;2;208;208;208m░ ░ ░ [38;2;17;17;22m [38;2;208;208;208m [48;2;32;32;40m[38;2;32;32;40m░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;17;17;22m[38;2;17;17;22m [38;2;208;208;208m [38;2;17;17;22m [38;2;208;208;208m ░[38;2;17;17;22m [38;2;208;208;208m░ [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;25;25;32m[38;2;208;208;208m ░[38;2;25;25;32m [48;2;32;32;40m[38;2;32;32;40m░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░░[48;2;17;17;22m[38;2;208;208;208m░[38;2;17;17;22m [38;2;208;208;208m░[38;2;17;17;22m [38;2;208;208;208m [38;2;17;17;22m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;25;25;32m [38;2;208;208;208m [38;2;25;25;32m [38;2;208;208;208m [38;2;25;25;32m [38;2;208;208;208m ░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;17;17;22m [38;2;72;72;88m│[38;2;208;208;208m░[38;2;17;17;22m [38;2;72;72;88m│[38;2;208;208;208m [38;2;17;17;22m [48;2;32;32;40m[38;2;32;32;40m░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;25;25;32m [38;2;208;208;208m ░[38;2;25;25;32m [48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;208;208;208m░ ░[48;2;32;32;40m[38;2;32;32;40m [48;2;17;17;22m[38;2;208;208;208m░ [38;2;17;17;22m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;13;13;18m[38;2;104;104;107m [38;2;226;226;226m•[38;2;244;244;244m•[38;2;104;104;107m [38;2;168;168;168m•[38;2;104;104;107m ─[38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m ˙ [38;2;244;244;244m•[38;2;226;226;226m•[38;2;104;104;107m [38;2;168;168;168m•[38;2;104;104;107m˙[38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m [38;2;244;244;244m•[38;2;104;104;107m ˙ [38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m [38;2;168;168;168m•[38;2;120;120;120m•[38;2;104;104;107m ˙[38;2;226;226;226m•[38;2;104;104;107m ˙[38;2;153;153;154m•[38;2;226;226;226m•[38;2;168;168;168m•[38;2;190;190;190m•[38;2;104;104;107m ˙ ─[38;2;190;190;190m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m˙ [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;11;11;15m[38;2;90;90;92m˙── [38;2;138;138;140m·[38;2;126;126;127m·[38;2;90;90;92m [38;2;88;88;89m·[38;2;90;90;92m ˙[38;2;138;138;140m·[38;2;90;90;92m ─ [38;2;126;126;127m·[38;2;138;138;140m·[38;2;90;90;92m [38;2;88;88;89m·[38;2;69;69;71m·[38;2;90;90;92m ˙ [38;2;138;138;140m·[38;2;90;90;92m [38;2;138;138;140m·[38;2;90;90;92m [38;2;126;126;127m·[38;2;90;90;92m ˙ [38;2;138;138;140m·[38;2;90;90;92m [38;2;127;127;129m·[38;2;90;90;92m [38;2;138;138;140m·[38;2;90;90;92m ˙[38;2;88;88;89m·[38;2;90;90;92m─ [38;2;138;138;140m·[38;2;90;90;92m ˙[38;2;138;138;140m·[38;2;88;88;89m·[38;2;108;108;109m·[38;2;90;90;92m˙ [38;2;138;138;140m·[38;2;90;90;92m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;9;9;13m[38;2;75;75;77m˙ [38;2;127;127;129m·[38;2;75;75;77m [38;2;127;127;129m·[38;2;75;75;77m ˙ ─ ─ ─ ─ ˙˙ ˙ ˙ [38;2;108;108;109m·[38;2;75;75;77m [38;2;91;91;92m·[38;2;75;75;77m ─ [38;2;108;108;109m·[38;2;75;75;77m ˙[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;7;7;10m[38;2;59;59;62m ˙ ˙˙ ˙˙˙[38;2;127;127;129m·[38;2;59;59;62m [38;2;69;69;71m·[38;2;127;127;129m·[38;2;59;59;62m ˙ [38;2;127;127;129m·[38;2;59;59;62m─ ˙ ─ ˙ · ˙ ˙ ˙ ˙[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208mBjork - Joga[39m [33m★ Favorite[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'mono/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;208;208;208mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'mono/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;208;208;208mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;208;208mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;208;208;208m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;176;176;176m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;208;208mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;208;208;208mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;208;208;208m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;208;208;208m2[39m [38;2;170;180;194mLongest streak: [38;2;208;208;208m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;208;208;208m2 days[39m [38;2;170;180;194mStations listened: [38;2;208;208;208m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;208;208;208m2/371[39m [38;2;170;180;194mStations counted after: [38;2;208;208;208m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;58;58;58m [48;2;9;13;20m [48;2;118;118;118m [48;2;9;13;20m [48;2;176;176;176m [48;2;9;13;20m [48;2;208;208;208m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'receiver accent' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;116;242;138m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [1m[38;2;116;242;138mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;116;242;138mPLAYING[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138mBjork - Joga[39m [33m★ Favorite[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'rose/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;143;199mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'rose/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;143;199mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;143;199mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;143;199m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;214;92;154m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;143;199mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;143;199mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;143;199m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;143;199m2[39m [38;2;170;180;194mLongest streak: [38;2;255;143;199m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;143;199m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;143;199m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;143;199m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;143;199m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;76;34;56m [48;2;9;13;20m [48;2;143;63;106m [48;2;9;13;20m [48;2;214;92;154m [48;2;9;13;20m [48;2;255;143;199m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'ruby/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;95;135mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'ruby/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;95;135mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;95;135mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;95;135m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;201;63;104m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;95;135mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;95;135mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;95;135m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;95;135m2[39m [38;2;170;180;194mLongest streak: [38;2;255;95;135m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;95;135m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;95;135m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;95;135m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;95;135m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;76;18;48m [48;2;9;13;20m [48;2;143;39;79m [48;2;9;13;20m [48;2;201;63;104m [48;2;9;13;20m [48;2;255;95;135m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'scene' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;116;242;138m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [1m[38;2;116;242;138mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;116;242;138mPLAYING[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;4;7;20m[38;2;4;7;20m [38;2;122;138;184m˙[38;2;4;7;20m [38;2;199;210;232m│[38;2;4;7;20m [38;2;122;138;184m˙[38;2;4;7;20m [38;2;242;246;255m•[38;2;4;7;20m [38;2;199;210;232m│[38;2;4;7;20m [38;2;138;148;184m˙˙[38;2;4;7;20m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;5;9;25m[38;2;5;9;25m [38;2;199;210;232m│[38;2;5;9;25m [38;2;199;210;232m│[38;2;5;9;25m [48;2;11;17;40m[38;2;58;90;158m▀▀▀▀[48;2;5;9;25m[38;2;5;9;25m [38;2;138;148;184m˙[38;2;5;9;25m [38;2;232;236;248m••[38;2;5;9;25m [38;2;138;148;184m˙[38;2;5;9;25m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;6;11;29m[38;2;6;11;29m [48;2;143;156;192m[38;2;237;243;255m•[38;2;143;156;192m [38;2;237;243;255m•[48;2;6;11;29m[38;2;6;11;29m [38;2;199;210;232m│[38;2;6;11;29m [48;2;11;17;40m[38;2;42;74;134m│[38;2;169;212;255m·[38;2;38;55;92m│[38;2;255;209;102m·[48;2;6;11;29m[38;2;6;11;29m [38;2;138;148;184m˙[38;2;232;236;248m•[1m●[22m[38;2;6;11;29m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;6;13;34m[38;2;6;13;34m [48;2;143;156;192m[38;2;143;156;192m [38;2;237;243;255m•[48;2;6;13;34m[38;2;6;13;34m [38;2;26;36;68m│[38;2;6;13;34m [48;2;11;17;40m[38;2;237;243;255m╱◆╲[48;2;6;13;34m[38;2;6;13;34m [48;2;11;17;40m[38;2;42;74;134m│[38;2;90;111;158m▪[38;2;38;55;92m│[38;2;26;36;68m░[48;2;6;13;34m[38;2;6;13;34m [38;2;232;236;248m••[38;2;6;13;34m [38;2;122;138;184m˙[38;2;6;13;34m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;7;15;38m[38;2;7;15;38m [48;2;143;156;192m[38;2;237;243;255m•••[48;2;7;15;38m[38;2;7;15;38m [48;2;14;23;50m[38;2;38;55;92m▀▀▀▀▀[48;2;7;15;38m[38;2;7;15;38m [48;2;11;17;40m[38;2;237;243;255m╱◆╲[48;2;7;15;38m[38;2;7;15;38m [48;2;14;23;50m[38;2;38;55;92m▀▀▀▀▀▀[48;2;7;15;38m[38;2;7;15;38m [48;2;11;17;40m[38;2;26;38;69m▄[38;2;255;209;102m·[38;2;26;38;69m▄▄[48;2;7;15;38m[38;2;7;15;38m [38;2;122;138;184m˙[38;2;7;15;38m [38;2;138;148;184m˙˙˙[38;2;7;15;38m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m▀▀▀[48;2;8;17;43m[38;2;8;17;43m [38;2;237;243;255m▀[48;2;11;17;40m[38;2;199;210;232m▀[38;2;237;243;255m▀[38;2;199;210;232m▀[38;2;237;243;255m▀[38;2;199;210;232m▀[48;2;8;17;43m[38;2;237;243;255m▀[38;2;8;17;43m [38;2;122;138;184m˙[38;2;8;17;43m [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m█[48;2;8;17;43m[38;2;8;17;43m [48;2;11;17;40m[38;2;199;210;232m╱╱◆╲╲[48;2;8;17;43m[38;2;8;17;43m [38;2;122;138;184m˙[38;2;8;17;43m [48;2;14;23;50m[38;2;255;209;102m·[38;2;14;23;50m [38;2;169;212;255m•[38;2;38;55;92m│[38;2;14;23;50m [48;2;8;17;43m[38;2;8;17;43m [48;2;11;17;40m[38;2;11;17;40m [38;2;42;74;134m│[38;2;11;17;40m [38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [48;2;8;17;43m[38;2;8;17;43m [48;2;11;17;40m[38;2;38;55;92m▀▀▀[48;2;8;17;43m[38;2;8;17;43m [38;2;26;36;68m│[38;2;8;17;43m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;11;17;40m [38;2;90;111;158m▪[38;2;38;55;92m│[38;2;11;17;40m [48;2;9;19;47m[38;2;9;19;47m [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;199;210;232m╱╱╱◆╲╲╲[48;2;9;19;47m[38;2;9;19;47m [48;2;14;23;50m[38;2;38;55;92m█[38;2;90;111;158m▪[38;2;26;36;68m░[38;2;14;23;50m [38;2;38;55;92m│[38;2;255;209;102m·[38;2;14;23;50m [38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;11;17;40m [38;2;42;74;134m│[38;2;169;212;255m·[38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;38;55;92m▀▀▀▀[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;11;17;40m [38;2;255;209;102m•[38;2;38;55;92m│[38;2;255;209;102m·[38;2;11;17;40m [48;2;10;21;52m[38;2;10;21;52m [48;2;14;23;50m[38;2;38;55;92m█[38;2;27;41;73m▄▄[38;2;14;23;50m [38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;11;17;40m [38;2;199;210;232m│[38;2;38;55;92m│[38;2;26;36;68m░[38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;14;23;50m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;27;41;73m▄[38;2;169;212;255m•[38;2;27;41;73m▄[38;2;169;212;255m•[38;2;14;23;50m [38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;90;111;158m▪[38;2;26;38;69m▄[38;2;169;212;255m·[38;2;26;38;69m▄[38;2;90;111;158m▪[38;2;11;17;40m [48;2;10;21;52m[38;2;10;21;52m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;10;21;52m[38;2;26;36;68m│[38;2;10;21;52m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;36;68m░[38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;36;68m░[38;2;11;17;40m [38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m▀▀▀▀▀▀[48;2;23;27;63m[38;2;23;27;63m [48;2;17;26;53m[38;2;17;26;53m░[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;11;17;40m [38;2;90;111;158m▪[38;2;38;55;92m│[38;2;26;36;68m░[38;2;11;17;40m [38;2;38;55;92m█[48;2;23;27;63m[38;2;23;27;63m [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;255;209;102m•[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;38;69m▄▄[38;2;199;210;232m│[38;2;26;38;69m▄▄[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;14;23;50m [38;2;255;209;102m·[38;2;38;55;92m│[38;2;14;23;50m [38;2;38;55;92m█[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;90;111;158m▪[38;2;26;36;68m░[38;2;11;17;40m [38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [48;2;23;27;63m[38;2;23;27;63m [48;2;17;26;53m[38;2;17;26;53m░[48;2;23;27;63m[38;2;23;27;63m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;23;27;63m[38;2;23;27;63m [48;2;14;23;50m[38;2;14;23;50m [38;2;26;36;68m░[38;2;38;55;92m│[38;2;169;212;255m•[38;2;14;23;50m [48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;36;68m░[38;2;38;55;92m█[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;38;69m▄▄[38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;14;23;50m[38;2;38;55;92m▀▀▀[48;2;30;30;69m[38;2;30;30;69m [48;2;11;17;40m[38;2;11;17;40m [38;2;38;55;92m│[38;2;11;17;40m [48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m│[38;2;11;17;40m [38;2;255;209;102m·[38;2;38;55;92m█[48;2;30;30;69m[38;2;30;30;69m [48;2;14;23;50m[38;2;38;55;92m█[38;2;27;41;73m▄▄[38;2;14;23;50m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;255;209;102m•[38;2;38;55;92m│[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[38;2;90;111;158m·[48;2;14;23;50m[38;2;38;55;92m█[38;2;27;41;73m▄▄[38;2;26;36;68m░[38;2;27;41;73m▄[38;2;255;209;102m·[38;2;14;23;50m [38;2;38;55;92m█[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;11;17;40m[38;2;169;212;255m•[38;2;26;38;69m▄[38;2;26;36;68m░[38;2;26;38;69m▄▄[38;2;26;36;68m░[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;26;36;68m░[38;2;14;23;50m [38;2;38;55;92m│[38;2;14;23;50m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;26;36;68m░[38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m█[48;2;37;33;74m[38;2;37;33;74m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;255;209;102m·[38;2;38;55;92m│[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;26;38;69m▄[38;2;11;17;40m [38;2;26;38;69m▄[38;2;255;209;102m•[38;2;11;17;40m [38;2;26;38;69m▄[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;26;36;68m░[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░░[48;2;11;17;40m[38;2;38;55;92m█[38;2;90;111;158m▪[38;2;11;17;40m [38;2;38;55;92m│[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m│[38;2;14;23;50m [38;2;38;55;92m█[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;26;36;68m░[38;2;255;209;102m·[38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;169;212;255m•[38;2;27;41;73m▄[38;2;14;23;50m [38;2;27;41;73m▄▄[38;2;14;23;50m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;38;55;92m█[38;2;169;212;255m•[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;11;17;40m [38;2;255;180;92m░[48;2;17;26;53m[38;2;17;26;53m░[48;2;14;23;50m[38;2;255;180;92m░ [38;2;14;23;50m [48;2;44;36;80m[38;2;44;36;80m [48;2;11;17;40m[38;2;255;180;92m░ ░ ░ [38;2;11;17;40m [38;2;255;180;92m [48;2;17;26;53m[38;2;17;26;53m░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;11;17;40m[38;2;11;17;40m [38;2;255;180;92m [38;2;11;17;40m [38;2;255;180;92m ░[38;2;11;17;40m [38;2;255;180;92m░ [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;14;23;50m[38;2;255;180;92m ░[38;2;14;23;50m [48;2;17;26;53m[38;2;17;26;53m░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░░[48;2;11;17;40m[38;2;255;180;92m░[38;2;11;17;40m [38;2;255;180;92m░[38;2;11;17;40m [38;2;255;180;92m [38;2;11;17;40m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;14;23;50m [38;2;255;180;92m [38;2;14;23;50m [38;2;255;180;92m [38;2;14;23;50m [38;2;255;180;92m ░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;11;17;40m [38;2;42;74;134m│[38;2;255;180;92m░[38;2;11;17;40m [38;2;42;74;134m│[38;2;255;180;92m [38;2;11;17;40m [48;2;17;26;53m[38;2;17;26;53m░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;14;23;50m [38;2;255;180;92m ░[38;2;14;23;50m [48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;255;180;92m░ ░[48;2;17;26;53m[38;2;17;26;53m [48;2;11;17;40m[38;2;255;180;92m░ [38;2;11;17;40m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;6;11;28m[38;2;93;101;120m [38;2;255;209;102m•[38;2;255;246;220m•[38;2;93;101;120m [38;2;255;68;85m•[38;2;93;101;120m ─[38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m ˙ [38;2;255;246;220m•[38;2;255;209;102m•[38;2;93;101;120m [38;2;255;68;85m•[38;2;93;101;120m˙[38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;246;220m•[38;2;93;101;120m ˙ [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;68;85m•[38;2;90;111;158m•[38;2;93;101;120m ˙[38;2;255;209;102m•[38;2;93;101;120m ˙[38;2;140;149;168m•[38;2;255;209;102m•[38;2;255;68;85m•[38;2;169;212;255m•[38;2;93;101;120m ˙ ─[38;2;169;212;255m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m˙ [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;5;9;25m[38;2;79;86;104m˙── [38;2;154;128;68m·[38;2;129;126;119m·[38;2;79;86;104m [38;2;129;37;52m·[38;2;79;86;104m ˙[38;2;154;128;68m·[38;2;79;86;104m ─ [38;2;129;126;119m·[38;2;154;128;68m·[38;2;79;86;104m [38;2;129;37;52m·[38;2;51;64;95m·[38;2;79;86;104m ˙ [38;2;154;128;68m·[38;2;79;86;104m [38;2;154;128;68m·[38;2;79;86;104m [38;2;129;126;119m·[38;2;79;86;104m ˙ [38;2;154;128;68m·[38;2;79;86;104m [38;2;142;118;64m·[38;2;79;86;104m [38;2;154;128;68m·[38;2;79;86;104m ˙[38;2;129;37;52m·[38;2;79;86;104m─ [38;2;154;128;68m·[38;2;79;86;104m ˙[38;2;154;128;68m·[38;2;129;37;52m·[38;2;94;119;148m·[38;2;79;86;104m˙ [38;2;154;128;68m·[38;2;79;86;104m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;4;8;21m[38;2;66;72;88m˙ [38;2;142;118;64m·[38;2;66;72;88m [38;2;142;118;64m·[38;2;66;72;88m ˙ ─ ─ ─ ─ ˙˙ ˙ ˙ [38;2;94;119;148m·[38;2;66;72;88m [38;2;81;88;104m·[38;2;66;72;88m ─ [38;2;94;119;148m·[38;2;66;72;88m ˙[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;3;6;18m[38;2;52;57;72m ˙ ˙˙ ˙˙˙[38;2;142;118;64m·[38;2;52;57;72m [38;2;51;64;95m·[38;2;142;118;64m·[38;2;52;57;72m ˙ [38;2;142;118;64m·[38;2;52;57;72m─ ˙ ─ ˙ · ˙ ˙ ˙ ˙[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138mBjork - Joga[39m [33m★ Favorite[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'settings selection' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mSettings[22m[38;2;138;150;168m ────────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mSettings › Appearance[39m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [38;2;116;242;138mDisplay color[39m[22m [38;2;116;242;138mgreen[39m [49m +[48;2;7;10;15m Receiver style [38;2;116;242;138mpulse-grid[39m [49m +[48;2;7;10;15m Transparent background [38;2;116;242;138moff[39m [49m +[48;2;7;10;15m ASCII-safe display [38;2;116;242;138moff[39m [49m +[48;2;7;10;15m Reduce motion [38;2;116;242;138moff[39m [49m +[48;2;7;10;15m Mouse and trackpad scrolling [38;2;116;242;138mauto[39m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m[38;2;170;180;194mSelected[39m[22m [49m +[48;2;7;10;15m[38;2;170;180;194mPress Enter to change this setting.[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'slate/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;159;180;207mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'slate/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;159;180;207mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;159;180;207mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;159;180;207m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;124;145;171m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;159;180;207mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;159;180;207mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;159;180;207m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;159;180;207m2[39m [38;2;170;180;194mLongest streak: [38;2;159;180;207m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;159;180;207m2 days[39m [38;2;170;180;194mStations listened: [38;2;159;180;207m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;159;180;207m2/371[39m [38;2;170;180;194mStations counted after: [38;2;159;180;207m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;42;51;64m [48;2;9;13;20m [48;2;79;97;120m [48;2;9;13;20m [48;2;124;145;171m [48;2;9;13;20m [48;2;159;180;207m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'teal/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;94;234;212mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'teal/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;94;234;212mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;94;234;212mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;94;234;212m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;45;212;191m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;94;234;212mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;94;234;212mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;94;234;212m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;94;234;212m2[39m [38;2;170;180;194mLongest streak: [38;2;94;234;212m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;94;234;212m2 days[39m [38;2;170;180;194mStations listened: [38;2;94;234;212m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;94;234;212m2/371[39m [38;2;170;180;194mStations counted after: [38;2;94;234;212m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;18;63;60m [48;2;9;13;20m [48;2;31;118;108m [48;2;9;13;20m [48;2;45;212;191m [48;2;9;13;20m [48;2;94;234;212m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'violet/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;140;80;240mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'violet/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;140;80;240mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;140;80;240mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;140;80;240m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;118;63;205m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;140;80;240mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;140;80;240mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;140;80;240m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;140;80;240m2[39m [38;2;170;180;194mLongest streak: [38;2;140;80;240m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;140;80;240m2 days[39m [38;2;170;180;194mStations listened: [38;2;140;80;240m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;140;80;240m2/371[39m [38;2;170;180;194mStations counted after: [38;2;140;80;240m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;62;22;118m [48;2;9;13;20m [48;2;84;39;153m [48;2;9;13;20m [48;2;118;63;205m [48;2;9;13;20m [48;2;140;80;240m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`receiver baseline / compact / ascii > braille-wave 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + + :::: .: .:. +.+-. .-::-:.-+. .*: :+:::--:-+. : +: +- +- +- -+::: + .*::*. .+-:+-. + + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > gallop-fine 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + .:-:. + :..:#*.-+. + .-+-:-: -# + .+:- ++*-:-@@-- + %*++*-. * + **+ + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > manhattan 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + . o o | . oo + | | | .o@ +### | | #### oo . . +# #.### ooo /◆\\ |.|o . # +# #.# # . ####### . . /◆\\ . . .| ||. .. . # + ... . . . . //◆\\\\ . . .| . .. . + oo o .o oo o.o -o .oo + ..-.. - . . . ..- . . . .. +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > pulse-grid 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > ultracode 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + + + + + + + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > braille-wave 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + + ⣀⣀⣀⣀ ⢀⣀ ⢀⣀⡀ +⢀⡴⠋⠁ ⠈⠙⠒⠒⠋⠉⠈⠙⢦⡀ ⢀⡞⠉ ⠉⠳⠤⠤⠤⠖⠋⠉⠙⢦⡀ ⣀ +⠉ ⠹⣄ ⣰⠋ ⠳⣄ ⣠⠞⠉⠉⠉ + ⠈⢧⣀⣀⡼⠁ ⠈⠧⣄⣀⡴⠋⠁ + + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > gallop-fine 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + ⢀⣀⣄⣀⡀ + ⣀⢀⡀⠰⡯⠟⢀⠦⢕⠄ + ⠠⡤⢖⠋⠉⡉⠉ ⢰⣼ + ⠐⢦⣀⡤ ⠳⣆⡧⠦⠤⠦⣿⣿⠲⣂ + ⢿⡺⢴⣌⠻⠚⠁ ⢼ + ⣬⢧⡎ + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > manhattan 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + ˙ • • │ ˙ •• + │ │ │ ˙•● +▀▀▀ │ │ ▀▀▀▀ •• ░ ░ +█ █░▀▀▀ ••• ╱◆╲ │░│• ░ ▀ +█ █░█ █ ░ ▀▀▀▀▀▀▀ ░ ░ ╱◆╲ ░ ░ ░│ ││░ ░░ ░ █ + ░░░ ░ ░ ░ ░ ╱╱◆╲╲ ░ ░ ░│ ░ ░░ ░ + •• • ˙• •• •˙• ─• ˙•• + ··─·· ─ · · ˙ ··─ · · ˙ ·· +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > pulse-grid 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > ultracode 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + + + + + + + +Bjork - Joga +" +`; + +exports[`receiver baseline / full / ascii > braille-wave 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| | +| | +| | +| | +| | +| .---:--:-. :-::-. -::-: ::: | +| .+- :-:::-:. -+. -+. .-+: .---. .-+. | +| .+- +: +: .-::-: +- :: | +| :+ *. .*. -+. | +| :+ .*. +: -+. | +| .*. -+ -+. -+. | +| :-+-. :-:-. | +| | +| | +| | +| | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > gallop-fine 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| | +| .+-+- | +| +#*++%:+-#+ | +| :#:.*%: *-.+++: | +| #@@#*: .%-#*+ # | +| -%*--+-+#+.:-. :. #: . | +| :.-+*+: . :#.-* | +| :*::%#::%... :- +@%+ | +| *#*:. -* :%@: @#%%**+*%#--*@-++@- | +| *#@#+- -@% # ...--%@%: .#% | +| **%%*%+-. ++%@%%%-. #@- | +| .+*+:-*%##. *+- +@@ | +| :@+. .#.- + | +| *@%+. | +| -+-+-+-+. | +| | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > manhattan 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . | . o | .. | +| | | #### . oo . | +| o o | |.|. .o@ | +| o | /◆\\ |*|. oo . | +| ooo ##### /◆\\ ###### #.## . ... | +| ### ####### . # # //◆\\\\ . . o| | || ### | | +| #.# *| # # ///◆\\\\\\ #*. |. # |.|| #.# #### | +| # #. . o|. ### # #. ||.# #.#o#o # *#.#* . .| #.# #. # | +| #.#. . ###### . #. *|. # # o#. ###|###. #. .| # *. || . . .|o #.# #### | +| # #.### | . . .# | .# ### #. # o|.#..###.#. # . o#.##. . . # . | #.# # # .# | +| #.#.# # # .| #. . .#.# #o ##. .# .#. ..#* |.#. # | # .# ..|| #. . #o# ## #.#o# # # | +| ... . . . . . . . . . . . . ... . . . . |. | . . . .. . . | +| oo o -o o . oo o.o o o o . o o oo .o .oooo . -o o. | +| .-- .. . .. - .. .. . . . . . . . . ..- . ..... . | +| . . . . - - - - .. . . . . - . . | +| . .. .... .. . .- . - . . . . . . | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > pulse-grid 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | +| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | +| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | +| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | +| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | +| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o @ @ o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | +| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | +| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | +| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | +| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | +| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | +| . ∘ @ o o o @ ∘ . . . ∘ o @ o o o o @ o o ∘ ∘ ∘ ∘ o @ @ o o o o @ o . . . . o @ o o o o ∘ | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > ultracode 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / unicode > alpine 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ˙ •˙˙ ˙˙˙ ˙ ˙ ˙ ˙˙ ˙ • ˙ • ˙ │ +│ ·˙˙ ˙ •••••˙ ˙˙ ˙˙ ˙ ˙ ˙ ˙ · ˙ · ˙ │ +│ ˙ ••●●●•• ˙˙˙˙ ˙˙˙ ˙ ˙˙ ˙ ˙ ˙ ˙˙˙˙ ˙˙˙˙ ˙ │ +│ · · ••••• ˙ ·˙ ˙˙ ˙ ˙˙ ˙˙ ˙ ˙˙ │ +│ • · ˙˙· · • ˙ ˙˙ ˙ ˙ ˙ │ +│ • •˙ ˙ ˙ ˙ ˙ ˙ ˙ ˙ ·· · ˙ ·˙ │ +│ ˙ · ˙ ▀▀▀▀▀▀▀ ░······· ˙ ˙ ˙ ▲˙ │ +│ ·········▲ · ····················· ·▀▓▓▓▓▓▓▓▓▓▓█▀ · · ·············· · ▓ ··· │ +│ ·· ··▲▓▲········ ▲ · ·············▀▓▀•▓•▀•▀•▓•▓•▀•▀•▓•▀▓▀ ········ ···▓▓▓······▓▓▓· · │ +│ ·····˙▓▓▓▓▓ · · · ▓▓▓ · · ·· ···· · ·▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ·······▲▓▲·· ▲▓▓▓▲ │ +│ · · ·▲▓▓▓▲ ░▲▓▲ · ··· ▓░░▌╋▐░░░░●░░▌╋▐░░▓ ░ ▓▓▓▓▓ ▓▓▓▓▓ │ +│ ░ ▓▓▓▲▓▓▓░ ░ ▓▓▲▓▓ ░ ░░ ░▓░░░░▓░░░░•░░░░▓░░▓ • ▲▓▲▓▲ ▲▓▓▲▓▓▲ │ +│ · │ ·˙│˙˙˙·˙˙˙˙˙˙˙˙˙˙ · · · · │ · │ │ +│ ·· · · · ˙˙˙˙˙·˙˙˙˙˙˙˙˙˙ │ +│ · · · · · · · · · · │ +│ · · · · ·· · · · · · · · · · │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > aurora 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ··│·│····│·┊ ··│┊║│▒│█│█│║║║║││·┊·│┊·║┊·┊ ┊│ ·┊│ │ +│ ··┊·│·┊┊┊│┊│·· ··││║║█║█░█░▓▒░║││┊│┊│┊·│·· ··│· ·┊│· │ +│ ····│·│┊│║│║┊│ ┊┊║║▓░█▒█▒█▒▓║║║││┊║┊│· ✦ ┊┊┊· ·┊║┊· │ +│ ··┊·│┊│││║│║┊│· ·││▒▒█▓███▓█░░║│┊│┊┊║·· ·┊┊┊│ ·┊║║┊·· │ +│ ···┊·│┊║║║║║║┊┊ ··║░███████░║│┊··┊║│┊║· ✦ ┊┊┊║· ·│││┊││ │ +│ ···│┊║│║║║░││·· ┊┊▒▓█████▓█│┊·····│┊┊║·· ···║┊·│ ··┊┊┊║║┊··· │ +│ ··┊││▒║▒║║│┊┊· ·││███████░║┊· ··│┊┊║┊┊│·· │·┊║┊·······│║│┊││·· │ +│ ✦·┊┊║║▓║║│┊┊·· ··│║█████▓█║║┊·· ···┊·┊║┊┊║┊┊·· · ·║││││····┊║│││║┊· │ +│ ··┊║║▓││·· ││▓▓███▒█║▒│┊│┊┊··· │·┊║│║║┊│· │││║║┊┊┊·┊║││┊┊· │ +│ ··││▓││· ┊│▒▒█▓█▒█║▒│││┊│┊┊· │·┊║│║║││· ││││║┊·┊·│║┊· │ +│ ·┊┊▒││· ┊┊║▒▓▒█░█║▒││║│║┊┊·· │✦┊║│║║││· │║││║┊··┊┊│ │ +│ ✦ ┊┊║┊│·· ┊┊║░▒░▓░▓║║││║│║│┊·· │·┊║││║┊│· ✦ │║│┊│··┊┊ │ +│ ·┊·║┊│·· ┊│║░░░▓║▓│║││║┊│┊┊·· │··║┊│║┊┊· ┊║┊·· │┊ │ +│ ·····│·│··· · ┊│║║░║║│║││┊║║┊│·· ···║┊┊║·· ┊│·· │· ✦ │ +│ ··│·┊··│ │ ·· ··┊││║║│║│║┊│┊┊║┊│·· · ·║┊┊│·· ✦ ·│· │· ✦ │ +│ │·│·┊· │ ┊ ·· ··┊┊││││║│▒┊││┊║┊│·· ·║┊┊║· ✦ ✦ ·║· │· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > braille-wave 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ⢀⡤⠖⠚⠉⠓⠲⠤⣄⡀ ⣀⣠⠤⠤⣄⡀ ⣠⠤⠤⢤⣀ ⣀⣀⣀ │ +│ ⢀⡴⠋ ⠉⠓⠒⠒⠒⠚⠉⠁ ⠙⢦⡀ ⣠⠞⠁ ⠈⠓⢦⣀ ⢀⣠⠖⠋⠁ ⠈⠙⢦⡀ │ +│ ⢀⡴⠋ ⠳⡄ ⣰⠃ ⠈⠙⠒⠒⠚⠉ ⠹⣄ ⣀⣀ │ +│ ⠘⣆ ⡼⠁ ⠈⢧⡀ ⣠⠞⠁ │ +│ ⠘⢦ ⢀⡼⠁ ⠳⡄ ⣠⠞⠁ │ +│ ⠈⢳⡀ ⣠⠞ ⠙⢦⡀ ⣠⠞⠁ │ +│ ⠉⠓⠶⠚⠁ ⠉⠓⠒⠋⠁ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > cascade 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ █ ┊ ▓▒▒▒▒ │┊┊█ ·██ ▓▓· ▒░▓┊ ║ │┊┊· ▓█ █┊▓│┊│ ▒ │ ││ ·┊ ┊█┊┊┊║┊┊ ┊ ┊║░ ┊ ║ ▓┊█│█┊ │ +│ ┊┊·█▓▓▒▓ ·┊║┊│· · █ ▓█ ▒▒▓┊ ║ ║┊┊┊ █ ·┊█│┊║ ▒ ║·║│··· ┊┊┊ ┊┊┊░│┊·┊·│║▒ ┊ ║ █┊ │█┊ │ +│ ┊┊ █▓▓▓▓┊ ┊║┊║ █ ▓▒█┊ ░ ░┊┊┊ █ │ ║┊░ ▓┊║ ░║· ·┊┊│ ┊┊┊▒│┊ ┊ ║░▒ │ ░ ┊·║ ┊ │ +│ ┊┊ ████┊ ┊░┊║ · · ┊ █▓ ┊ ▒·┊░┊┊┊ ║ ░┊░┊ █┊░ ░░· ┊┊║ ┊┊┊▒║┊ ┊ ║▒▓ │ ▒ ┊ ░ ┊ │ +│ ┊┊ ·█·┊ ┊▒┊░ ·· ┊ ██·┊ ▒ ┊▒││┊· ┊┊ ·║ ▒┊▒┊ ┊▒ ▒▒ │┊║ ││┊▓░┊ ┊ ░▒█·║┊▒ ┊ ░ ┊ │ +│ · ┊┊ ┊ ┊▓│▒ · · · ┊┊·█ ┊ ▓ ┊▓│║┊ ┊┊┊ · ░ ▒┊▓┊┊ ┊▒ ▓▒ ┊ │┊░ ║║┊█▒┊·│ ▒▓█┊░┊▓ ┊ ▒·┊ │ +│ ┊┊ ·┊·┊▓│▒ · ┊┊ ┊ █ ┊▓║║┊ ┊┊┊ ▒ ▓│█┊┊ ┊▓ █▓ ┊ ║┊▒·║║┊█▒┊ │ ▒█ ┊▒┊█ ┊ ▓·┊ │ +│ ·┊│ ┊ ┊█║▓ · · ·┊┊ · │ █ ┊█░░┊ ┊┊┊ ▒ █║█┊┊ ┊█ ██ ┊·░┊▒ ░░┊ ▓┊ ║ ▓ ┊▒┊ ┊·█ │ │ +│ │║ │ │ ░ ┊┊· ░ · ┊ ▒▒│·┊┊┊ ··█ ░ ┊┊·│ ┊ ▒│█ ▒▒║·█║ ░ · ┊█┊ ·║ ░ │ +│ ║░ · · │ ║ ▒ ┊┊ ░ ··│ ▓▓║ ┊┊┊·· · ▒ │┊ │ ··┊ ▓║ ▓▓║ ║ ▒· ┊█┊ ··║ · ░ │ +│ ║▒ ║ ║ ▓ │┊ ▒ │·██║ ┊┊┊ ▓ │┊ ║ · ┊ █░ ·██░ ░ ▓ · ┊·┊ ░ · ▒ │ +│ ░▒ ░ ░ █· ││ ▓ ║ █ ░ ┊││· · ▓ ║│ ░·· ┊ █░ ▒ ▒·█ · │ │ ▒ ▓ │ +│ ▒▓ ░·▒ █ ║│ ▓ ░ ·▒ ││║ █ ░│ ░ │ ▒ ▓ ▒ █ │ ║ ▒ ▓ │ +│ ▓█ ▒ ▒ · · ░║ · █ ░ ▓ ║║░ · ░║ ▒ · ║ ▓ ▓ ▓ · ║ ║ ▓ █ │ +│ · ▓█ ·▓ ▓·· · ▒░ · ▒ ▓ ░░░ ▒░ ▓ ░··█ █ █ ░ ░ █ │ +│ █··· █ █ ·· ▒░ ·▓ █·░▒▒ ▓░ ▓ · · ░ █· ·· ·░ ▒ █ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > caustics 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓▒░░· ·░░▒▓██▓▒▒░·· ··░▒ │ +│ ▒░·· ·░░▒▓██▓▓▒░░·· ··░░▒▓ │ +│ ░·· ··░▒▒▓██▓▓▒▒░░·· ···░▒▒▓█ │ +│ ░··· ··░░▒▒▓███▓▓▒▒░░░····· ·····░░░▒▒▓██ │ +│ ░░····· ···░░░▒▒▒▓▓████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▓▓▓██ │ +│ ░░░░░░░░░░░░░░░░░░················░░░░░░░░░▒▒▒▒▒▒▓▓▓▓███████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓███ │ +│ ▒▓▓▓▓▓▓▓▓▓████████████████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓█████ │ +│ ███████▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░·······░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓███████████████████ │ +│ ▓▓▓▒▒▒░░░░···· ····░░░░▒▒▒▒▓▓▓▓█████████████████████ │ +│ ▓▒▒▒▒▒░░░░░····· ····░░░░▒▒▒▓▓▓▓████████████████████ │ +│ ▓▓▓▒▒▒▒▒▒▒▒░░░░░░······ ·····░░░░▒▒▒▒▓▓▓▓███████████████████ │ +│ ▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░░░░·······················░░░░░░▒▒▒▒▒▓▓▓▓███████████▓▓▓▓▓▓▓ │ +│ ███████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓████████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓███████████▓▓▓▓▓ │ +│ ███▓▓▒▒▒░░░····· ·····░░░░▒▒▒▓▓▓▓██████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓████████▓▓▓ │ +│ ██▓▒▒░░·· ··░░░▒▒▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█████▓ │ +│ ██▓▒░░·· ··░░▒▒▓████▓▓▒▒▒░░░░░░░░░░░░░░░░▒▒▒▓▓▓██ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > chladni 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠁⠒⠠⢀⡀ ⠑⠠ ⣀ ⣀ ⠤⠂⠁ ⢀⡀⠄⠐⠈⠁ ⠈⠁⠂⠤⢀ ⠈⠂⠄⢀⡀ │ +│ ⠁⠒⠠⢄⡀ ⠉⠉⠁ ⢀⡀⠄⠒⠈ ⠈⠁⠂⠤⢀⡀ ⠈⠉ │ +│ ⠉⠒⠠⠄⡀ ⢀⡀⠄⠒⠈⠁ ⠈⠁⠒⠤⢀ │ +│ ⠉⠉ ⠒⠠⢄ ⠉⠐⠠⠄⡀ ⢀⡀⠄⠒⠈ ⡀⠄⠒ ⠉⠉ ⠈⠉⠉ ⠂⠠⢀ ⠈⠁⠂⠠⢀ │ +│ ⠑⡀ ⠉⠐⠢⣤⣀⣀⣤⠤⠒⠈ ⠎ ⢡ ⠈⠁⠒⠤⣄⣀ │ +│ ⢠⠃ ⢀⡠⠴⠚⠛⠛⠛⠢⠄⡀ ⢄ ⡸ ⣀⠠⠔⠛⠛ │ +│ ⣀⡀⠠⠄⠂⠁ ⢀ ⠄⠂⠈ ⠉⠐⠠⠄⡀ ⠑⠠⠄ ⣀ ⣀ ⠠⠐⠊ ⣀⠠⠐⠂⠁ │ +│ ⢀⡠⠄⠂⠉ ⠉⠐⠢⠄⣀ ⣀⠠⠐⠂⠁ │ +│ ⢀⠠⠄⠂⠉ ⡠⠄⠂ ⠉ ⠉ ⠐⠂⢄ ⠈⠐⠂⠄⣀ ⡀⠠⠐ ⠁ ⢀⠠⠐⠂⠈⠉ │ +│ ⣤⣤⠔⠂⠉ ⡎ ⠑ ⠈⠐⠢⣤⣤⣤⡤⠖⠊⠁ ⢠⠃ │ +│ ⠉⠙⠒⠤⢀⡀ ⢃ ⡰ ⡀⠤⠒⠛⠉⠉⠛⠢⠄⣀ ⠈⢄ │ +│ ⠁⠂⠠⢀⡀ ⠁⠂⠠ ⣀⣀⡀ ⣀⣀ ⠤⠐⠈ ⡀⠤⠐⠈⠁ ⠈⠐⠂⠄⣀ ⠑⠂⠤ ⣀⣀ │ +│ ⠁⠒⠤⢀⡀ ⢀⡀⠤⠐⠈⠁ ⠈⠐⠂⠤⣀ │ +│ ⣀⡀ ⠈⠁⠒⠠⢀⡀ ⡀⠤⠐⠈⠁ ⢀⣀⣀ ⠈⠑⠂⠤⢀ │ +│ ⠈⠁⠐⠠⡀ ⠁⠒⠠⢀⡀ ⢀⡀⠄⠐⠈⠁ ⢀⠠⠒ ⠉ ⠉ ⠂⢄ ⠈⠁⠂⠤⢀ │ +│ ⠈⡄ ⠉⠒⢦⣤⣤⣤⠒⠈⠁ ⠃ ⢱ ⠈⠑⠲⣤⣤ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > constellation 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ✦ │ +│ · │ +│ ✦ · ✦·······✦· │ +│ ··· ·····✦ ········ ····· │ +│ · · ······ · ··✦····· ······ ·· │ +│ ·· · ······ ·· ····················✦ │ +│ · ✦ ·· ···· ······✦······· │ +│ · · · ········ ······ · │ +│ ··· · ····✦··✦· │ +│ ·· ·✦ │ +│ ✦ ··· │ +│ ····· ···· │ +│ ····· ··· │ +│ ···✦·· │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > copper-bars 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ██████████████████████████████████████████████████████████████████████████████████████████ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ██████████████████████████████████████████████████████████████████████████████████████████ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ██████████████████████████████████████████████████████████████████████████████████████████ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > crossfade 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > cube 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ +----------------+ │ +│ @@@@@@@@@@@@@@@@@@@\\ │ +│ @@@@@@@@@@@@@@@@@@@@\\ │ +│ /@@@@@@@@@@@@@@@@@@@@\\ │ +│ @======================@ │ +│ ***********************| │ +│ ********************** │ +│ *********************| │ +│ |********************| │ +│ ******************** │ +│ +******************+ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > cyclone 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠠ ⡰⠐⠊⠈⠡⠁⢠⠁⠤⠁⣈⠂⢱⢄⡆⣀ ⠐ ⠠⢀⠐⠄⢀ │ +│ ⠂⠐ ⡠⠤⡐⠎⠌⠊⡂⠒⠠ ⠄⢀⠑⠠⡊⢪⠐⠂ ⠄⠄⠐⠤ ⡂ │ +│ ⡀⠂⠁⠠⠠⢐⡔⠈⡆⢲⡠⢆⣠⢀ ⠠⠂⠄⠂⡑⠌⢈ ⠄ ⡂⠃⠔⠄⠠⠂ │ +│ ⠄⠁⢀⠠⡐⠑ ⡀⡠⠠⠄⠅⡈⠈⠓⠂⡈⠠⢤⢁ ⠠ ⠰⠈⡠⠐ ⠤ │ +│ ⡠⠈ ⠄⠐ ⠄⡀⠠ ⠂ ⠠⢀⢰⢠⢀ ⢀⢃⣃⣒ ⠂⠐⠅⢀⠄⡂⠄⠄ │ +│ ⠠ ⡂⢈⡈⠠⠈ ⠆⠌ ⠂⠡⠸⠘⠜⢔⠬⡉⢣⣀ ⣳⠸⡠⠅ ⠊⢈⡣ ⠔⡀⠄⠁⠈ │ +│ ⡁⢀⠄⠂⠠⡈⡁⠃ ⠤⠥⢦⢢⣢⠈⢿⢤⠻⣨⠌⠁⣈⣒⠁⢤⠂ ⢌⠢ ⠁⠂ │ +│ ⠁⣀⠄ ⠠ ⠆ ⡠⠢ ⢔⡴⢚⣢⣯⣋⣂⡿⢖⡡⢎⡠⢕⣢⠂ ⡪ ⢔⠕⠂⠈ ⠂ ⠂ │ +│ ⠠ ⠘⢈ ⡊⠕ ⠂⢸ ⢀⡂⢨⣟⢈⢙⡌⠳⠄⡀⡄⡄⢆⠡⢈⠰ ⢀⠘⠐⠄ ⡆ │ +│ ⠁ ⠑⠁ ⡁⡃⠄⠁⠩⡨⡑⣂⠬⡥⢂⠢⢪⣃⠒⠠ ⠈⠂⠁⡀⣀⡐⢄⠸⠄⡀⠨⠆ ⠐⠈ │ +│ ⠂ ⠢⠉⢈ ⣁⠡⠂ ⠔⡂⠐⡈⢭⣅⢂⠈⠈⠑⠲⠒⠶⡢⠹⡒⠤⠢⠁⠁⢄⠐⠁ ⠂⠈⠄ │ +│ ⠙ ⡉⡀⡤⠈⡀ ⠐⢊⠠⠂⡀⠈⠓⠦⡀⡨⡂⢧⢹⠊⠶⢈⠁⠂⠈ ⠁⡀⡈⡀⠡ │ +│ ⠈⠎⠉⡀⡰⠄⠄ ⠁⠌⢀⠆⣀ ⠁⠁ ⠂⠘ ⠃ ⠁ ⠄⠐ │ +│ ⠈⠆⠁⣊⡁⢌⢂ ⠃⢐⠅⢌ ⡂ ⡀ ⡀ ⡀ ⠄ ⠁⠈ │ +│ ⠔ ⢉⠄⡀⠌⠄⡀ ⠐ ⠌ ⠂ ⢀ ⡠⠄ │ +│ ⠠ ⠈⠠⢔⠄⢀⠃ ⡀ ⠡⠃⠈⠕⠈⠨ ⠈ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > equalizer 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ·· ██ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ██ ██ ██ ·· ·· ·· ·· ◆◆ ◆◆ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ██ ██ ██ ██ ██ ◆◆ ◆◆ ◆◆ ·· ·· ◆◆ ·· ·· ·· ·· ·· ██ ██ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ◆◆ ·· ·· ·· ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ·· ◆◆ ◆◆ │ +│ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ◆◆ ·· ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ◆◆ ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ◆◆ ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ██ ██ ██ ██ ██ ██ ██ ██ ██ ◆◆ ◆◆ ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fern 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⣠⣾⣿⠆ │ +│ ⢀⢤⢳⣿⣭⡊ │ +│ ⡄⡝⡏⡬⡑⡽⠁ │ +│ ⢀⠠⠃⡦⠗⡘⡡⡌⢗ │ +│ ⠰⠩⢦⠑⢅⢃⠒⠄⣃ │ +│ ⡾⠜⡦⠑⠎⡈⡀⠪⠔⡢ │ +│ ⢀⡦⢰⡆⠮⢞⢈⠃⡐⢪⠐⢆ │ +│ ⢀⠈⡉⡉⣔⡐⠄⠞⠈⢀⡐⢌⡅ │ +│ ⢠⡄⠠⡠⠔⡮⣄⠃⠐ ⢀⠐⠦⡑⠛⣠ │ +│ ⢸⢟⡄⠹⠁ ⡀⢁⣘⠈ ⠠⢀⣁⡀ │ +│ ⡴⢀⠐⡎⣤⠐ ⠈ ⢀⠈⠃⠤⠁⢁⠔ │ +│ ⠘⡵⠂ ⠁⢀⡇⠈ ⠤⡀⣁⡀ │ +│ ⠐⡀⠨ ⠁⠂ ⠐⡰⠠⡈⠉ │ +│ ⠈ ⠄⠠⠈ ⢀⠁ │ +│ ⠃ ⡀⠈ │ +│ ⠁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fire 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ░░░ │ +│ ░░░ │ +│ ░▄▄▄ ░░░ │ +│ ▄▄▄▄ ░░░▄ · ▄ │ +│ ░░ ▄▄░░ ▄▄▄▄ · ▄▄▄ │ +│ ░░ ░░░░░ ▄▄▄░░ ░░▄ ▄░░░ │ +│ ▄▄░ ░░░▄▄▄▄ ░░░▄▄ ░░░ ▄▄░░░ ▄▄▄ ░░░▄▄ │ +│ ░░░░░░▄▄▄▄▄▄░░░ ░▄▄▄▄▄ ▄▄▄ ▄░░░░░░ ░•░░░░▄▄▄▄▄ │ +│ ░░▄▄▄▄▄▄░░░░░░░▄▄▄▄▄▄░░░ ▄▄▄▄░░░░░░▄▄▄ ░░░░▄▄▄▄▄▄▄░ │ +│ ▄▄▄▄▄▄░░░░▒▒▒▄▄▄▄▄░░▒▒░░ ▄▄▒▒░░░░▄▄▄▄▄▄ ░░░▄▄▄▄▄▄░░░░ │ +│ ▄▄▄▄░·░░░░▄▒▒▒▄▄░░▒▒▒▒▒▒▄▄▄ ▄░▒▒▓▒▒▄▄▄▒▒▄▄░ ░▄▒▒▄▄▄░░░░░░▄ │ +│ ▄░░░░░░▄▄▒▒▒▒▒░░░▒▒▓▓▒▒▒▄▄░░▒▒▒▓▓▒▒▒▒▒▒▒▒░░░░▄▄▄▒▒▒▒░░░░░░▄▄▄ │ +│ ▀▀▀█▀▀▀▒▒▒▒▀▀█▀▀▀▀█▀▒▓▓▒▀▀▀▀█▀▀▀▀▓▒▒▒▀█▀▀▀▀█▀▀▄▄▒▒▀▀▀█▀▀▀▀█▄▄▄▄█▀▀▀ │ +│ ▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fireworks 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · · *· · ·· │ +│ ··· ✦ +· · · · │ +│ · ··· · · · ··· +· · │ +│ · ✸✦● ···· ✦ ··*· ●· │ +│ ✦· ✦●✸ ★ · x· · │ +│ · ·★· · ●✸✦ ·· +· │ +│ · ··· · · · · · · ·x │ +│ · · · · · ·· · │ +│ · ·· ···· ·· · · │ +│ · · ● ·· ···· · │ +│ · ·· · · │ +│ · ●· · · · │ +│ │ · · │ +│ │ │ +│ · · │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > flyover 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·····+·+·=:x:x:X:X:X:$:$:$:$:$:$:$:X:X:X:X:x:x:x:x:=:= │ +│ · : :·;·+·+·=:x:x:x:X:XXX:XXX:X:x:x:x:x:=·=·=·+·+·+·+·+·+· │ +│ ···+···=·=:x:x:x:x:x:x:=:=·=···+···+·················· │ +│ : · · :·;·+·+·=·=·=:=:=·=·=·=·+·+·+·;·;·;·;·;·;·;·;·;·;·;·:·: │ +│ ·· ···;···+·=·=·=·=···+···;········ │ +│ +·;·; : · :·;·+·+·+·=·=·+·+·+·;·;·;·: : : : : : · │ +│ ···+···· ·······+···+···+······· │ +│ x:=·=·+·;·: · : :·;·+·+·=·=·+·+·+·;·;·: : : : : : · · · │ +│ x:x:x:=·=·+·; : : :·;·+·=·=·=·=·+·+·;·;·:·: : : · :·;·+·=· │ +│ :x:x:x:=······ ···+···=···=···+········· ···+···+·· │ +│ =:=:x:x:=·+·;·: · · · :·;·+·=·=:=·=·+·+·+·;·;·;·: : · :·;·+·+·;·;·: │ +│ ·+·=·=·=·=···;· ··+·=·=·=·=·=·+···+···;··· ···;···;·· │ +│ ;·+·+·+·+·+·;·: · · :·;·+·=·=·=·=·+·+·+·+·;·;·: · :·;·;·;·;·: : · │ +│ ·······+······ ····+···=···+···+······· ········ │ +│ : :·;·;·;·;·; : · · : ;·+·+·+·+·+·+·+·+·+·;·: · : :·;·;·;·: : · · │ +│ ······· ·;···+···+···+···+··· ·;···;·· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fractal-tree 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ⢀⣄⣤⣤⣠⣤⣤⣤⣤⣤⣠⣤⣄⣄ │ +│ ⢀⣀⣤⣶⣾⣿⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣶⣦⣄⣀ │ +│ ⢀⣴⣿⣽⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣽⣷⣄ │ +│ ⣴⣾⡿⣿⣿⣟⢡⣿⣟⣿⠻⣿⣿⣿⣿⣻⣿⣿⣿⡿⢻⣟⣿⣧⢙⣿⣿⡿⣿⣶⡄ │ +│ ⢀⣾⣿⣿⣿⣿⣿⣽⡿⣿⣿⣿⡰⠿⡿⣿⣿⣹⣿⡿⡿⠷⣸⣿⣿⡿⣿⣽⣿⣿⣿⣿⣿⣆ │ +│ ⢰⣿⡙⣿⠛⠻⢍⣟⡿⣛⡞⠛⠺⢄⢸⡏⢏⣏⠏⣿⢀⠼⠚⠛⣞⡻⣟⣏⠽⠛⢻⡟⣹⣷ │ +│ ⢾⣟⣿⡿⠖⠒⠊⠉⠑⠒⠻⢄ ⢹⢇⠈⡏⢀⢿⠁ ⢀⠼⠓⠒⠉⠉⠒⠒⠾⣿⣟⣿⠆ │ +│ ⠁ ⠘⡄ ⡇ ⡜ ⠁ │ +│ ⠱⡀⡇⡰⠁ │ +│ ⠱⡷⠁ │ +│ ⡇ │ +│ ⡇ │ +│ ⡇ │ +│ ⡇ │ +│ ⡇ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > galaxy 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀⢀⣀⠤⠐⠂⠃⠁ ⠁ ⠄ │ +│ ⠁ ⡀ ⠚ ⡀⡤⠤⠤⠶⠲⠖⠁⠢⠒⠦⠦⠦⠄⢠⢀ ⢄ │ +│ ⠞⠋ ⢀⣠⡴⢶⠈⠁⠁ ⡀⣀⡀⣀⣀⢠ ⠁⠘⠒⠤⢀⡀ │ +│ ⠠ ⡐⠃ ⡀⢠⠺⠟⠉⠁ ⣒⢄⠤⢶⡒⢻⠛⠛⡺⠋⠁⡈⠩⠈ ⠐⠾⠦⣤⡀ ⠉⠑⠠⣀ │ +│ ⢀⠞⠁ ⢤⠟⢃ ⡂⢀⣡⢾⠍⠛⠐⢠⣄⣥⡭⣢⣴⣧⢴⢦⢤⣰⣄⣄⢐ ⡀⠌⠉⠲⣠⡀ ⠈⠁⢀ ⡀ │ +│ ⡌ ⠄ ⡖⠃⡀ ⢽⢛⠩⡀⡐⢭⡵⡾⠈⠋⢑ ⠠⠁⣠⢐⢊⠑⠜⠊⣻⡿⡬⣀⠁⡄⠈⠙⠷⡄ ⠈⠠ │ +│ ⠠ ⢰⠁ ⠐⡇⠪⢢⠄⢠⡏⢆⣀⠕⣠⡿⡈⢒ ⣨⣶⣛⡛⠌⢹⡟⠛⠷⢷⣄⠨⡀⠐⡉⢳⣔ ⠈⢣⡀ ⠘⣀ ⠄ │ +│ ⠠ ⠁⢸⡄ ⢸⡇⠰ ⠈⠼⣧⡔ ⣂⡾⣯⣖⣠⣾⣿⣿⣿⣿⣿⣧⣧⡊⠨⢐⠭⠷⣧⡁⠄ ⠸⣧⡀ ⢲ ⠈⡄ │ +│ ⠐ ⠈⢣⡀⢀ ⠙⣹⣮⣌⠐⡀⡈⠉⠳⠧⣬⣶⣈⣃⣬⡨⢿⡏⠤⠌⢈⣿⠗⠢⡤⢀⣾⠃⠃⡀⠄⣸⡅ ⡇ │ +│ ⢀ ⠈⢦ ⠦⡀ ⠐⢉⠟⢻⠤⣠⣢⠒⡄⢈⢄⢥⠩ ⠁⠄⣂⣴⣵⠟⢣⡀⢒⢀⡾⠏⠘⢃⣀⢡⡶⠁ ⡸ ⠐ ⠄ │ +│ ⠁ ⠠⡀ ⠉⠻⣦⢀⢀⡀⠅⡈⣙⢽⡓⠺⠟⠗⠶⠇⢺⢻⠏⡉⠂⢐⢌⣴⡾⡯⢡⠈⠒ ⢔⠛ ⡴ │ +│ ⠁⠱⢤⢀ ⠈⠘⠛⠃⠪⣷⣉⢈⡨⣉⣠⣤⡤⣩⣠⢠⡴⠶⡞⠊⠉ ⣠⡜⠛ ⣄⠊ ⠄ │ +│ ⠈ ⠂ ⠄ ⠑⠒⠄⣄⢀ ⠁⠙⠁⠉⠁⠉⠉ ⣀⡤⠼⠖⠊ ⢠⠤⠁⠁ │ +│ ⠠⠐⠁ ⠉⠈⠈⠒⠂⠲⠰⠨ ⠦⠠⠰⠆⠒⠚⠋⠁⠁ ⣀⠠⠒⠋ │ +│ ⠄ ⢀⢤⠄ ⠐⠊⠉⠁ ⠄ │ +│ ⠁ ⠠ ⡀ ⠈ ⠈ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > gallop-fine 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ⢀⣤⣠⣤⣄ │ +│ ⣤⢾⣓⡋⣰⣾⠉⠛⠙⢻⣤ │ +│ ⢠⣞⠃⠈⣱⡿⠃ ⢳⡆⠈⠹⠳⢦⡄ │ +│ ⠿⣿⣿⣶⠻⠉ ⠠⣽⠓⠿⢶⣆ ⢽ │ +│ ⣠⣾⠾⠙⠚⡙⠚⠳⠿⠳⠂⠘⠋⠁ ⠘⠂ ⣼⠃ ⠈ │ +│ ⣀⡀⣄⣤⣦⠞⠃ ⠁ ⠘⣳⠄⢨⡏ │ +│ ⠰⠟⠉⠉⣽⡟⠉⠰⣷⠄⢀⡀ ⢠⡆ ⢸⣿⣾⣅ │ +│ ⠻⢷⣩⣀⡀ ⣠⡏ ⠘⢿⣿⡄ ⣿⠿⣾⡿⣴⢶⡴⠾⢿⣗⣠⡌⢹⣿⠚⠛⠺⣿⠍ │ +│ ⠟⠿⣿⢾⠞⠋ ⢰⣿⣯ ⢯ ⢀⡀⢀⠙⠓⢿⣿⢿⠆ ⠈⣹⣷ │ +│ ⢼⠟⢿⣷⣜⣷⣤⣄⡀ ⠹⠞⢿⣿⣷⣷⡿⠋⠈ ⢽⣿⡆ │ +│ ⠈⠳⣇⣤⡈⠙⢹⣟⣹⢷⡀ ⠟⠛⠋ ⢩⣿⣿ │ +│ ⠘⣿⢇⡀ ⠈⠿⠂⠙ ⠛ │ +│ ⠻⣿⣷⣆⡀ │ +│ ⠓⠛⠓⠛⠙⠛⠙⠛⠁ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > golden-gate 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ●●●•·· ~ ˙·•●•●•●●●•●●●••●•··· ˙ ˙ • ˙ ˙~•••●•~●••••· ˙ ˙ │ +│ ●●••●·˙ ˙ ▀ ··▀• · · ˙ ˙ ˙ ··●●●••●·▀· ˙▀ ˙ •●●●•˙˙ ˙ ˙ ˙ │ +│ ·• ˙ ˙ ˙ ˙ ˙ ˙██████ ˙ ·●•●●●•●●██████˙ ˙ ˙ ˙ •••˙ ˙ ˙ ˙ │ +│ ˙ ˙ ˙ ˙ ••██│ ██ ˙ ˙ ˙ ˙ ˙ ˙██•│██•••••˙ ˙ ˙ ˙ ˙ │ +│ ˙ ••••│ ██████•••˙ ˙ ˙ ˙ ˙ ˙ ••██████˙│ │ •••••~ │ +│ ˙ ˙ ˙ ••••│ │ │ ██│ ██│ │•• ˙ ˙ ˙ ˙ •• │██ │██ │ │ │ │ │•••••• ˙ ˙ │ +│ · ░░•••░│·│ · │ │ ██│˙·█│·· ·••••░ ˙ ˙ ░░˙░•••• │ │·█ │██ │ │ │ │ │˙│ │ │••••• ˙ ˙ │ +│ ░░•·░•│░│░│░░░░░│░··░██░██│ │ │ │ │••••••••·•░• │ │ │ │·█████ │ │ │ │░│░│░│░│░│░│░•••••░░░ │ +│ ▀░▀·▀░▀▀····░▀▀▀▀·▀▀████░█▀▀▀▀░·▀▀▀▀·▀▀▀░▀▀░·▀░··░▀▀▀▀▀██████▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ +│ ░ · ··█░· ˙ ██╲╱██·· ˙ ~ ░ ·░·│·~~ ···· ░˙ ██╲╱██· · · · ·· ~ · │ +│ ˙ ░ · ˙░~˙ ˙ ˙· ██·•██˙··░ ░ ~·▲│▲ · ·· █░ ██• ·~ ˙ ˙ │ +│ ▒ ˙ ██╲╱██ · ˙·▄▄▄ ˙ ˙ · ██╲╱██ ˙ ~ · ˙ │ +│ ▒▒˙ · · ˙ · ··· · ˙ · · · ˙ · · ·· ˙ ˙ ˙ ˙ ·˙ ˙ │ +│ ▒▒▒░░ ˙ · · · ˙·˙ ˙· ˙ · ·˙· · ·˙·˙ · │ +│ ▒░ ▒ ░░░˙˙ ˙ · · · ··˙ · · · ·˙ ˙ ·˙· ˙ · · ˙ ·˙˙ ·· │ +│ ░ ░▒░ ▒ · ˙ ˙ ˙˙·˙ ˙ ˙ ˙ ·˙ ·· · ˙ · │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > goniometer 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀ ⡇ ⡀ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄⡧⠊ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⠈ ⡇ ⠁ │ +│ ⡇ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > harmonograph 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀ ⢀ ⢀ ⠄ ⢀ ⠂ ⠄⠐ ⠠ ⠄ ⡀⢀ │ +│ ⢀⠂⠁ ⠈⢀ ⠤ ⠠ ⠄ ⡀ ⠠ ⢀⢀ ⢀ ⠂⢀ ⠁ ⠄⡀ ⠠⠐⢱ │ +│ ⠄ ⡀⡨⠂⠂⠂⠄ ⡈ ⢀ ⡀ ⠔ ⡀⢀⢀ ⠰ ⢀⠨ ⢀ ⠠⠈ ⠐⠐ ⠁ ⠁ ⠁ ⠐ ⠠⢀ ⠄⠶⡀⡀ ⠁ ⠂ │ +│ ⠐ ⠂ ⠐ ⢰⢒⠠⠂ ⠁ ⢀ ⢀⢀ ⢉⢂⠦⢌⢠⢀ ⠁⡀⠐⠐ ⠄ ⢀ ⠄ ⠊⠈⡀⠬⠠⠢⠠⢀⠁ ⠂ ⡠⡈⠐⠂ ⠁ │ +│ ⠂ ⠂ ⠈⡀ ⠄⠡ ⠁⠨⠳⡄⣀⠠⡂⢐⠁⠂⢠⠁⢄⠠⠄⠤⢄⠨⠊⠔⠔⡄⣠ ⡀ ⠐⠁⢀⢠ ⠢⢘⣙⡠⢁⠁⠪⡠ ⡒ ⠂ ⠆ ⠈⠂ │ +│ ⠐ ⠈ ⠄ ⠂ ⠈⢀ ⠵⢃⠺⣊⠌⠢⡨ ⡂ ⠄⠁⠔ ⢀ ⠠⠋ ⡤⠄⠌⢈⡀⠨⠈⠈⠂⠄⢘ ⠡ ⢂ ⠐⡂⠡ ⢀⠈ ⠄⠁ ⠐⠄ │ +│ ⠠⠁ ⢀⠑ ⠠⠄⠃⢠ ⠰⠛⡄⠈⠄⠁⠠ ⠄ ⠂⠠⠡⡠⠈⠂⢘⠚⠄⢀ ⠂⠈⢄⠍ ⠅⠄ ⠠ ⠐⡂ ⠁⠐ ⠄⡀ │ +│ ⠢ ⡀ ⠐⢀⠠ ⠐ ⡈⠁⠠⡅⠁⠁⠏⡀⡄⠤⠈ ⠌ ⢀ ⠈ ⠄ ⠅⡀⡀⠨⠒ ⡐ ⠙ ⡔ ⢜ ⠄⠡⢁⡁ ⠂ ⠌ ⠁⠂ ⠂ ⡀ │ +│ ⠂ ⡀ ⠨ ⠆ ⠄⢂⠁ ⡐⠨⠐ ⠐ ⠠⠐⣂⠠⠆⠂⠄⡘⠐⠄⢐⢀ ⠐⠁ ⡄⠎⢁⡀⢃⣀⠆⠁⠄⠠⠂ ⠄ ⠂ ⠈ ⠐ │ +│ ⠂ ⠂⠐⠠⠂⠐ ⠡⠐⡀⠁⢂⢀⠡⢀⡀ ⠐⡈⠐⢠⡀⠨⠁⠸⠁⣠⣀⢂ ⠂⠠⢄ ⣄⢰⢀⠁⡀⠔⢈⠰⠪⡐⢬⠗⡄ ⡀⠐ ⠂ ⠐ ⠂ ⠁ ⠠ │ +│ ⠄⠐⠆ ⠁⠈⢅ ⠲⠂⢄⢈⠒⠤⡐⠵⢉⠒⠨ ⠉ ⠂ ⡄ ⠂⠑⠜⢁⣅ ⡄⠠⡠⠠ ⠂⢨⢁⠂⠄⠁⠻⡀⠐⢇⣄ ⠡ ⡀⠁ ⠂ ⠂ ⠠ │ +│ ⠄⠠⢀⠁ ⠂ ⢩⡐⡈⣀⡀⠘⠈⠈⠊⠂ ⠁ ⠐ ⠠⡀ ⠈ ⠈ ⠔ ⠄ ⠇ ⢢ ⠨⠂⠦ ⠠ ⡐ ⠂⠉⠐ ⡀ ⢡ ⢈ ⠐ ⢀ │ +│ ⠂⠠ ⡂ ⡀⠐⠁ ⠄ ⠈ ⠔ ⠐ ⠂ ⠁ ⢐ ⠰ ⠠ ⠄ ⢀⠂ ⠐ ⠈ ⠄⠐⢀ ⣀ ⡀⠈⠄⠠⠉⠁ ⢀⠄ ⡀⠁ ⡀ │ +│ ⡀⡂⠂ ⠈⠁⠁ ⠈ ⠐ ⠅ ⠤ ⢂ ⡀ ⢀ ⠂ ⠈ ⠄⠄ ⠈ ⠐ ⠂⢁ ⠠ ⠠ │ +│ ⠒⠂ ⠈ ⠁⠁ ⠌ ⢀ ⢀ ⠠ ⠁ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > hex-pulse 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ··▒░░ ░░░░ ░░░ ░░▒▒····▒▒█░░░░████░░░░████░░░░█▒▒····▒▒░░ ░░░ ░░░░ ░░▒·· │ +│ ·▒▒░░░ ░░░░ ░░░░░ ░░▒▒···▒▒▒██░░░████░░░░████░░░██▒▒▒···▒▒░░ ░░░░░ ░░░░ ░░░▒▒· │ +│ ▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒██████████████████████████▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒ │ +│ ░░···░░░░░ ░░░░░ ░░▒▒▒··▒▒▒██░░░█████░░░████░░░█████░░░██▒▒▒··▒▒▒░░ ░░░░░ ░░░░░···░░ │ +│ ░·····░░░ ░░░ ░▒▒····▒▒██░░░░███░░░░████░░░░███░░░░██▒▒····▒▒░ ░░░ ░░░·····░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒██████████████████████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ │ +│ ▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒███████████████▓▓▓▓▓▓███████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒ │ +│ ·▒▒░░ ░░░░ ░░▒▒···▒▒██░░░░████░░░██▓▓····▓▓██░░░████░░░░██▒▒···▒▒░░ ░░░░ ░░▒▒· │ +│ ▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒███████████████▓▓▓▓▓▓███████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒ │ +│ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒██████████████████████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ │ +│ ░·····░░░ ░░░ ░▒▒····▒▒██░░░░███░░░░████░░░░███░░░░██▒▒····▒▒░ ░░░ ░░░·····░ │ +│ ░░···░░░░░ ░░░░░ ░░▒▒▒··▒▒▒██░░░█████░░░████░░░█████░░░██▒▒▒··▒▒▒░░ ░░░░░ ░░░░░···░░ │ +│ ▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒██████████████████████████▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒ │ +│ ·▒▒░░░ ░░░░ ░░░░░ ░░▒▒···▒▒▒██░░░████░░░░████░░░██▒▒▒···▒▒░░ ░░░░░ ░░░░ ░░░▒▒· │ +│ ··▒░░ ░░░░ ░░░ ░░▒▒····▒▒█░░░░████░░░░████░░░░█▒▒····▒▒░░ ░░░ ░░░░ ░░▒·· │ +│ ▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒███████▒██████▒███████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > hologram 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒█▒▒▒▒▒▒░░░░ ▓ ▓ │ +│ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░ │ +│ ░▓░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░█░░░░░░ │ +│ ░░░░░░░ │ +│ ░░░░ ▓ ▓ ░░░░ │ +│ ░░░░░▒▒▒░░░░ ▓ ░░░█▒░░░░ ░░░░▒▒▒░░░░░ │ +│ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░▓ ░░▒▒▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░ │ +│ ░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒█░░▓ ░░░░░ ▓░▒▓▓▓▒░ ░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒░░ │ +│ ░░█▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░░░░▓ ░▒▓▓▓▒░ ░░░░░ ░░░▒▒█▓▓▓▓▓▓▓▒▒▒▒░░ │ +│ ░░░█▒▒█▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░█░ │ +│ ▓ ░░░░░▒▒▒░░░░ ░░░░▒░░░░ ▓ ▓ ░░░░█▒▒░░░░░ │ +│ ░░▓░ ▓░░░░ │ +│ ░░░░░░░ │ +│ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ │ +│ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░█░ │ +│ ▓▓ ▓ ░▓░░▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > julia 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ······························ │ +│ ······░░░░░░░░················░░░░░░░░······ │ +│ ················································ │ +│ ·····░░░░░░··························░░░░░░····· │ +│ ············································ │ +│ ········································ │ +│ ···································· │ +│ ·································· │ +│ ···································· │ +│ ········································ │ +│ ············································ │ +│ ·····░░░░░░··························░░░░░░····· │ +│ ················································ │ +│ ······░░░░░░░░················░░░░░░░░······ │ +│ ······························ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > kaleidoscope 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ████◆✦*∘· ··∘*✦◆◆◆✦✦*******✦◆◆◆████████◆✦****✦◆████████◆◆◆✦*******✦✦◆◆◆✦*∘·· ·∘*✦◆████ │ +│ ████◆✦*∘· ··∘**✦✦✦✦✦✦****✦✦◆◆███████◆◆◆✦*∘····∘*✦◆◆◆███████◆◆✦✦****✦✦✦✦✦✦**∘·· ·∘*✦◆████ │ +│ ███◆◆✦*∘····∘∘***✦********✦✦◆◆███◆◆◆◆✦✦**· ·**✦✦◆◆◆◆███◆◆✦✦********✦***∘∘····∘*✦◆◆███ │ +│ ███◆✦**∘∘·∘∘∘*****∘∘∘∘**✦✦◆◆◆✦✦✦✦**∘∘∘∘*∘· ·∘*∘∘∘∘**✦✦✦✦◆◆◆✦✦**∘∘∘∘*****∘∘∘·∘∘**✦◆███ │ +│ ███◆✦**∘∘∘∘*****∘∘∘··∘∘*✦◆███◆✦*∘· ··∘∘··∘∘··∘∘·· ·∘*✦◆███◆✦*∘∘··∘∘∘*****∘∘∘∘**✦◆███ │ +│ ◆◆◆◆✦***********∘·····∘*✦◆████✦*∘· ·∘***✦✦***∘· ·∘*✦████◆✦*∘·····∘***********✦◆◆◆◆ │ +│ ◆◆◆✦✦*****✦✦✦✦**∘· ··*✦◆████◆✦*· ∘*✦***✦✦***✦*∘ ·*✦◆████◆✦*·· ·∘**✦✦✦✦*****✦✦◆◆◆ │ +│ ◆◆✦✦*****✦✦◆◆✦✦*∘· ·∘*✦◆███◆✦✦*∘∘∘∘∘∘**◆◆∘∘◆◆**∘∘∘∘∘∘*✦✦◆███◆✦*∘· ·∘*✦✦◆◆✦✦*****✦✦◆◆ │ +│ ◆◆◆✦✦*****✦✦✦✦**∘· ··*✦◆████◆✦*· ∘*✦***✦✦***✦*∘ ·*✦◆████◆✦*·· ·∘**✦✦✦✦*****✦✦◆◆◆ │ +│ ◆◆◆◆✦***********∘·····∘*✦◆████✦*∘· ·∘***✦✦***∘· ·∘*✦████◆✦*∘·····∘***********✦◆◆◆◆ │ +│ ███◆✦**∘∘∘∘*****∘∘∘··∘∘*✦◆███◆✦*∘· ··∘∘··∘∘··∘∘·· ·∘*✦◆███◆✦*∘∘··∘∘∘*****∘∘∘∘**✦◆███ │ +│ ███◆✦**∘∘·∘∘∘*****∘∘∘∘**✦✦◆◆◆✦✦✦✦**∘∘∘∘*∘· ·∘*∘∘∘∘**✦✦✦✦◆◆◆✦✦**∘∘∘∘*****∘∘∘·∘∘**✦◆███ │ +│ ███◆◆✦*∘····∘∘***✦********✦✦◆◆███◆◆◆◆✦✦**· ·**✦✦◆◆◆◆███◆◆✦✦********✦***∘∘····∘*✦◆◆███ │ +│ ████◆✦*∘· ··∘**✦✦✦✦✦✦****✦✦◆◆███████◆◆◆✦*∘····∘*✦◆◆◆███████◆◆✦✦****✦✦✦✦✦✦**∘·· ·∘*✦◆████ │ +│ ████◆✦*∘· ··∘*✦◆◆◆✦✦*******✦◆◆◆████████◆✦****✦◆████████◆◆◆✦*******✦✦◆◆◆✦*∘·· ·∘*✦◆████ │ +│ █████◆*∘· ·∘**✦✦✦✦✦**∘∘∘∘∘∘∘***✦✦◆██████◆✦✦◆██████◆✦✦***∘∘∘∘∘∘∘**✦✦✦✦✦**∘· ·∘*◆█████ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > lava-lamp 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ░▒▒▒▒▒░░ │ +│ ░▒▓█████▓▒░ ░░░░░░░░░░░░░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > leds 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ 150 Hz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□] │ +│ 400 Hz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□] │ +│ 1 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□] │ +│ 2.5 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□] │ +│ 6 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ 15 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ 20 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 9 │ [■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 11 │ [■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 12 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 13 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 14 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 15 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 16 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 17 │ [■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 18 │ [■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > liftoff 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·· · · · + · + + · ++ │ +│ + · · ·+ · · · · · + · │ +│ · + · · · ·· · · ·· │ +│ · · · + ·· · ·· · + · · │ +│ ··· · + · · · ·· + ·· · · · + │ +│ · · · · · + + · · │ +│ + · · · · · · · · │ +│ · · ·· · · · │ +│ · + · + ░░░░ ▒▒▒▒▒▓·▓·▓██ ██▲\\ █ ▓▓▓ ▒·▒▒·░ ░░░░ · ·· · │ +│ · · · · · ·░·░░▒ ▒▒▓▓▓▓█ █/██\\ █ ▓·▒▒▒▒░░ · + · · │ +│ + · · + · · ·· ░░ ░▒▒▒═▓▓██|██|█ ▓ ▒▒░░ · · · · · │ +│ · ··· · ░ ░▒▒ ▓▓▓█|▓▓|▓▓▓▒▒▒░░ · · · · · · · │ +│ · · · ·░║═ ▓▓█|██| ▓▓ · + · + + + · │ +│ · ░ ░▒ ▓▓/| █|\\ ░ · + · · │ +│ · · · ░═ /_|██|_\\ ░ · │ +│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/__|__|__\\▀▀▀▀▀▀▀▀▀▀▀▀▀▀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > lissajous 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·························· ·· ●● ●●●●●●●●●●●●●●●●●●●●●●●●●● │ +│ · ●●·●●●●●●●·●●●●● • │ +│ ···· ●● ●●●●●●● ······· ·· •••• │ +│ ······ ●●●●●●● ······· •••••• │ +│ ●●●●●●··· ••••••··· │ +│ ●●●●● ······· ••••••• ····· │ +│ ●●● ······· ••••••• ··· │ +│ ● ······ •••••• · │ +│ ●●● ••••••• ●●●●●●● ··· │ +│ ●●●●● ••••••• ●●●●●●● ····· │ +│ ●●●●●●••• ●●●●●●··· │ +│ •••••• ●●●●●●● ••••••• ●●●●●● │ +│ •••• ●● ●●●●●●● ••••••• •• ●●●● │ +│ • ●●•●●●●●●●•●●●●● ● │ +│ •••••••••••••••••••••••••• •• ●● ●●●●●●●●●●●●●●●●●●●●●●●●●● │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > lorenz 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀ ⠂⠐⠐⠠ │ +│ ⠄ ⠈ ⠈ │ +│ ⢀ ⠁ ⡀⡀⣀ ⢀⠁ │ +│ ⢠⠔⠂⠂⠄⠄⡀ ⡀ ⠂ ⡀⠠ ⠂⠁ ⢑ │ +│ ⠨ ⢠⠊⣑⠴⣂⡌⡐⡠ ⡀ ⠠ ⠈ ⡀⠠ ⠁ ⡂ ⠈ │ +│ ⠡⠘⡀⡧⣫⣻⣿⣾⣮⡴⣠⢂⠄⡀ ⡀⠠ ⠂ ⠁ ⠂ ⠐ │ +│ ⠡⠈⢕⠿⣿⣿⣿⣿⣿⣿⣷⣕⣦⠅⡢⣀⡠⠐⠐ ⠁ ⠠⠈ ⠄ │ +│ ⠈⡀⠪⠻⢿⣿⣿⣯⣿⣿⣿⣿⣯⣫⡢⡁⠢⢄ ⡀⠂ ⠠ │ +│ ⠡⢈⠪⢙⠽⣻⡿⠿⠵⣣⠇⡽⠈⡇ ⠈⣆ ⠄⠁ ⡀ │ +│ ⠐⠄⡉⠒⠬⠭⠭⠤⠞⠁⢀⡇ ⠘⡄ ⡀⠄⠁ ⢀ │ +│ ⠈⠈⠒⠠⠤⠤⠤⠴⠊ ⠙⢄ ⣀⠠⠂⠁ ⠄ │ +│ ⠉⠒⠒⠒⠊⠈ ⠐ │ +│ ⢀⠠ ⠁ │ +│ ⢀⠠⠐ │ +│ ⣀⣀⣀⣀⠤⠤⠒⠈ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > manhattan 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ˙ │ ˙ • │ ˙˙ │ +│ │ │ ▀▀▀▀ ˙ •• ˙ │ +│ • • │ │·│· ˙•● │ +│ • │ ╱◆╲ │▪│░ •• ˙ │ +│ ••• ▀▀▀▀▀ ╱◆╲ ▀▀▀▀▀▀ ▄·▄▄ ˙ ˙˙˙ │ +│ ▀▀▀ ▀▀▀▀▀▀▀ ˙ █ █ ╱╱◆╲╲ ˙ · •│ │ ││ ▀▀▀ │ │ +│ █·█ ▪│ █ █ ╱╱╱◆╲╲╲ █▪░ │· █ │·││ █·█ ▀▀▀▀ │ +│ █ █░ ░ •│· █▄▄ █ █· ││░█ █·▄•▄• █ ▪▄·▄▪ ░ ░│ █░█ █░ █ │ +│ █·█░ ░ ▀▀▀▀▀▀ ░ █· ▪│░ █ █ •█░ █▄▄│▄▄█░ █· ·│ █ ▪░ ││ ░ ░ ░│• █░█ █▄▄█ │ +│ █ █░▀▀▀ │ ░ ░ ░█ │ ·█ █▄▄ █░ █ •│·█░·█▄▄░▄· █ ░ •▄░▄▄░ ░ ░ █ ░ │ █░█ █ █ ░█ │ +│ █·█░█ █ █ ·│ █░ ░ ░█·▄ ▄• ▄█░ ░█ ░█░ ░░█▪ │·█░ █ │ █ ░█ ░·││ █░ ░ █•▄ ▄▄ █░█•█ █ █ │ +│ ░░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░░░ ░ ░ ░ ░ │░ │ ░ ░ ░ ░░ ░ ░ │ +│ •• • ─• • ˙ •• •˙• • • • ˙ • • •• ˙• ˙•••• ˙ ─• •˙ │ +│ ˙── ·· · ˙· ─ ·· ·· ˙ · · · ˙ · · · ˙·─ · ˙···˙ · │ +│ ˙ · · ˙ ─ ─ ─ ─ ˙˙ ˙ ˙ · · ─ · ˙ │ +│ ˙ ˙˙ ˙˙˙· ·· ˙ ·─ ˙ ─ ˙ · ˙ ˙ ˙ ˙ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > matrix 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ # ( ) F R T 2 4 T ( H ) │ +│ S # 4 ) ( 4 2 I E 1 % * ) │ +│ G 2 * ) Q * ( % 9 ) 6 W │ +│ T 4 R E % D J * ) L X 8 │ +│ G ( E $ ( 0 % 9 O 8 M 8 │ +│ F * I @ * 6 9 ^ & Z B W │ +│ R 2 U * I 0 L ) A 7 C ) K * │ +│ 2 R 5 % ( B L ) ) ^ Z % 5 I * │ +│ A * E 0 V 6 ^ 8 ) C Q ) G # 2 Q 5 │ +│ A ) ) ) ) ) ) ( ( ( * * & & │ +│ A I 6 Y & ( R G # Q ) ^ A │ +│ A * C M Z ( $ 2 % V 7 N & │ +│ U 9 & C ( 3 Q 9 ) N 6 │ +│ @ ) V I ( 2 C K 8 K │ +│ X E ) 5 │ +│ ) R X * │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > mesh 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ────── │ +│ ────────── ───────────────── │ +│ ────────────────── ──────── ───────── ──────────────────────────── │ +│ ───────────────── ────────────────── ─────── ──────────────────────────────────────── │ +│ ──────────────────────────────────────── ────── ───────────────────────────────────── │ +│ ──────────────────────── ────── ────── ──────────────────────────────── │ +│ ──────────────────────────────────────── ───── ────────────────── ─────── ──── │ +│ ── ───── ───────────────────── ─────── ─────── ─────────────── ────── ─── │ +│ ─── ───── ───────────────────────── ────── ─────────── ────── ──── ── │ +│ ─── ────── ──────────────────────── ─────── ─────────────── ──── ─── │ +│ ── ─── ───────────────────── ──────────── ────── ─── ─── │ +│ ── ───────────────────────────────────────────── ──── ─── │ +│ ── ─────────────────────── ───── ── │ +│ ────────────── ───────────────── ──── │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > moire 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ··░▓▓▒░·░▒▓▒░··░▒▒░· ·░▒░· ·░▒▒▒░░░░░░░▒▒▒░░·· ·░▒▒▒░···░▒▓▓▒░·░▒▓▓▒░░▒▓█▓▒░░▓██▒░░ │ +│ ·░▓▓▒░·░▒▓▓░··░▒▓▒· ·░▒▒░· ·░░░·· ··░▒▓█████▓▓▒░░· ·░▒▓▒░··░▒▓▓▓░░░▒██▒░░▒▓█▓▒░▒▓█▓▒░ │ +│ ░▒▓▓▒·░▒▓▓▒··░▓▓▒···▒▓▒░· ·░▒▒· ·░▒▓▓▓▓▓▓▓▒▒░░░░░▒░░····░▓▓▓▒░░▒▓█▓▒░░▓██▒░░▒██▓░░▒██▓░ │ +│ ▒▓▓▒░░░▓▓▒░·░▓▓▒░·░▒▓▓░··░▒▒▒· ·░░░░····░░▒▒░·· ·░▒▒▒░··░▒▓▓▒░░▒▓█▓▒░▒▓█▓▒░░▓██▒░░▓██▒ │ +│ ▒█▓▒░░▒█▓▒░░▒▓▓▒░░▒▓▓▒··░▓▓▒···░▒▒░· ·░▒▓███▓▒░·· ··▒▓▓▒░░░▓██▒░░▒██▓░░▒██▓░░▒██▓░░▒██▓ │ +│ ▓█▓░░▒▓█▓░░▒▓█▓░░▒▓█▓░·░▓▓▓░·░▒▓▒░· ·░░▒▓▓██▓▒░░░▒▒▒░·░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓ │ +│ ▓█▓░░▒▓█▓░░▒██▒░░▒██▒░░▒▓▓▒░░▒▓▓▒··░▒▒▒░··░░▒░· ·░▓▓▓▒░░▒██▓░░▒██▓░░▒██▓░░▓██▒░░▓██▒░░▓██ │ +│ ██▓░░▒██▒░░▓██▒░░▓█▓▒░░▓█▓▒░░▓█▓▒░░▓▓▓░· ░▒░· ·░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒██▓░░▒██▓░░▒██▒░░▓██ │ +│ ██▓░░▒██▒░░▓██▒░░▓██▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░· ·░▒░ ·░▓▓▒░░▒▓█▓░░▒▓█▓░░▒▓█▓░░▒██▒░░▒██▒░░▓██ │ +│ ▓█▓░░▒██▓░░▒██▓░░▒██▓░░▓██▒░░▒██▓░░▒▓▓▓▒· ·░▒▒░░░░▒▒░··░▒▓▓▒░░▒▓▓▒░░▒█▓▒░░▒█▓▒░░▓█▓▒░░▓█▓ │ +│ ▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▓█▓▒░··░░▒▒▒▒▓████▓▒░· ·░▒▒░··░▓▓▒░·░▓▓▓░░░▓█▓░░░▓█▓░░▒▓█▓ │ +│ ▓██▒░░▓██▒░░▓██▒░░▒██▓░░▒▓█▓▒░░▒▓▓▒░· ··░▒▓▓▓▓▒░· ·░▒░· ·░▒▓▒░·░▒▓▓▒·░▒▓▓▒░░▒▓▓▒░░▒▓▓▒ │ +│ ▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▓██▒░░▒▓▓▓▒··░░▒▒▒░· ··░░▒░░░░░░▒▒░· ░▒▒▒···░▓▓▒··░▓▓▒░·░▓▓▓░·░▓▓▓░ │ +│ ░▓██▒░░▒██▓░░▒▓█▓▒░░▒▓▓▒░·░▒▓▓▒░· ··░░░▒▒▒▒▓▓▓████▓▓▒░· ·░▒░░ ·░▒▒░··░▒▓▒░·░▒▓▓▒·░▒▓▓▒░ │ +│ ░▒▓█▓▒░░▓██▒░░▒▓█▓░░░▒▓▓▒░··░▒▒▒░· ·░░▒▓▓███▓▓▒░· ··░░░· ·░▒▒░· ·▒▓▒░··░▓▓▒··░▓▓▒░· │ +│ ░░▒▓█▓▒░░▓█▓▒░░▒▓▓▓▒··░▒▓▓▒· ·░░▒░░·· ··░░░░▒░░░░▒▒▒▒▒░· ░░▒░· ·▒▒▒···░▒▓▒··░▒▓▒░·░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > motion-contour 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ •• •• •• •• │ +│ • ····· •••• ·········· •••• ····· • │ +│ •· ∙∙ ∙∙∙ · ∙∙∙∙∙∙∙∙∙∙ · ∙∙∙ ∙∙ ·• │ +│ •· ∙·· ···· ·· ···· ··∙ ·• │ +│ ••··∙· • ∙ • ∙ ∙ • ∙ • ·∙··•• │ +│ ••··∙∙ · • •••••••••• • · ∙∙··•• │ +│ •• ·· ∙∙ ·· • ∙∙•• ••∙∙ • ·· ∙∙ ·· •• │ +│ •• ··· ∙∙ ·· • ∙ • • ∙ • ·· ∙∙ ··· •• │ +│ • · ∙ ·· ••••∙ ∙•••• ·· ∙ · • │ +│ ••·· ∙∙∙∙∙∙∙ · • • · ∙∙∙∙∙∙∙ ··•• │ +│ •••••••••·····∙ ∙·····••••••••• │ +│ ••·∙ ·· ∙·•• │ +│ · ∙∙ · │ +│ • ·· • │ +│ •••• │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > nebula 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·····░░░░░░░▒▒▒▒▒▒▒▒▒▒▒✦▒▒▒░░░░░░░······ │ +│ ✦······░░░░✦░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░·····✦ │ +│ ·······░░░░░░░░░░░░░░░░░░░░░░░░░░░······ ··· │ +│ ···········░░░░░░░░░░░░░░░░░░░░░········ ··· │ +│ ··················░░░░░░░░············ ✦ ·· │ +│ ✦ ································ ✦ │ +│ · ·························· │ +│ ·· ····························· ✦ │ +│ ··· ··········░░░░░░░░░░░░░░······✦·· │ +│ ··· ✦ ··········░░░░░✦░░░░░░░░░░░········ │ +│ ···········░░░░░░░░░░░░░░░░░········ │ +│ ····················· ························░░░░░░░░░·········· │ +│ ·································································· │ +│ ··········░░░░░░░░░✦░✦░░░░░········✦······················ │ +│ ······░░░░░░░░░░░░░░░░░░░░░░░░░░··········· ✦ │ +│ ·····░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░······· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > neural-net 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ⢠⣤⣀⣀⣀⣀⣀⡠⠤⠤⠤⠤⠤⠤⠒⠒⠒⣒⢶⡾⠿⢶⣶⣶⠶⣒⣒⡒⠒⠒⠒⠒⣒⣒⡲⢶⣶⡶⠾⠿⠶⡶⣒⡒⠒⠒⠒⠒⠒⠒⠒⠤⠤⠤⣤⣤⣤⣴⣶⠤⢄⣀⣀⣀⣀⡀ │ +│ ⠈⠉⠻⣿⣫⠶⣒⡒⠒⠤⠤⠤⢤⣠⣔⣊⣩⢒⠕⢁⣀ ⢙⣳⣫⣶⠦⢾⡟⠋⠛⣛⠦⢶⣮⣓⣉ ⢀⣀ ⠈⣑⣪⣉⠵⠶⠤⢖⣒⠒⣉⡩⠝⠒⠉⡱⠁ ⠈⠉⠉⠉⠉⠑⠒⠒⠒⠒⠒⢴⣶ │ +│ ⠈⢷⡫⡒⢎⣉⠶⠲⢍⣁⢀⣔⡪⡴⢭⣻⠟⠛⠻⠿⣭⠭⢯⣝⣯⢝⠛⢛⢭⣟⣉⠭⠭⠝⠛⠛⠛⠛⠛⠒⠒⠤⢵⣦⢤⣔⣊⣉⣑⠒⠤⣤⣊ │ +│ ⣀⡠⣝⣮⡵⢬⡓⠲⣮⡛⠉⣱⠾⢶⢕⣁⢀⣀⣀⣠⣔⡫⠽⠦⣗⣿⡋⠻⢗⡢⠬⠭⣒⣤⣀⣀⣀⣀⡠⠤⠒⠉⠁ ⠉⠢⢤⡀⢉⠝⠉⠉⢛⡿⠿ │ +│ ⠸⠿⢿⣿⡿⣒⣒⡻⣲⡞⠻⣶⢞⠟⢭⣝⣉⠭⡽⠟⠛⠉⠉⠉⡩⠝⠋⠉⠉⠉⠻⣯⢍⣛⠭⡉⠉⣉⠝⠛ ⡸⠓⢤⣠⠶⠋ │ +│ ⠉⠚⠵⣲⢮⣩⡻⣻⣖⢷⠶⡯⢭⣝⣚⣤⣀⣀⡠⠔⠉ ⠑⢄⠉⡺⠿⢤⣀⣀ ⢠⠊⡠⢒⠕⠉⠑⠤⣀⣀ ⢀⣀ │ +│ ⢠⣤⣾⣿⣮⣓⣒⣲⣫⣋⣩⣶⣽⣄⠙⢍⠒⠬⢦⣄⣀ ⠉⠢⢄⡀ ⣠⡲⠕⣒⠭⠊⠉⠒⢄⣀ ⠈⠒⠤⣀⡴⠋⡠⣊⡡⠔⣒⠭⠊⠁ ⢀⡠⠔⠊ │ +│ ⠈⠉ ⢀⡴⢋⠔⠊ ⠈⠻⡭⣙⢍⠉⠙⠛⠛⠲⢖⡢⢤⡬⣖⣶⡋⣠⣔⡪⠤⠤⣒⠶⠚⠛⠛⠛⠫⣭⣕⢚⡾⡷⠺⢟⡢⠴⠮⠤⠤⠤⢄⣀⣠⣤⡀ ⢀⠤⠒⠁ │ +│ ⣴⡫⠊⠁ ⠈⢆⠑⠳⢄⣀⣠⣤⠶⠭⡽⢖⡷⢝⡉⣙⠵⣖⠚⠭⠤⢄⣀⣀⣀⣤⣮⣓⣝⠽⢿⣶⠴⢟⡛⠒⢶⣉⠭⠝⠛⠉⠉⠉⠈⠉⠉⠑⠒⡲⠴⠮⢅⣀⣀ │ +│ ⡠⠞⠁ ⠑⢄⠘⠛⠉⢙⣿⡻⣿⡭⣝⣋⡉⣉⣛⡭⣽⡿⣛⠛⠉⠙⠛⠉⣩⢞⡳⠒⢥⣤⠤⣔⣚⣉⠓⠪⢕⣢⢄ ⢀⠤⠒⠉ ⢉⣉⣑⣒⣒⣢⢴⣶ │ +│ ⠸⠿ ⢣⣤⣴⣿⣷⣭⣒⣒⣊⣉⣉⣉⣉⣒⣒⣪⣵⣿⣦⣤⣤⣿⣯⣓⣊⣉⣁⣀⣀⣀⣀⣀⠭⠭⠭⠶⠿⠿⢶⣶⠮⠕⠒⠒⠒⠒⠊⠉⠉⠉⠉⠁ │ +│ ⠈⠉ ⠈⠉ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > newton 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ███████████████████████████████████████████████████████████▓▓▓▒░▓▓▓▓██▓▓▓░▒▒▒▓▓▓▓▓████████ │ +│ ████████████████████████████████████████████████████████▓▓▓▒▒▒▒░▒▒░░▒▓▓▓▓▓▓▓██████████████ │ +│ ██████████████████████████████████████████████████████▓▒·▓▓▓▓▓▓░▓▓▓▒▓▓████████████████████ │ +│ █████████████████████████████████████████████████████▓▒▒▓█████▓▓▒▓▓███████████████████████ │ +│ ███████████████████████████████████████████████▓▒▓▓▓▓▒░░▒▒▓▓▓█████████████████████████████ │ +│ █████████████████████████████████████████████▒▓▒██████▓▓██▒▓██████████████████████████████ │ +│ ▓▓▓▓▓█████▓▓███████████████████████████████▓▓▒▓███████▒▓▓█████████████████████████████████ │ +│ ▓▓▒░▒▒▒▒▒▓▓▓▓▒▓██▓▓▒▒▓▓▓░▓▓█▓▓██████▓▓▓▒▒▒▓▒▒▒▒▓▒▒▒▓▓█████████████████████████████████████ │ +│ ▓▓▓▓▓█████▓▓███████████████████████████████▓▓▒▓███████▒▓▓█████████████████████████████████ │ +│ █████████████████████████████████████████████▒▓▒██████▓▓██▒▓██████████████████████████████ │ +│ ███████████████████████████████████████████████▓▒▓▓▓▓▒░░▒▒▓▓▓█████████████████████████████ │ +│ █████████████████████████████████████████████████████▓▒▒▓█████▓▓▒▓▓███████████████████████ │ +│ ██████████████████████████████████████████████████████▓▒·▓▓▓▓▓▓░▓▓▓▒▓▓████████████████████ │ +│ ████████████████████████████████████████████████████████▓▓▓▒▒▒▒░▒▒░░▒▓▓▓▓▓▓▓██████████████ │ +│ ███████████████████████████████████████████████████████████▓▓▓▒░▓▓▓▓██▓▓▓░▒▒▒▓▓▓▓▓████████ │ +│ ███████████████████████████████████████████████████████████▓▓▓▒▓▒▓▓▓██▓▓▓▒▓▓▓▓▒▒▓▓▓▓▓▓▓▓██ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ordered-dither 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ wOkwd0qCLvYxvfvjufcrcrYvUz0Cm0dZqQZYXfr1(]|1ffJCddW*Wka0Cjf[[<]-(|XUpp&#&hoOQnn))-)[\\|zc0L │ +│ ?(}t/vrXvUxv/t-?li^I:~<)\\zcOQqJLnx]-;I "'!l})vvQLwJLuu{{<~,!;~i[}f\\uxXrvjn|t)\\[)])-}-{+[-) │ +│ [{xnQCZJQux[[I; ^ :,?]fjJYQXCnx1)~~:i:>l]-){j/x/ntj(j)\\}|[1_}+-i->-~1}t\\cuYuUxx1)<>^;',^~+ │ +│ xCCwCOYUff{1~?~]+{}t(rfvjvrcjvjvtx/r1|[1+]+}_)1xjYYZCOUCjj}[l!,!:+-tfJCdZdOZun1}l!"l,~_\\\\z │ +│ 0JmQwQpZp0p0ZYLvctx\\j\\vuLCkbWo%o*mmzu)|]{?ttXUkh8W@W&ppYc|/[1]t/zXqwok&aowdCCuzjntcxzuCYQU │ +│ rJUpb&#BM&pqUX||]1-|\\zXqb8M@&%bbCJtf1|[//zvOZad*k*wdQOzUuXrcnYnYzLzLJZCwObmbwk0mJCxn\\j)f/c │ +│ JLdpMhomwXv(\\?[~{[/\\zcQCq0mCOXUxzjx\\x/r\\x/r/ujcnJY0Cw0mJ0cc\\t[}+}?||zXmm*h*pbCY//--l_<}{uc │ +│ (rtn\\j|t}([)_[_[<->-i_~}-()xtvnXjx||~~:I "'!I]{nxCJOXUrf-+,, . :,-[rjUY0zYrr]]!>^;"iI_-|1f │ +│ /YUpp*dhZZnr{}i}1vzOOkqdLQnr}1~_!?+{{xrXcQYLcJuvtn\\t1t)\\{/1\\{t(j/cnUz0UQzJxr{)+_l_>[[ │ +│ )vnJXCnu||~~:l\`;,_i_l+>]+11j/vxXjn||~~,; \`.I:?[rrJUOXUrf-+,, ,"+?f │ +│ ujvjcrufufx|r(\\[(?[~[_}]t/vuQJ0UQvu))~<"l,ii1)ucmOdOqUX\\|~<"I"!l}{rxQCZCZXXtf[}-~1[\\(nf │ +│ (nuQCppopkZZvu))~_>]-/fCCbk&a#ppXc||+-<[-\\tYXmmaqkmwYYrn(/)t)ftvjzvCcCYQXQU0XLXJxvtr1|{\\{f │ +│ ZwWW@B@&Wwwzv|f1/(vvQQakWo8aowdLQcJuXnUcJX0JOCq0pZkqhqaqdQmYYrvtr/cvQQaa%W@&8bbCY/f{(}ffUJ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > phosphene 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·░░░░░░░····░░▒▓████████▓▓▓▓▓▓▓▒▒▒▒▒░░░░▒░░░░░░░░░············░░░░▓▓▓▓▓▓▓▓▓▒▒▒▒░░░▒▒▒░▒▒▒░ │ +│ ░░░····░░░▓▓▓▓▓█▓▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒········░░░░░░░░░░░░░░░·░░░▒▒▒▒▓███████▒▒▒░░░·░░░ │ +│ ···░░░▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░··░░░░▒▒▒▒▓▓▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▓▓▓█████▓▒▒░░·· │ +│ ░▒▒▓████▓▒▒▒░░░▒░░░░░····░░░░▓▓▓▓███████████▓▓▓▓██████████████████▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓███▓▓▒░░ │ +│ ▓█████▓▓▒▒▒▓▒▒▒▒▒░░░▒▒▒▓▓▓▓▒▒▒▒░░░░▒▒▒▒▓▓▓█▓▓▓▒▒░░░░░▒▒▒▓▓██████████▓▓▓▒▒▓▒▒▒▒▒░▒▒▒▒▓▓▓▓▓▓ │ +│ ████▓▓▓▒▒▓▓▓▓▓▓▓▓▓▓▓█████▓▒░░···░░░░░▒▓▓███▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▓██████▒▒▒░░▒░░░░▒░░░▒▒▓▓▓▓ │ +│ ███▓▒▒▒▒▓▓▓▓▓▓▓█████████▓▓▒░░░░░░░░·░░▒▓▓████▓░░░▒▒▒▒▒░▒░░░░░····░▒▒▒▓▒▒░░░··░░░░░░░░▒▒███ │ +│ ▓▓▒▒░░░▒▒▒▒▒▒▒▓▓███████▓▓▒▒▒▒▒▒▒▒░▒▒▓████████▒██████▓▒▒░░░░░░░░··░░░▓▓▓▓▒░░░··░░░░▒░▒▒▓▓██ │ +│ ▒▒▒·····░░░░░··░░▒▓█████▓▒▒▒░▒▒▒▓▓▓▓▓████▓▓▓███▓▒▒░░··░▒▒▒▓▓▓▒▓▓▓███████▓▒▒░░▒▒▒▒▒▒▒▓▓████ │ +│ ▒▒▒▒░····░░░░░░··░░▒███████▓▓▓▓▓█▓▓▓▓▒▒░░░░··░░▒▒▒▒░░░░░░▒░░▒▒▓▓██████▒▒▒░░░░░░▒▒░▒▒▓█████ │ +│ ▓▓▓▓▓▓░░···░░░░░░·░░▒▒▓████████▓▓▒▒░░·····░░░░▒▒░░▒▒▒▒▒▒▒▓▓███████▓▒░░░··░░░░░░░░▒▒▓██████ │ +│ ▒▒▓█████▓▒▒▒░░░░░░░░▒░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░▒▒▒▓▓█████████████▓▒▒░░···░░░░░░····░▒▒▓████▓▓▓ │ +│ ▒▒▓▓▓███████▓▓▓▒▒▒░▒▒▒▒▒░░░·░░░░░░░░░░░░▒▓▓██████████████▓▓▒▒░░░░░░░░░░······░▒▓▓▓▓▓▒▒▒▒▒░ │ +│ ▒▒▓▒▒▓▓████████████▓▓▒▒▒░░▒▒░░░░░░░░░░░░▒░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒░░········░▒▒▒▒▒▒▒░░░░░░░░░░ │ +│ ░▒▒▒▒▓▓▓▒▓▓▓████████████▓▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░···░░░░░░░· │ +│ ▒▒▒░░▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▓▓▓█████████▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓█████████████▓▓▓▒▒░░░░░···░░░░░░░░·░░░░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > phyllotaxis 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀⢀ ⢀ │ +│ ⠠⠠ ⠌⠌⡀⠂⠌⡠⢈⠠⢐⠄⠄ │ +│ ⢀ ⠅⠅⠌⠨⠨⢀⠂⢌⠂⠄⠢⠐⠄⢂⠡⠡⠐ │ +│ ⢀⠨⠐⡀⠅⠅⠅⠅⡂⠂⠌⠂⡈⠄⠡⡈⠌⠄⢂⠡⠡⠨⠈⠄ │ +│ ⠨⠐⡀⠅⢂⢂⢈⢈⠐⠠⢁⢂⠑⠈⠌⠄⠄⠡⠨⢐ ⡂⡁⠅⡁⡂ │ +│ ⠅⠡⠐⡈⠄⠂⡐⡐⠠⢑⢀⠂⡐⠡⠂⡁⠌⠌⢈⠠⢀⠂⡂⡂⡂⠄⠄ │ +│ ⠠⢈⢂⠑⢈⠄⡂⠅⠄⠄⠨⠐⡐⡐⢀⠡⠡⠐⡈⡀⡂⢊⠂⡐⢐ ⠄⠅⡂⠄ │ +│ ⠠⢀⠂⠔⠠⢁⠠⢐⠈⢌⠂⡁⠄⢂⠂⠌⠌⡐⢀⠂⢄⠡⠈⠄⢂⠌⡐⠠⢐ │ +│ ⠈⢐⠠⠡⢁⠂⡂⡐⠠⢂⠂⠂⢂⠠⡐⠠⢐⢐⢐⠐⡀⠂⡂⡁⠄⠢⠐⠄⠢ │ +│ ⠠⡈⢐⢐⢐⢐ ⡁⠂⠌⠨⠠⢂⠄⢂⠂⡐⡐⠠⠠⠁⠄⠂⢊⠠⡈⠌⠄ │ +│ ⠐⠐⠠⠠⠐⡀⡂⠅⠅⢁⠨⠠⡐⠠⠂⠔⠠⠨⢈⠈⡈⠌⠄⢂⢐⠈ │ +│ ⠈⠌⠌⠄⠄⢂⠡⠈⠄⠡⠡⢀⠢⠁⠂⠅⡁⡂⡂⡂⢁⠡⢐ │ +│ ⠐⡈⠌⠄⠂⡑⢈⠠⡁⢂⠐⠌⢈⢐⢀⢂⠂⡂⡐⠈ │ +│ ⠁⠑⢈⠠⢐⠠⢁⠊⠠⢂⢂⠂⠐ │ +│ ⠈ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > pixel-crush 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓▓▓▓████████████████████████████████████████████▓▓▓▓████▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓ │ +│ ▓▓▓▓████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓▓▓▓▓████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓▓▓▓▓████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒ │ +│ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░ │ +│ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▓▓████████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░ │ +│ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▓▓████████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░ │ +│ ▒▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░ │ +│ ▒▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░ │ +│ ░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓████▓▓▓▓▒▒▒▒▓▓▓▓░░░░░░░░░░░░ ░░░░ ░░░░ │ +│ ░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓████▓▓▓▓▒▒▒▒▓▓▓▓░░░░░░░░░░░░ ░░░░ ░░░░ │ +│ ▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░▒▒ │ +│ ▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > planetarium 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ······░░░░░░░░░░░░░░░░░░░░░··························▓█▓······+ * ˙ ●· ····+·· │ +│ ·····░░░░░░░░░░░░░░░░░··░················ ··˙· ·· · · ····· ···· ·· · ·˙······· │ +│ ····░░░░░░░░░░░········░········ ·· ··········* ···+···* ······· ·· ···· ·· · · ·*···░░░· │ +│ ····░░+░░░·········░·········· ······+···· ····*···················▓███▓════····░·░░░░░░░░ │ +│ ··········░···░·····▒▓███▓▒················▒▒▒·········*······════▓█████▓════·······░░˙˙░˙ │ +│ ···················▓███·█▓··········+·▒·+▓▒·▒▒··▓······+····═════·▓·██▓════··········░░·░ │ +│ ·· ·· ······+··▒▓███▓▒·++···· ●···▒▓▒▓▓▓▓▓▓▒▓▒······*·····═════···············*····░░ │ +│ · · ····+··░·····░······· ·· ·▒▓▒▒▓▓███▓▓▒▓▒·+░··░······✦·· ················+····· │ +│ ·······█·█▓···░···░····░░···●····+·█·█·▓▓▒▓▓▓▓▓▓▒▓▓▒········+·······················*····· │ +│ · ····▒▓▒·░···░░····░·············+·····▓▓···▒▓·+···+········˙········▒▓▒·══··········· │ +│ ·········+···*·····+···········+····▒▓·▒·█·█·+●·····✦·······══·▓·██▓·══······ │ +│ · ····*+········· ······ ·······*····+·······═·˙·· ········═══·▒▓▒······ + │ +│ ···········░*···········˙···· ··+ ··········· ··*··+·· ·˙ ·· ···· ········ · + │ +│ · ·*·*································ · · ·· ··✦··· · ······· ·· ·· ˙ ● ˙ ˙ · │ +│ ·˙✦ ·· ··˙ · ˙˙ ·✦·· · · ·· · · · ·✦·*· · + · ˙ · │ +│ +· ˙˙ * · · · · ˙ ·˙ * ˙ + │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > pulse-grid 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ · ∘ ● ◉ ◉ ◉ ● ∘ · · · ∘ • ● ◉ ◉ ◉ ◉ ● • • ∘ ∘ ∘ ∘ • ● ● ◉ ◉ ◉ ◉ ● • · · · · • ● ◉ ◉ ◉ • ∘ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > radial-eq 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ⠐⣄⢤ │ +│ ⠙⣌⢎⢧⢧⢠⢀ ⡄⡠ │ +│ ⠘⢮⣫⣣⣯⣟⣾⣱⡇⡄ ⣠⣼⣜⡝⠁ │ +│ ⠐⢕⢵⡽⣻⣿⣿⣇⣷ ⣶⣟⢯⡮⡊ │ +│ ⠠⣜⣒⠭⣗⡬⣶⣕⡷⣵⣿⣿⣿⣿⠿⠿⣿⣿⣿⣿⣮⢄⣀⣀ │ +│ ⢒⡪⠭⢖⣻⣽⣿⣾⣿⣿⡟⠉ ⠉⢻⣿⣿⣷⣿⣯⣗⡀ │ +│ ⠐⠫⠭⠭⠵⠾⠿⢿⣿⡟ ⢻⣿⡿⠿⠷⠮⠭⠭⠔⠒ │ +│ ⠉⠉⠈⢉⣿⣿⣧⣀ ⣀⣼⣿⣿⡿⣿⣟⣯⠵⣒⡪⠭⢍⡁ │ +│ ⢀⡠⡷⡻⣿⣿⣿⣿⣶⣶⣿⣿⣿⣿⢟⢾⢝⡿⣚⠽⠒⠉⠉ │ +│ ⢀⡴⡪⡪⡺⠳⢽⣿⣿⠏⣿⣿⣿⣯⣞⢗⢕⢕⢯⡺⠁ │ +│ ⠊⠘⠁ ⠁⠁ ⢸⢏⡿⣽⣻⢫⡋⠁⠁⠁ │ +│ ⠈⢸⢱⢣⢳⢳⡀ │ +│ ⠘⠈⡎⣇⢧ │ +│ ⠑⠸⠈ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ribbon 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒▒▒▓▓▓▓▓▓████████ │ +│ ▒▒▒▒▓▓▓▓████████████ │ +│ ▒▒▒▓▓▓▓███████████████ │ +│ ▒▒▒▓▓▓▓████████████▓▓▓▓▓ │ +│ ▒▒▒▓▓▓██████████▓▓▓▓▓▓▓▒▒▒ │ +│ ▒▒ ▒▒▒▓▓▓█████████▓▓▓▓▓▒▒▒▒▒▒▒▒ │ +│ ▓▓▒▒▒▒▒ ▒▒▓▓▓█████████▓▓▓▓▓▒▒▒▒▒▒▒ │ +│ ██▓▓▓▒▒▒▒▒▒▒▒ ▒▒▒▓▓██████████▓▓▓▓▓▒▒▒▒▒▒ │ +│ ▓██████▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓██████████████████▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒ │ +│ ▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████████▓▓▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▓▓████████████▓▓▓▓▓█████████████▓▓▒▒ │ +│ ▒▒▓▓████████████████████████▓▓▓▒▒ │ +│ ▒▒▓▓▓███████████████████▓▓▓▒▒▒ │ +│ ▒▒▓▓▓▓▓███████████▓▓▓▓▒▒▒ │ +│ ▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒ │ +│ ▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ripple-tank 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⡏ ⢠⣿⠃ ⢠⣾⠁ ⠸⠿⠛ ⠰⣿⡷ ⠈⠉⠁ ⠿⠟ ⣴⣦⡀ ⢀⣀ ⢀ ⠘ │ +│ ⢀⣿⡏ ⢠⣿⠃ ⢀⣄ ⣀⡀ ⢀⣀ ⣿⣿ ⠉⠛⠁ ⠈⢿⣷ ⠸⣿⡄ │ +│ ⣼⡿ ⢀⣿⡏ ⢠⣿⠃ ⢀⡀ ⣿⡗ ⢸⣿ ⢀⣤⡀ ⢠⣤⡀ ⠹⣿⡀ │ +│ ⢠⣿⠇ ⣼⡿ ⢠⣿⡏ ⢀⣤ ⠐⢿⠿ ⠈⠁ ⢸⣿⠃ ⢠⣤⡀ ⢿⣷ ⠙⠃ │ +│ ⢸⣿ ⢠⣿⠇ ⣼⡿ ⢠⣿⡏ ⣰⣿⠆ ⠘⠛⠃ ⣶⣄ ⠛⠃ │ +│ ⣿⡏ ⢸⣿ ⢀⣿⡇ ⣾⡿ ⢀⡀ ⢀⣿⡄ ⣰⡇ ⣀ ⠉⠉ ⠹⣿ │ +│ ⣿⡇ ⣸⣿ ⢸⣿ ⣿⡇ ⢸⠟ ⢸⣿⠁ ⣿⡇ ⢰⣿ ⣠⡀ ⢀⣠⡀ │ +│ ⣿⡇ ⢹⡟ ⢸⣿ ⢿⡇ ⣾⡟ ⢰⣿⠃ ⣿⡏ ⢰⣿⠁ ⢀⡀ ⠘⠿⠟ ⢠⡀ │ +│ ⠘⠁ ⠁ ⢻⣿⠆ ⢠⣿⠃ ⣾⡟ ⢰⣿⠃ ⣿⡏ ⢀⡀ ⢸⣿ ⣿⡇ ⣿⡧ │ +│ ⣀ ⠁ ⣿⠃ ⢸⡿ ⢰⣿⠇ ⠿⠇ ⢸⣿ ⢠⣿⡇ ⣿⡇ │ +│ ⢿⣷ ⢀⣴⣦ ⠘⠃ ⠈⠟ ⢀⣿⡏ ⢸⣿ ⢀⣿⡇ │ +│ ⠐⣷⡄ ⠉ ⢸⣿⡆ ⠘⠛⠃ ⣠⡀ ⢀⣀ ⣾⡟ ⢀⣿⡏ ⣸⣿ ⢀ │ +│ ⢰⣦⡀ ⠹⣿⡄ ⠉ ⢰⣿⠇ ⣴⡄ ⢠⣄ ⠿⠿⠃ ⠁ ⣼⡟ ⢀⣿⡏ ⣸ │ +│ ⢰⣦ ⢿⣷ ⠈ ⣤⡀ ⣠⣤ ⠿⠇ ⠻⡿ ⣸⠟ ⣼⡟ ⢀⣿ │ +│ ⠈⣿⣧ ⠈⠻⠇ ⠻⣿⠆ ⠠⣶⣦ ⠻⠟ ⢀⡀ ⣀⣤⡀ ⢀⡀ ⣼⡿⠁ ⣾⡟ │ +│ ⠘⢿⡆ ⢀⡀ ⠉⠉ ⢿⡿ ⢰⣶⡄ ⠻⠟⠃ ⢠⣾⡿⠃ ⠰⠟ ⣼⡿⠁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > rotozoomer 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ │ +│ ▓▓███████▓▓▓▒▒▒░░░·······░░░▒▒▒▓▓████████▓▓▓▒▒░░░········░░▒▒▒▓▓▓███████▓▓▓▒▒▒░░░·······░░ │ +│ ▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓▓▒▒░░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░ │ +│ ▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓▓▒▒░░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░ │ +│ ▓▓████████▓▓▒▒▒░░░·······░░░▒▒▓▓▓████████▓▓▓▒▒░░░········░░░▒▒▓▓▓███████▓▓▓▒▒▒░░········░░ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ░░········░░▒▒▒▓▓▓███████▓▓▓▒▒░░░········░░░▒▒▓▓▓████████▓▓▓▒▒░░░·······░░░▒▒▒▓▓████████▓▓ │ +│ ░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░░▒▒▓▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓ │ +│ ░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░░▒▒▓▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓ │ +│ ░░·······░░░▒▒▒▓▓▓███████▓▓▓▒▒▒░░········░░░▒▒▓▓▓████████▓▓▒▒▒░░░·······░░░▒▒▒▓▓▓███████▓▓ │ +│ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > silk 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠉⠓⠦⠤⣄⣀⣀⣀⣀⣀⣠⠤⠴⠒⠒⠋⠉⣉⣉⡭⠭⠭⣽⣛⣛⣯⣭⣭⠭⠭⠤⠴⠒⠒⠚⠉⠉ ⠉⠓⠦⣄⡀ ⠈⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠓⠒⠲⠤⢤⣀⣀⣀⣀⣀⣀⡤⠤⠖⢋⣡⠤ │ +│ ⠉⠓⠲⢤⣀⡀ ⢀⣀⡤⠴⠚⢉⣡⠴⠒⣋⣩⠥⠤⠖⠒⠦⠬⢭⣉⣉⠓⠒⠒⠒⠒⠒⠒⠒⠋⠉⠉⠉⠉⠉⠉⠉⠓⠒⠦⣄⣀ ⠉⠓⠲⠤⣄⣀⣀⣀⣀⣀⣀⣠⠤⠤⠴⠒⠒⠒⠒⢒⣒⣒⣒⣒⣒⣒⣒⣒⣒⣒⣒⣋⡭⠤⠴ │ +│ ⠒⠦⢤⣀⣀⠉⠙⠒⠒⠒⠒⠒⠋⠉⣀⡤⠴⢚⣩⠴⠚⣉⣡⠤⠴⠒⠒⠒⠒⠒⠦⢤⣈⡉⠓⠒⠦⠤⣄⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡀ ⠈⠙⠲⢤⣀⡀ ⣀⣠⠤⠖⢚⣉⡭⠴⣒⣺⠭⠭⠭⠭⠭⠭⣗⣒⡒⠒⠒⠒ │ +│ ⠤⠤⠤⠤⢬⣉⣓⣒⡒⠒⠒⣒⣚⣩⠥⢖⣚⡭⠴⠒⠋⢁⣀⣠⠤⠤⠤⠤⠤⠤⠤⣄⣀⠉⠓⠦⢤⣀ ⠉⠉⠓⠲⠤⣄⣀⠉⠙⠒⠒⠒⠒⠒⠒⠚⠉⣁⣠⠴⢚⣩⠴⢚⣩⠥⠖⠒⠚⠉⠉⠓⠒⠲⠤⣍⡉⠓⠒ │ +│ ⠒⣒⣲⢯⣽⡿⠿⢿⣿⣿⣿⡷⠾⣟⣛⣛⡒⠒⠒⠋⠉⠉ ⣀⣀⣀⣀ ⠈⠙⠲⢤⣀⠈⠉⠓⠒⠦⠤⠤⠤⠤⠤⠤⠴⠒⠒⠒⠒⠒⠲⠤⠤⢬⣉⣓⡒⠲⠤⠤⠴⠒⣒⣋⡥⣖⣺⠭⠖⢋⣉⡤⠤⠖⠒⠒⠚⠙⠒⠒⠲⠤⣄⡉⠙⠲ │ +│ ⣩⢵⣺⠭⠖⠚⣉⣉⣀⣀⣀⣉⣉⠓⠲⠤⣍⣉⠉⠉⠉⠉⠉⠉⠉⠉⠁ ⠈⠉⠉⠙⠒⠦⣄⣈⠉⠓⠦⣄⣀⡀ ⢀⣀⣀⡤⠤⠴⠒⣒⣒⡯⣭⣿⠷⣿⣿⣛⣻⣿⣿⠷⢿⣛⣓⣒⣚⣉⣉⣀⣀⣀⣠⠤⠤⠤⠤⠤⠤⢤⣀⣀⠉⠙⠲ │ +│ ⣚⣩⠤⠖⠚⠉⠁ ⠈⠉⠓⠲⢤⣈⠉⠙⠒⠒⠦⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢤⣀⣈⠙⠲⠤⣄⣀⠉⠉⠉⠉⠉⠉⢀⣀⡤⠴⢒⣫⠵⣚⡭⠗⠚⣉⣁⡤⠤⠤⠤⣌⣉⡙⠒⠦⠤⣄⣀⡀ ⠈⠙⠒⠦ │ +│ ⠤⠤⠤⠴⠒⠒⠋⠉⠉⠉⠉⠉⠙⠒⠲⢤⣀⡈⠙⠒⠦⣄⣀⡀ ⢀⣀⣀⣀⣀⣈⣉⣓⠲⠤⢬⣉⣉⣓⠒⣋⣉⣩⠤⢖⣺⠭⢖⣋⡥⠖⠚⠉⠁ ⢀⣀⣀⣀⡀ ⠉⠙⠲⢤⣀⡀⠉⠉⠉⠙⠒⠒⠋⠉⠉⠉⠉⠉⠓⠒⠒⠦⢤ │ +│ ⣀ ⠈⠉⠙⠲⠤⣄⡀⠉⠙⠒⠦⠤⠤⠤⠤⠤⠖⠒⠋⠉⣁⣠⠴⠒⣋⡭⠵⠒⠒⢚⣉⣉⡓⠒⠒⠲⠤⠤⢤⣀⣀⣀⣀⣀⣀⣀⡤⠤⠤⠤⠤⠤⢤⣀⣀ ⠉⠓⠦⣄⡀ ⢀⣀⣀ │ +│ ⠈⠉⠓⠒⠦⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣄⣀⣀⠉⠙⠲⠤⢤⣀⣀⣀⣀⣠⠤⠴⠒⠋⣁⡤⠖⢋⣡⠤⠖⠋⠉⠉ ⠉⠉⠙⠒⠲⠤⣄⣀ ⠈⠉⠓⠲⢤⣀ ⠉⠙⠒⠲⠤⠤⠤⠴⠒⠒⠋⠉⠉⢀⣀ │ +│ ⠒⠦⣄⡀ ⠈⠉⠓⠒⠦⠤⢤⣀⣀⣀⡤⠤⠖⠚⣉⡥⠴⠚⠉⣀⡤⠴⠒⠚⠉⠉⠉⠉⠙⠒⠲⢤⣀⡀⠈⠉⠓⠒⠲⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣄⣀⡀⠈⠙⠒⠦⢤⣀⣀⣀⣀⣀⣀⡤⠴⠒⠋⢉⣠ │ +│ ⣀⡀ ⠉⠙⠒⠒⠲⠤⠤⠖⠒⠒⠒⠋⠉⠉⠉⠉⢉⣉⣉⣉⣓⣒⣒⣒⣒⣒⣒⣒⣋⣩⠥⠴⠒⠚⠉⠁ ⣀⣀⣠⠤⠤⠤⠤⠤⣄⣀ ⠉⠓⠦⣄⣀ ⠉⠙⠒⠲⠤⣄⣀⣀ ⢀⣀⣠⠤⠖⠚⣉⡤ │ +│ ⠉⠓⠦⢤⣀⡀ ⣀⣀⡤⠤⠖⠚⠉⣉⡤⠤⠖⣒⣚⡭⠭⠭⢷⣒⣒⣒⠒⠒⠒⠒⠒⠚⠉⠉⠁ ⠈⠉⠓⠦⢤⣀ ⠈⠉⠓⠒⠒⠦⠤⠤⠖⠒⠒⠒⠒⠋⠉⠉⠓⠒⠒⠒⠦⠤⠤⠬⣍⣉⣩⠤⠤⠴⢚⣉⣡⠤ │ +│ ⠙⠲⠤⣄⣀ ⠉⠉⠉⠉⠉⠉⠁ ⣀⣠⠴⠚⣉⡥⠖⢚⣉⡥⠴⠒⠒⠒⠒⠲⠤⢬⣉⡉⠓⠒⠒⠦⠤⠤⠤⠤⠖⠒⠒⠒⠒⠒⠒⠦⠤⣄⣀ ⠈⠙⠲⢤⣀⡀ ⢀⣀⣠⠤⠴⠒⠒⢋⣉⣩⠭⢭⣭⣭⣭⣭⣭⣭⡭⠭⠭⠤⠤⠖ │ +│ ⠲⠤⠤⣄⣈⡉⠓⠒⠒⠒⠒⠒⠚⢉⣁⡤⠖⣋⡥⠖⠚⣉⣠⠤⠤⠖⠒⠒⠒⠲⠤⢤⣀⡉⠓⠲⠤⣄⣀⡀ ⠈⠉⠓⠦⢤⣀ ⠉⠉⠉⠙⠋⠉⠉⠉ ⢀⣠⠤⠖⣋⡭⠴⣚⣩⠭⠖⠒⠒⠒⠒⠲⠭⣍⣙⡒⠒⠒ │ +│ ⠤⠖⠒⣲⣤⣭⣭⣽⣶⣶⣶⣯⣭⣭⣖⣚⡭⠥⠖⠚⠉⠁ ⠉⠓⠦⣄⡀ ⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠙⠒⠒⠲⠤⣄⣈⡉⠓⠒⠦⠤⠤⠤⠖⠒⢋⣉⡤⢖⣋⡥⠖⠋⠁ ⠉⠙⠒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > skyline 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · · ▀▀▀▀▀ · · │ +│ █▀▓▓█ · • │ +│ · █▒▓▓█ · · · •••· │ +│ + + █·▓▀█ · • · • • │ +│ · █▓▓·█ │ │ │ │ │ +│ ·+ · █▒▓▓█ │ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀▀ │ +│ · · • █▀▒▓█ ▀▀▀▀▀▀▀▀▀ ·█▒▓▒▓·▓·█ █·▓▓▓·█ ·▓▀▓· █·▓▀▓▓ │ +│ │ │ █·▓·█ █·▓·▓▓▓▓█ █·▓·▓▓▓▀█ █▓▓▓▒▓█ ▓▓▓▓▓ █·▓·▓▓ │ +│ ▀▀ █▀▓▒▓·█ █▓▓·▓·█ █▀▓▓█ █▀▓▓▓▓▓▒█ █▀▒▀▒▓▓▀█ █▓▓·▓▓█ █▀▓▓▓▀█ █·▓▀▓▀ │ +│ ▄▄ █▀▓▓▓▓█ █·▓·▓▀█ █·▒▓█ █·▓▀▓·▓▀█ █▓▓▀▓▀▓▀█ █▓▓▓▓·█ █▀▓·▓▓█ █·▒▓▓▒ │ +│ ·││ · │ █▓▓▓▓▀█ █·▓▀▓▓█ █▀▓▓█ █▓▓·▒▒▓·█ █▀▒▀▓▀▓▀█ █▀▓▓▓·█ █▀▓▓▒▀█ █▀▓▀▒· │ +│ ▀▀▀▀▀ ▀▀▀▀▀▀▀ █▀▓▒▓▀█ █▓▓▀▓▒█ █▓▓▓█ █▓▓▀▒▓▒·█ █▓▓▀▓▓▓▓█ █▀▓▀▓·█·█·▒·▒▓█ █▀▓▓▓· │ +│ █▀▒▀█ █▀▓·▓▀▓▓█ █▓▓▓▓▓█ █▀▓·▓▓█ █▓▓▓█ █·▓·▓▀▓▓█ █▓▓▀▓▒▓·█ █▓▓▀▓·█ █▀▓▀▓▓█ █·▓▀▓▀ │ +│ ·▄▀▄▀ ▀▄▀▄▀▄▄·▀ ▀▄▀▄▀▄▀ ▀·▀▄▀▄▀ ▄▄▀▄· ▀▄▀▄▀▄▀▄▀ · ▀▄▄▄▄▄▄▄▀ ·▀▄▀▄▄▄▄ ▀▄▀▄·▄▀ ▀▄▀▄▀▄ │ +│ • • • • •█ • ••█ • • │ +│ ────·· · ────˙· ·────· · ──── · ───˙ ·────· ·˙ ──── · ────· ──── ────··· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > spectrogram 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ░░ ░░ │ +│ ░░ ░ ░░ ░ ░░░ ░░ │ +│ ░░ ░░░ ░░░ ░░░░░ ░░░░ ░░░░ ░░▒▒░░░░░▒▒▒░ │ +│ ░░░░ ░░ ░ ░░░░ ░░░ ░░░░ ░░▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░▒▒▓▓ │ +│ ░░░░░ ░░░░░ ░ ░░░░░░░▒▒▓▒▒▒▒▒▓▓▓▓▓▒▒▒▓▓▓▒▒▒▒▒▒▓▓▒▒▒▒▓▓▓▓▓▓▒▒▓▓▓▓▓▒▒▒▒▓ │ +│ ░░░░░░░░ ░░░░░ ░░▒▒▒░░░▒▒▓▓▒▒▒▒▒▓▓▓▒▒▒▒▒▓▓▓▒▒▒▒▓▓▓▓▓▒▒▒▓▓▓▓▒▒░░▒▒▒░░ ░░░░░░ ░░░▒▒▒░░░▒ │ +│ ▓▒▒▓▓████▓▓▓████▓▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓▓██▓▓▓▒▒▓▓▓▒░░░░░░░░ ░░░░░░░░░░▒▒░░░░░░░░░ ░░░ │ +│ ███████████████████████████▓▓▓▓▓▓▒▒░░░▒▒▒░░░░░▒▒▒░░░░░▒▒░░ ░░░░ ░░░ ░░░▒░░░░░░▒░░░ │ +│ ██████████████████████████████▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒░░ ░░░░ ░░░░░░░░▒▒▒▒░░░░▒▒▒░░░░▒▒▒ │ +│ ▓▓▓▓▓▓▒▒░▒▒▒▒▒▒▒▒▒▓▓███▓██████████████▓▒▒▒▒▒▒▒░░░▒▒▒▒▒░░░▒▒▒▒░░░░░░░░░ ░░░▒░░░░▒▒▓▓▓▓▓███ │ +│ ▒▒▒▒▒▓▓▒▒░░▒▒▒▒▒░░░░▒▒▒▒▒▒▒▓▓█████████████████▓▓▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▓▓▓▒▒▒▒▒▓▓▓▓▒▒▓▓███████ │ +│ ░░░░░░▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▓▒▒▒▓▓███████████████████▓▓▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓██████████████▓ │ +│ ▓▒▒▒░░░▒▒▒░░░░░░▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒▓▓████▓█████████████████████████████████████████████▓ │ +│ ▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒░░░░░▒▒▒▒▒▒▒▓▓███▓▓████████████████████████████████████▓▓▒▒▒▒▒▒ │ +│ ░▒▒▒▒░░░░░▒▒▒▒░░▒▒▓▓▓▒▒▒▒▒▓▓▒▒░░░▒▒▒▒░░░░▒▒▒▒▒▒▓▓████████████████████▓▓▓████▓▓▓▓▓▓▓▓▓▒▒▒▒▓ │ +│ ▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒▒▒░░░▒▒▓▓▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒░░▒▒▒▓▓▒▒▒▓▓███▓▓▓█████▓▓▒▓▓▓▓▒▒░░▒▒▒▒▒░░▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > spinning-donut 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ~-:--;:-. =, .~=.,~;,-:-~ │ +│ ;;=;!=;..!;...*=. . *....!..:=!:=:; │ +│ !!*!-,*...#*....#..~..#.....#...!*-=*=! │ +│ #*;#~-#,,..$.,...$..#...$..,..$..,#-:*#*# │ +│ $@*@**@*!*!@**;;:@~~@~~~@;;*=*@**!@#*@@@@ │ +│ @@@@@@@@@@@**!!=@==@==@=!!**@$@@@@@@@@@ │ +│ @@@@@@@@@@@$#*@@$ $ @$#$$@@@@@@@@@@ │ +│ @@@@@@@@@ @@ $@@@@@@@@@@@ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > spirograph 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ⢠⢄ ⡞⢧ ⡼⢳ ⡀ │ +│ ⣀ ⠸⡀⠑⡇ ⢧⡸⠁⢸⢀⡴⠋⡇⢀⣀ │ +│ ⠱⡉⠒⠤⣇ ⡏⢢⡰⢇⢀⢼⠃⢀⡼⠛⢉⠞ │ +│ ⢀⡤⢄⣀⣙⣄ ⢸⠙⡧⣴⣑⣼⣧⢼⠋⢡⢃⣠⣇⣀⡠⢤⡀ │ +│ ⢑⣦⣀⣀⣘⢭⠭⣯⣿⠷⠛⠛⠿⣾⡭⡯⡽⣁⣀⣀⣴⠋ │ +│ ⠻⢭⣀⡈⠑⣤⡬⢳⣿⠃ ⠘⣿⠾⢥⡔⠊⠁⣀⠭⠟ │ +│ ⠸⠭⢤⡤⢮⠥⢤⠧⢼⣿⣶⣤⣤⡶⡿⡯⠽⡭⠶⡤⠤⠤⠬⠗ │ +│ ⢠⣋⡴⠥⢤⣛⠮⡟⢻⠝⢯⡝⣻⡕⢳⠤⣙⡄⠈⢢⡀ │ +│ ⠮⠖⢪⠏⢀⡰⡏⠙⣆⠜⢣⡇⠈⢹⢤⡀⠘⡕⠒⠵ │ +│ ⠯⠒⠁ ⡇⢠⢏ ⡜⠑⡄⢸ ⠈⠒⠽ │ +│ ⠷⠃⢸⣰⠃ ⠘⠾ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > starfield 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ +*✦ +++ *+ · + * + **━** │ +│ *++ * + ++ + ++ * ━** ** │ +│ + * ++ *++ + *+ + + ** ·· │ +│ +++* * **· ++ ++ * + ✦*++ ✦ * │ +│ *++ ━*━* *** ++*✦+ + *+ *** ++ ** │ +│ ***━* ++* ++ + **+ *+ · ·━ ++ + *++*++ │ +│ ━***+ + · + ·++ ━ + ***━********━**** │ +│ ++*✦✦✦*+ + ˙ · · ***━++✦✦*++· │ +│ ++*✦*+ ········· + ++ *·++·*+··*━****······ │ +│ + ·+++····+·+✦ + +··+ *+━·++·············+· +++*✦✦✦+ │ +│ ****━*** ············+· *+ ++* +·━+··········++····· │ +│ ** ···············+ ·* *+ ✦* ···+····+··*✦*+ * │ +│ ············*·✦+ * ++ +· ··✦····++ ** +++ ** │ +│ ━***·····✦**··*·+✦· + + + * + ✦*++ │ +│ ✦✦*++ **** ···+*++·**··+·· ✦ + * + + ━ ··· │ +│ ++* ········**····· + ++ * ·+ * ++ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > stormfront 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▓░░░░░░░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒ │ +│ ▓█▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒█▒▒▒▒█▒░░░░░░░░░░░░░░░▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒░▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓░░░░░░░░▒▒░▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▓░░░░░░░░█░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▓▒▒░▒▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░█░░░░░░░░░░█░█░░░░░▒▒▒▒▒▒░░░░░░░░▓░░░░░░░░░░░░░░░▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▓▒█▒▒▒▒▒█▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░▓░░░░░░░░░░░░░█░░░░░░░█░░░░░░░░░░▓░░░░░▒▒▒▒▒▒ │ +│ ▒▒▒▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▓░░░░█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░ │ +│ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░░░░░░░░░░░░░░▓░█░░░░░░░░░░░░░░▓░░░░░░░░░░░░▓░░░░░░░░░░░░░░░░░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░··· █ █ ·····░░░░█░░░░░░·······▓····· █ ······░▓░ │ +│ ░░░░░░░░░░░░░░░░░░░░▓░░░░░░░··· ····░░░░░░··░······ █ ▓ │ +│ ·····░░▓░░░░░░░░░░░░█░░░░··░ ░ ······ ▓▓ ▓ ░ █ │ +│ ·······▓··░░░░░░░░░···· ▓ ▓ ▓▓ ▓ ▓░ ░ │ +│ ·█···········▓ ▓ ▓░░ ██ █ ▓ █ │ +│ ··· ··· ··· ░··· ···░ ··· █ ░▓··· ··· ··· ··· │ +│ █ █ █ ▓█▓ ░ ▓ │ +│ ░ ░ ⌁ ⌁ ⌁ ⌁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > sumi-ocean 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ▓ │ +│ · · ▓████▓ │ +│ ⌁ ▓██████▓ │ +│ ⌁ ▓████▓ │ +│ │ +│ ───·░·░──░·░░──···░──···───·░·───··░──·─··──░···───···───····──░░────···───···──· ·░──·░░░ │ +│ ─────────────░─────··───···─────·───────────────░·────··░────··───────────────·─────··──── │ +│ ░──━━░░───────━━░──────░░░━━───░░░░───━━░░░░░───░░░░─░────░━░────────━━━────░░░░─━━─░─░░░─ │ +│ ━·━▒▒━━━━━━━━━··━━━━━━━━━━▒····▒━━━━━━▒▒━━···━━━━━━━━·░░··━▒━━━━─░──··▒▒━━━━━━━━━···━━ ━━━ │ +│ ━━····▒▒▒▒▒▒·━━━···▒▒▒▒▒▒▒▒━━━━·▒▒▒▒▒▒░▒··━━━▒▒▒▒▒▒▒━━··━━━░▒▒▒───━━━━·▒▒▒▒▒▒▒▒··━━━▒▒━▒▒▒ │ +│ ▒▒━━━━━━━━━·━···━━━━━━━━━━━▒·▒▒━━░▒━━━━━━━▒▒▒━━━▒▒··▒▒━━▒▒▒━━━▒░···──▒─━━━━━▒····▒▒▒━━━━━━ │ +│ ···▒▒▒▒▒▀▀━━▓━━━▒▒▒▒▒▒▒▒····━━━━━━━▒▒▒····▓▓━▒▒━━━━━··▒▒▒▓▓━━━━·━━━━━▒▒▒▒▒▒▒·━━━━━━▒▒▒▒▒▒· │ +│ ━━━━━━━━━━▀▀▒▓▓▓━━━━━━━━━━━━▓▓▓▓▓▓▀━▓·━━━━━━▓━━▓▓▓▓▓▀━·▓▓━━▓▓▓▓━▓▓━▓━━━━▒▓▀▀▀▓▓▓▓▓▓━━━━━━▀ │ +│ ▓▓▓▓▓▓▓━██━━▀▀▒▒▓▓▓▓▓▀▀▀▓▓▀▓━━━━━━━▀▀━▓▓▓▓▓▓▓▓▓▀▀▀▀▀━━━━━▓▓▓▒ ▓▓▓▓━━━━▓▓━▀━━━▀▀▒▒▒▒▓▓▓▓▀▀━ │ +│ ▀▒▓▓▒━━█▒▓██━━━▓▓▒▓▓━━━━▀▀━━███████━━▀▀▓▓▓━━━━━━━━━━█████━▒━▀▀▀▀▀▀━━██━━━━███━━━━━▓▓▒▓▀━━█ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > tesseract 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠑⢄ ⡠⠊ │ +│ ⠑⢄ ⡠⠊ │ +│ ⠑⢄⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡠⠊ │ +│ ⢸⠑⣤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣤⠊⡇ │ +│ ⢸ ⡇⠑⢄ ⡠⠊⢸ ⡇ │ +│ ⢸ ⡇ ⢱⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⡎ ⢸ ⡇ │ +│ ⢸ ⡇ ⢸ ⡇ ⢸ ⡇ │ +│ ⢸ ⡇ ⢸ ⡇ ⢸ ⡇ │ +│ ⢸ ⡇ ⢸ ⡇ ⢸ ⡇ │ +│ ⢸ ⡇ ⡸⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢇ ⢸ ⡇ │ +│ ⢸ ⡇⡠⠊ ⠑⢄⢸ ⡇ │ +│ ⢸⡠⠛⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠛⢄⡇ │ +│ ⡠⠊⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠑⢄ │ +│ ⡠⠊ ⠑⢄ │ +│ ⡠⠊ ⠑⢄ │ +│ ⡠⠊ ⠑⢄ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > torus-knot 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ⣀⡤⠖⠚⠉⠉⠉⠓⠲⠤⣀ │ +│ ⢀⣠⠤⠖⠒⠒⠒⠒⠒⠲⠤⢤⣀⡀ ⢀⠞⠁ ⠈⠙⢦⡀ │ +│ ⡴⠉ ⠉⣹⠻⢄⡀ ⠙⢦ │ +│ ⢧ ⣰⠃ ⠙⢦⡀ ⢳⡀ │ +│ ⠈⠳⣄ ⣰⠃ ⠙⢆ ⢳ │ +│ ⠈⠳⢄⡀ ⢀⣠⠞⠁ ⠘⣆⣀ ⠈⡇ │ +│ ⢀⡰⠚⠉⠙⠢⣄ ⠈⢳⡀ ⡇ │ +│ ⣠⠊ ⠙⢦⡀ ⢳⡀ ⢠⠃ │ +│ ⢀⡞⠁ ⠙⣄ ⢇ ⢠⠎ │ +│ ⢠⠎ ⠈⠳⣄ ⢸ ⡰⠋ │ +│ ⢀⠏ ⠈⠳⢤⣀ ⣀⡼⡎ │ +│ ⡜ ⠈⠙⠒⠒⠒⠒⠒⠚⠉ ⢰⠃ │ +│ ⢰⠃ ⢸ │ +│ ⢸ ⡎ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > tunnel 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ │ +│ ░░░░░░░░░░░░░░ ░░░░░░░░▒▒▒▒▒▓▓▓▓▓███████████▓▓▓▓▓▓▒▒▒▒▒░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓███████████████████████▓▓▓▓▓▓▒▒▒▒░░░░░ │ +│ ░░░░░░░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░ │ +│ ░░░░░░░░▒▒▒▒▒▓▓▓▓███████▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒░░░ ░▒▒▒▓▓█████▓▓▓▓▓▒▒▒░░░░░░ │ +│ ░░░░░▒▒▒▒▓▓▓████████▓▓▒░ ░▒▓███▓▒░ ░░░▒▓████▓▒░░░░▒▒▓▓█████████▓▓▓▒▒▒▒░░░░░░░ │ +│ ░░░▒▒▒▓▓▓████████▓▓▒░ ░▒▓▓▒░░▒██░ ▓█▓░ ░▓▒ ▓▓░ ░▒██▓▒░░░░▒▓▓█████████▓▓▓▒▒▒▒▒░░░░ │ +│ ░░░▒▒▒▓▓▓██████▓▓▒░░░▒▓██▒ ▒█░▓▒▓▒ █▓█░░▓▓▓▒░▒▒▓▒█▓ ░▒▓▓▒ ░▒▓▓███████▓▓▓▒▒▒▒▒░░░░ │ +│ ░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ▒▓██▓░░▒██▒░██▓░░░ ░▓▓░▒▓▓░ ░▓███▒░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░ │ +│ ░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░▒▒▓▓▓▒░ ░░░░░░░░░▒▓▓▓▓▓▒░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░ │ +│ ░░░░░▒▒▒▒▓▓▓▓███████▓▓▓▒▒░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒▓▓▓███████▓▓▓▒▒▒░░░ │ +│ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓██████████████▓▓▓▒▒▒▒░░░░░ ░░░░▒▒▒▓▓███████████▓▓▓▒▒▒░░░ │ +│ ░░░░░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▓█████████████▓▓▓▓▓▓▓▓▓▓▓▓█████████████▓▓▓▓▒▒▒░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░ │ +│ ▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ │ +│ ▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > twister 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ██████████████████████████ │ +│ ████████████████████████████ │ +│ ███████████████████████████ │ +│ ▓▓▓▓▓▓██████████████ │ +│ ▓▓▓▓▓▓█████ │ +│ ▒ │ +│ ▓▓▓▓▓▓▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓ │ +│ ▒▒▒▒▓▓▓▓▓█ │ +│ █▓▒ │ +│ ███████▓▓▓▓▓▓▓ │ +│ ████████████████▓▓▓▓▓ │ +│ ███████████████████████████ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ultracode 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > xor-texture 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / micro / ascii > braille-wave 1`] = ` +"KEXP 90.3 FM · playing + + +.--::--:. +.--..-:. :-: +-- ..+-*. :-- .-+-+-: + .:: ::. +" +`; + +exports[`receiver baseline / micro / ascii > gallop-fine 1`] = ` +"KEXP 90.3 FM · playing + + .-+- + :-::.:#: + -: .**-*++ + +#:. .. +" +`; + +exports[`receiver baseline / micro / ascii > manhattan 1`] = ` +"KEXP 90.3 FM · playing + +## . oo o |o@o. +#### #||#. /|.. ###@..## +#..* ##..../◆\\.o|.....## + -o. .oooo. oo o .o.o..o +" +`; + +exports[`receiver baseline / micro / ascii > pulse-grid 1`] = ` +"KEXP 90.3 FM · playing + +. ∘ @ o o o o o o @ o . +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o o o o o @ o . +" +`; + +exports[`receiver baseline / micro / ascii > ultracode 1`] = ` +"KEXP 90.3 FM · playing + + + + + +" +`; + +exports[`receiver baseline / micro / unicode > braille-wave 1`] = ` +"KEXP 90.3 FM · playing + + +⢀⡤⠲⠤⣀⣠⢤⣀⡀ ⣰⢀⡤⣄⡀⢀⣠⣀⡀ ⣀⡤⠤ +⠴⠋ ⠈⠈⠳⣄⡼⠁ ⠉⠓⠋ ⠈⠙⢦⣠⠞⡤⠤ + ⠈⠉⠉ ⠉⠉⠁ +" +`; + +exports[`receiver baseline / micro / unicode > gallop-fine 1`] = ` +"KEXP 90.3 FM · playing + + ⢀⢤⢖⢤ + ⣀⡔⠒⠒⠈⠡⣼⠉ + ⠲⠤ ⠈⢧⣖⢒⣲⠗⢣ + ⣘⣮⠃⠈ ⠈⠁ +" +`; + +exports[`receiver baseline / micro / unicode > manhattan 1`] = ` +"KEXP 90.3 FM · playing + +▀▀ ˙ •• • │•●•˙ +██▀▀ ▀││▀░ ╱│░░ ▀▀▀●░˙▀▀ +█░░▪ ▀▀░░░˙╱◆╲░•│░░░░░██ + ─•˙ ·••••˙ •• • ˙•˙•˙˙• +" +`; + +exports[`receiver baseline / micro / unicode > pulse-grid 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`receiver baseline / micro / unicode > ultracode 1`] = ` +"KEXP 90.3 FM · playing + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ------------------------- +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ------------------------------ +1 receivers · mpv + +> Living Room · speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do. +REVIEW +> Save alarm . next Mon, Se. + Cancel + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +> Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK +> Volume: 70% [#######...]. + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM - . + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-picker' 1`] = ` +"Primary station ------------------------------- +Favorites, imports, recents, current station, a. + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------------------------------ +Favorites, imports, recents, current station, a. + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-ringing' 1`] = ` +"Alarm ringing --------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio +Started 1m ago . scheduled Mon, Sep 7, 11:59 AM. + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / ascii > 'alarms' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow… + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii > 'alarms/degraded' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Repair needed +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow… + + + +Scheduling needs repair . press r +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii > 'alarms/empty' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +No upcoming alarms. + +> Create alarm + Verify alarm setup . scheduler, terminal, pow… + + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations --------- +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS . Review warnings before +relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / ascii > 'countries' 1`] = ` +"RADIOCLI Countries ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E · 2 stations + + . .:.:+** . .-. > KEXP 90.3 FM · * + ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM — … + *@@@%- .@*%#%@@@@@%+ + +#+ +@%#%%+%%@#:. + ..-+. +#%@%: : ::. + %@@- +@#. ..::. + :@+. :%:: .%%#. + ++ : . + .. :::.::::::. +::###%%@#***#%@@@@@@@@@@@@@@:" +`; + +exports[`screen baseline / compact / ascii > 'help' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Navigation + ↑/↓ or n/p · Move selection + ←/→ or Tab · Switch tabs + [ / ] · Page selection by 10 + 1-9 · Jump to an Overview option + Enter · Open / tune the selection + b or Esc · Back to home + : · Command palette + ? · Toggle this help +" +`; + +exports[`screen baseline / compact / ascii > 'help/commands' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Commands + :search · Search stations (alias :s) + :country · Open a country (alias … + :codec · Filter by codec +> :language · Filter by language (al… + :bitrate · Minimum bitrate filter + :clear · Clear search filters + :volume <0-100> · Set volume (alias :vol) + :mute · Toggle mute +" +`; + +exports[`screen baseline / compact / ascii > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent · 1 favorites · 1 imported +" +`; + +exports[`screen baseline / compact / ascii > 'library' 1`] = ` +"RADIOCLI Library ------------------------------ +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'library/empty' 1`] = ` +"RADIOCLI Library ------------------------------ +0 stations · playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'map' 1`] = ` +"RADIOCLI World map ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'nearby' 1`] = ` +"RADIOCLI Nearby ------------------------------- +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle · vol 70 + + + + + + + + + +################################################ +" +`; + +exports[`screen baseline / compact / ascii > 'search' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|› tokyo jazz | ++----------------------------------------------+ + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deliberately long station na… + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'search/loading' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|* tokyo jazz | ++----------------------------------------------+ + +Searching station directories… + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings' 1`] = ` +"RADIOCLI Settings ----------------------------- +Choose a category · Updates are available here + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/agent' 1`] = ` +"RADIOCLI Settings ----------------------------- +Agent control & MCP · Enter changes setting · b… + +> Allow local agent con… off + Install or repair MCP… run repair + Open TUI for agent pl… on + Show Now Playing for … on + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ----------------------------- +Appearance · Enter changes setting · b categori… + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad sc… auto + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/data' 1`] = ` +"RADIOCLI Settings ----------------------------- +Data & library · Enter changes setting · b cate… + +> Export preferences an… JSON backup + Import preferences an… restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ----------------------------- +Discovery & providers · Enter changes setting ·… + +> Nearby location off + Radio Garden adapter off + Share favorite votes … on + Refresh provider heal… + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ----------------------------- +Media keys · Enter changes setting · b categori… + +> Learn previous media … + Learn play/pause medi… + Learn next media key + Reset learned media k… prev 0 · play 0 · ne… + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/playback' 1`] = ` +"RADIOCLI Settings ----------------------------- +Playback & audio · Enter changes setting · b ca… + +> Audio output Automatic + AirPlay receiver Living Room · 1 rece… + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o… on + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/updates' 1`] = ` +"RADIOCLI Settings ----------------------------- +Updates · Enter changes setting · b categories + +> Automatically check f… on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'stations' 1`] = ` +"RADIOCLI Stations ----------------------------- +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'stats' 1`] = ` +"RADIOCLI Listening stats ---------------------- +Stored locally on this machine + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +Streak · 2 current · 2 best +..........................## · 2 active days + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ------------------------- +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ------------------------------ +1 receivers · mpv + +> Living Room · speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do. +REVIEW +> Save alarm . next Mon, Se. + Cancel + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +> Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK +> Volume: 70% [#######...]. + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM - . + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-picker' 1`] = ` +"Primary station ------------------------------- +Favorites, imports, recents, current station, a. + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------------------------------ +Favorites, imports, recents, current station, a. + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing --------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio +Started 1m ago . scheduled Mon, Sep 7, 11:59 AM. + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow… + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms/degraded' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Repair needed +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow… + + + +Scheduling needs repair . press r +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms/empty' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +No upcoming alarms. + +> Create alarm + Verify alarm setup . scheduler, terminal, pow… + + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations --------- +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS . Review warnings before +relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / ascii-no-color > 'countries' 1`] = ` +"RADIOCLI Countries ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E · 2 stations + + . .:.:+** . .-. > KEXP 90.3 FM · * + ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM — … + *@@@%- .@*%#%@@@@@%+ + +#+ +@%#%%+%%@#:. + ..-+. +#%@%: : ::. + %@@- +@#. ..::. + :@+. :%:: .%%#. + ++ : . + .. :::.::::::. +::###%%@#***#%@@@@@@@@@@@@@@:" +`; + +exports[`screen baseline / compact / ascii-no-color > 'help' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Navigation + ↑/↓ or n/p · Move selection + ←/→ or Tab · Switch tabs + [ / ] · Page selection by 10 + 1-9 · Jump to an Overview option + Enter · Open / tune the selection + b or Esc · Back to home + : · Command palette + ? · Toggle this help +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'help/commands' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Commands + :search · Search stations (alias :s) + :country · Open a country (alias … + :codec · Filter by codec +> :language · Filter by language (al… + :bitrate · Minimum bitrate filter + :clear · Clear search filters + :volume <0-100> · Set volume (alias :vol) + :mute · Toggle mute +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent · 1 favorites · 1 imported +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'library' 1`] = ` +"RADIOCLI Library ------------------------------ +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'library/empty' 1`] = ` +"RADIOCLI Library ------------------------------ +0 stations · playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'map' 1`] = ` +"RADIOCLI World map ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'nearby' 1`] = ` +"RADIOCLI Nearby ------------------------------- +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle · vol 70 + + + + + + + + + +################################################ +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'search' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|› tokyo jazz | ++----------------------------------------------+ + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deliberately long station na… + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'search/loading' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|* tokyo jazz | ++----------------------------------------------+ + +Searching station directories… + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings' 1`] = ` +"RADIOCLI Settings ----------------------------- +Choose a category · Updates are available here + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/agent' 1`] = ` +"RADIOCLI Settings ----------------------------- +Agent control & MCP · Enter changes setting · b… + +> Allow local agent con… off + Install or repair MCP… run repair + Open TUI for agent pl… on + Show Now Playing for … on + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ----------------------------- +Appearance · Enter changes setting · b categori… + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad sc… auto + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/data' 1`] = ` +"RADIOCLI Settings ----------------------------- +Data & library · Enter changes setting · b cate… + +> Export preferences an… JSON backup + Import preferences an… restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ----------------------------- +Discovery & providers · Enter changes setting ·… + +> Nearby location off + Radio Garden adapter off + Share favorite votes … on + Refresh provider heal… + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ----------------------------- +Media keys · Enter changes setting · b categori… + +> Learn previous media … + Learn play/pause medi… + Learn next media key + Reset learned media k… prev 0 · play 0 · ne… + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/playback' 1`] = ` +"RADIOCLI Settings ----------------------------- +Playback & audio · Enter changes setting · b ca… + +> Audio output Automatic + AirPlay receiver Living Room · 1 rece… + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o… on + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/updates' 1`] = ` +"RADIOCLI Settings ----------------------------- +Updates · Enter changes setting · b categories + +> Automatically check f… on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'stations' 1`] = ` +"RADIOCLI Stations ----------------------------- +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'stats' 1`] = ` +"RADIOCLI Listening stats ---------------------- +Stored locally on this machine + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +Streak · 2 current · 2 best +..........................## · 2 active days + + + +" +`; + +exports[`screen baseline / compact / unicode > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ───────────────────────── +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ────────────────────────────── +1 receivers · mpv + +> Living Room · speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do… +REVIEW +› Save alarm · next Mon, Se… + Cancel + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +› Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK +› Volume: 70% [███████···]… + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM — … + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-picker' 1`] = ` +"Primary station ─────────────────────────────── +Favorites, imports, recents, current station, a… + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────────────────────────────── +Favorites, imports, recents, current station, a… + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio +Started 1m ago · scheduled Mon, Sep 7, 11:59 AM… + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / unicode > 'alarms' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode > 'alarms/degraded' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Repair needed +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduling needs repair · press r +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode > 'alarms/empty' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +No upcoming alarms. + +› Create alarm + Verify alarm setup · scheduler, terminal, pow… + + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ───────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before +relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / unicode > 'countries' 1`] = ` +"RADIOCLI Countries ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E · 2 stations + + ⢀ ⠄⠤⠄⠤⣤⣴⣦ ⠠ ⢀⣠⡀ > KEXP 90.3 FM · ★ + ⢾⠿⠶⣷⣿⣷⡊⢾⡀⠙⠋⠑⠂⡀⣴⣽⣶⣶⣶⣿⣿⣿⣿⣿⡿⠿⡳⠗ KCRW 89.9 FM — … + ⢹⣿⣿⣿⡿⠋ ⠈●⠟⣟⣛⡿⣿⣿⣿⣿⣿⢿⢋ + ⠛⡟⡋ ⣰⣿⣷⣶⣾⣟⠛⢿⡿⣿⠿⠆⠁ + ⠈⠁⣠⣤⡀ ⠹⠿⢿⣿⣷⠅ ⠘ ⢈⢂⠁ + ⢿⣿⣿⡆ ⢸⣿⡯⡀ ⠈⠈⢁⡐⠂ + ⢠⣿⠏⠁ ⠘⡿⠃⠃ ⠐⡿⢿⡷⠈ + ⢸⢋ ⠃ ⠈ + ⢀⡀ ⣀⣀⣀⢀⣀⣀⣀⣀⣀⣀⡀ +⣀⣀⣶⣶⣶⣷⣾⣿⣭⣥⣴⣴⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣀" +`; + +exports[`screen baseline / compact / unicode > 'help' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Navigation + ↑/↓ or n/p · Move selection + ←/→ or Tab · Switch tabs + [ / ] · Page selection by 10 + 1-9 · Jump to an Overview option + Enter · Open / tune the selection + b or Esc · Back to home + : · Command palette + ? · Toggle this help +" +`; + +exports[`screen baseline / compact / unicode > 'help/commands' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Commands + :search · Search stations (alias :s) + :country · Open a country (alias … + :codec · Filter by codec +> :language · Filter by language (al… + :bitrate · Minimum bitrate filter + :clear · Clear search filters + :volume <0-100> · Set volume (alias :vol) + :mute · Toggle mute +" +`; + +exports[`screen baseline / compact / unicode > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent · 1 favorites · 1 imported +" +`; + +exports[`screen baseline / compact / unicode > 'library' 1`] = ` +"RADIOCLI Library ────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'library/empty' 1`] = ` +"RADIOCLI Library ────────────────────────────── +0 stations · playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'map' 1`] = ` +"RADIOCLI World map ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'nearby' 1`] = ` +"RADIOCLI Nearby ─────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle · vol 70 + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / compact / unicode > 'search' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────┘ + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'search/loading' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────────────────────────────┘ + +Searching station directories… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Choose a category · Updates are available here + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/agent' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Agent control & MCP · Enter changes setting · b… + +> Allow local agent con… off + Install or repair MCP… run repair + Open TUI for agent pl… on + Show Now Playing for … on + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Appearance · Enter changes setting · b categori… + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad sc… auto + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/data' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Data & library · Enter changes setting · b cate… + +> Export preferences an… JSON backup + Import preferences an… restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Discovery & providers · Enter changes setting ·… + +> Nearby location off + Radio Garden adapter off + Share favorite votes … on + Refresh provider heal… + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Media keys · Enter changes setting · b categori… + +> Learn previous media … + Learn play/pause medi… + Learn next media key + Reset learned media k… prev 0 · play 0 · ne… + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/playback' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Playback & audio · Enter changes setting · b ca… + +> Audio output Automatic + AirPlay receiver Living Room · 1 rece… + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o… on + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/updates' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Updates · Enter changes setting · b categories + +> Automatically check f… on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'stations' 1`] = ` +"RADIOCLI Stations ───────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'stats' 1`] = ` +"RADIOCLI Listening stats ────────────────────── +Stored locally on this machine + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +Streak · 2 current · 2 best +··························■■ · 2 active days + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ───────────────────────── +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ────────────────────────────── +1 receivers · mpv + +> Living Room · speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do… +REVIEW +› Save alarm · next Mon, Se… + Cancel + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +› Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK +› Volume: 70% [███████···]… + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM — … + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-picker' 1`] = ` +"Primary station ─────────────────────────────── +Favorites, imports, recents, current station, a… + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────────────────────────────── +Favorites, imports, recents, current station, a… + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio +Started 1m ago · scheduled Mon, Sep 7, 11:59 AM… + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms/degraded' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Repair needed +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduling needs repair · press r +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms/empty' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +No upcoming alarms. + +› Create alarm + Verify alarm setup · scheduler, terminal, pow… + + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ───────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before +relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / unicode-no-color > 'countries' 1`] = ` +"RADIOCLI Countries ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E · 2 stations + + ⢀ ⠄⠤⠄⠤⣤⣴⣦ ⠠ ⢀⣠⡀ > KEXP 90.3 FM · ★ + ⢾⠿⠶⣷⣿⣷⡊⢾⡀⠙⠋⠑⠂⡀⣴⣽⣶⣶⣶⣿⣿⣿⣿⣿⡿⠿⡳⠗ KCRW 89.9 FM — … + ⢹⣿⣿⣿⡿⠋ ⠈●⠟⣟⣛⡿⣿⣿⣿⣿⣿⢿⢋ + ⠛⡟⡋ ⣰⣿⣷⣶⣾⣟⠛⢿⡿⣿⠿⠆⠁ + ⠈⠁⣠⣤⡀ ⠹⠿⢿⣿⣷⠅ ⠘ ⢈⢂⠁ + ⢿⣿⣿⡆ ⢸⣿⡯⡀ ⠈⠈⢁⡐⠂ + ⢠⣿⠏⠁ ⠘⡿⠃⠃ ⠐⡿⢿⡷⠈ + ⢸⢋ ⠃ ⠈ + ⢀⡀ ⣀⣀⣀⢀⣀⣀⣀⣀⣀⣀⡀ +⣀⣀⣶⣶⣶⣷⣾⣿⣭⣥⣴⣴⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣀" +`; + +exports[`screen baseline / compact / unicode-no-color > 'help' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Navigation + ↑/↓ or n/p · Move selection + ←/→ or Tab · Switch tabs + [ / ] · Page selection by 10 + 1-9 · Jump to an Overview option + Enter · Open / tune the selection + b or Esc · Back to home + : · Command palette + ? · Toggle this help +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'help/commands' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Commands + :search · Search stations (alias :s) + :country · Open a country (alias … + :codec · Filter by codec +> :language · Filter by language (al… + :bitrate · Minimum bitrate filter + :clear · Clear search filters + :volume <0-100> · Set volume (alias :vol) + :mute · Toggle mute +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent · 1 favorites · 1 imported +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'library' 1`] = ` +"RADIOCLI Library ────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'library/empty' 1`] = ` +"RADIOCLI Library ────────────────────────────── +0 stations · playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'map' 1`] = ` +"RADIOCLI World map ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'nearby' 1`] = ` +"RADIOCLI Nearby ─────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle · vol 70 + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'search' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────┘ + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'search/loading' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────────────────────────────┘ + +Searching station directories… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Choose a category · Updates are available here + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/agent' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Agent control & MCP · Enter changes setting · b… + +> Allow local agent con… off + Install or repair MCP… run repair + Open TUI for agent pl… on + Show Now Playing for … on + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Appearance · Enter changes setting · b categori… + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad sc… auto + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/data' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Data & library · Enter changes setting · b cate… + +> Export preferences an… JSON backup + Import preferences an… restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Discovery & providers · Enter changes setting ·… + +> Nearby location off + Radio Garden adapter off + Share favorite votes … on + Refresh provider heal… + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Media keys · Enter changes setting · b categori… + +> Learn previous media … + Learn play/pause medi… + Learn next media key + Reset learned media k… prev 0 · play 0 · ne… + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/playback' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Playback & audio · Enter changes setting · b ca… + +> Audio output Automatic + AirPlay receiver Living Room · 1 rece… + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o… on + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/updates' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Updates · Enter changes setting · b categories + +> Automatically check f… on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'stations' 1`] = ` +"RADIOCLI Stations ───────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'stats' 1`] = ` +"RADIOCLI Listening stats ────────────────────── +Stored locally on this machine + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +Streak · 2 current · 2 best +··························■■ · 2 active days + + + +" +`; + +exports[`screen baseline / full / ascii > 'airplay-code' 1`] = ` +"AirPlay Code ---------------------------------------------------------------------- Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits · Backspace edits · Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'airplay-settings' 1`] = ` +"AirPlay ----------------------------------------------------------------------------------- Ready +Choose where AirPlay playback should go + +Audio output: Automatic · Streaming: ready · Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver +Enter selects · c opens code entry · r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/save' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW +> Save alarm . next Mon, Sep 7, 1:30 PM (you. + Cancel + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/time' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring +> Time: 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/volume' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK +> Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +WEEKDAYS + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday + * Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-picker' 1`] = ` +"Primary station --------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-picker/fallback' 1`] = ` +"Fallback station -------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-ringing' 1`] = ` +"Alarm ringing ----------------------------------------------------------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio + Started 1m ago . scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing . Space stops the radio until snooze ends . b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarms' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii > 'alarms/degraded' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Degraded . Native scheduler ready. +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM . needs repair . press x again to de + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii > 'alarms/empty' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +No enabled future alarm. + +> Create alarm + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ----------------------------------------------------------- +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS . Review warnings before relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / ascii > 'countries' 1`] = ` +"Countries --------------------------------------------------------------------------------------- +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'explore' 1`] = ` +"Explore ----------------------------------------------------------------------------------------- +Saved and discovered stations + ++-------------------------------------------------------------+ +--------------------------------+ +| .::::.::::. | |Stations 2| +| .:-::#*%-:-*@@@@@%. .-- .: :-#*++.. .: | | | +|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM ★ | +| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM — A deliberately…| +| :#@@@@@@%@@#*. +@%@@@%%@@%@@@@@@@@@@@@@@+ . | | | +| -@@@@@@@%: #*::-##*%%*@@@@@@@@@@%#+.+ | | | +| -%@@###. .%@@%-*+#@%@@@@@@@@@@@% .: | | | +| . +@ :..: :@@@@@@@@%%@#*:*@@+%@%*- | | | +| .:+-.:: -@@@@@@@@@##. %. .#* | | | +| .#@@%* .+--@@@@@@* . + ---. | | | +| @@@@@%#: *@@@@: .:+-.::*-: | | | +| +@@@@@@. -@@@@-- :++-. | | | +| +@@@%- .@@@- + .#@@@@%. | | | +| +@%*. +#+ %*+#@@- | | | +| @#: :- ::| | | +| :@ .. | | | +| .. | | | +| .+. :::::--+#++.:+++***+++*++::: | | | +| .:+*######@###*--..: -+*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+. | | | +|##++*%@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*| | | ++-------------------------------------------------------------+ +--------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'help' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +Navigation +↑/↓ or n/p Move selection +←/→ or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi… Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f Favorite the station +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 · ↑/↓ scroll" +`; + +exports[`screen baseline / full / ascii > 'help/commands' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ██████████████████ +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent · 1 favorites · 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'library' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'library/empty' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'map' 1`] = ` +"World map ------------------------------------------------------------------------------------- +Station density by country + +. .................. . . .................... . +. ............................ .... ....................................... +. ........................................ .............................................. +. ...................................... ............................................... +. ................US.......... . .....................................JP.... +................................................................................................ +. . ................. . ................ ....................... +. . ..... ......... ................ ................ +. . . .......... ............... ............. +. . . ........... ........... . . .......... +. . . ........... . .... . . .............. +. . . .......... . . . .......... +. . . ......... . . . . ... +. . . . . . . . +2 plotted . 0 unplaced . labels show highest station counts + +Highest station counts > US . United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / ascii > 'nearby' 1`] = ` +"Nearby ------------------------------------------------------------------------------------------ +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'now-playing' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | +| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | +| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | +| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | +| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | +| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o @ @ o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | +| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | +| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | +| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | +| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | +| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | +| . ∘ @ o o o @ ∘ . . . ∘ o @ o o o o @ o o ∘ ∘ ∘ ∘ o @ @ o o o o @ o . . . . o @ o o o o ∘ | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'now-playing/diagnostics' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| ∘ @ o o o o . . . ∘ o o o o @ o . . . . . ∘ ∘ ∘ ∘ . . . . ∘ o o o o @ o . . . ∘ @ o o o o | +| o @ o o @ o . . . ∘ @ o o o o ∘ . . . ∘ o @ @ @ o ∘ . . . . o @ o o o o ∘ . . ∘ o o o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . o @ o o o o o o o ∘ . . . o o o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o @ ∘ . . ∘ o o o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . o @ o o o o o o o ∘ . . . o o o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . . o @ o o @ o . . . ∘ o @ o o o o @ ∘ . . . ∘ o o o o @ ∘ . . . o o o o @ | +| ∘ @ o o o o . . . ∘ o o o o @ o . . . . . ∘ ∘ ∘ ∘ . . . . ∘ o o o o @ o . . . ∘ @ o o o o | +| ∘ @ o o o @ ∘ . . . o @ o o o @ ∘ . . . . . . . . . . . ∘ o @ o o o o ∘ . . . o @ o o @ o | +| | +| Bjork - Joga * Favorite | +| | +| Stream: none | +| Station time: 12:00 | +| Started: 11:30:00 AM . available mpv, airplay | +| | +| . Bjork - Joga | +| . Aphex Twin - Avril 14th | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'now-playing/idle' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| No station tuned IDLE | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| ########################################################################################## | +| | +| Playback backend ready. Choose a station to start tuning. * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'search' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|› tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'search/loading' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|› tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +Searching station directories… + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Choose a category + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/agent' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba… on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/appearance' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/data' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/discovery' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B… on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/media-keys' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 · play 0 · next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/playback' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room · 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) · playing · volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/updates' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'stations' 1`] = ` +"United States ----------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'stats' 1`] = ` +"Listening stats --------------------------------------------------------------------------------- +Local listening history . never leaves this machine + ++------------------------------------------------------------------------------------------------+ +|Activity — 2026 | +| Dec Jan Feb Mar Apr May Jun Jul Aug Sep | +| | +|Mon | +| | +|Wed | +| | +|Fri | +| | +| | ++------------------------------------------------------------------------------------------------+ + ++------------------------------------------------------------------------------------------------+ +|Summary | +|Favorite station: KEXP 90.3 FM Total hours listened: 1.5h | +|Sessions: 2 Longest streak: 2 days | +|Current streak: 2 days Stations listened: 2 | +|Active days: 2/371 Stations counted after: 2 min | +| | +|Less · More | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'airplay-code' 1`] = ` +"AirPlay Code ---------------------------------------------------------------------- Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits · Backspace edits · Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'airplay-settings' 1`] = ` +"AirPlay ----------------------------------------------------------------------------------- Ready +Choose where AirPlay playback should go + +Audio output: Automatic · Streaming: ready · Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver +Enter selects · c opens code entry · r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW +> Save alarm . next Mon, Sep 7, 1:30 PM (you. + Cancel + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring +> Time: 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK +> Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +WEEKDAYS + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday + * Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-picker' 1`] = ` +"Primary station --------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station -------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ----------------------------------------------------------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio + Started 1m ago . scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing . Space stops the radio until snooze ends . b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms/degraded' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Degraded . Native scheduler ready. +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM . needs repair . press x again to de + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms/empty' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +No enabled future alarm. + +> Create alarm + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ----------------------------------------------------------- +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS . Review warnings before relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / ascii-no-color > 'countries' 1`] = ` +"Countries --------------------------------------------------------------------------------------- +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'explore' 1`] = ` +"Explore ----------------------------------------------------------------------------------------- +Saved and discovered stations + ++-------------------------------------------------------------+ +--------------------------------+ +| .::::.::::. | |Stations 2| +| .:-::#*%-:-*@@@@@%. .-- .: :-#*++.. .: | | | +|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM ★ | +| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM — A deliberately…| +| :#@@@@@@%@@#*. +@%@@@%%@@%@@@@@@@@@@@@@@+ . | | | +| -@@@@@@@%: #*::-##*%%*@@@@@@@@@@%#+.+ | | | +| -%@@###. .%@@%-*+#@%@@@@@@@@@@@% .: | | | +| . +@ :..: :@@@@@@@@%%@#*:*@@+%@%*- | | | +| .:+-.:: -@@@@@@@@@##. %. .#* | | | +| .#@@%* .+--@@@@@@* . + ---. | | | +| @@@@@%#: *@@@@: .:+-.::*-: | | | +| +@@@@@@. -@@@@-- :++-. | | | +| +@@@%- .@@@- + .#@@@@%. | | | +| +@%*. +#+ %*+#@@- | | | +| @#: :- ::| | | +| :@ .. | | | +| .. | | | +| .+. :::::--+#++.:+++***+++*++::: | | | +| .:+*######@###*--..: -+*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+. | | | +|##++*%@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*| | | ++-------------------------------------------------------------+ +--------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'help' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +Navigation +↑/↓ or n/p Move selection +←/→ or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi… Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f Favorite the station +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 · ↑/↓ scroll" +`; + +exports[`screen baseline / full / ascii-no-color > 'help/commands' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ██████████████████ +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent · 1 favorites · 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'library' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'library/empty' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'map' 1`] = ` +"World map ------------------------------------------------------------------------------------- +Station density by country + +. .................. . . .................... . +. ............................ .... ....................................... +. ........................................ .............................................. +. ...................................... ............................................... +. ................US.......... . .....................................JP.... +................................................................................................ +. . ................. . ................ ....................... +. . ..... ......... ................ ................ +. . . .......... ............... ............. +. . . ........... ........... . . .......... +. . . ........... . .... . . .............. +. . . .......... . . . .......... +. . . ......... . . . . ... +. . . . . . . . +2 plotted . 0 unplaced . labels show highest station counts + +Highest station counts > US . United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'nearby' 1`] = ` +"Nearby ------------------------------------------------------------------------------------------ +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'now-playing' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | +| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | +| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | +| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | +| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | +| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o @ @ o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | +| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | +| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | +| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | +| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | +| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | +| . ∘ @ o o o @ ∘ . . . ∘ o @ o o o o @ o o ∘ ∘ ∘ ∘ o @ @ o o o o @ o . . . . o @ o o o o ∘ | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'now-playing/diagnostics' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| ∘ @ o o o o . . . ∘ o o o o @ o . . . . . ∘ ∘ ∘ ∘ . . . . ∘ o o o o @ o . . . ∘ @ o o o o | +| o @ o o @ o . . . ∘ @ o o o o ∘ . . . ∘ o @ @ @ o ∘ . . . . o @ o o o o ∘ . . ∘ o o o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . o @ o o o o o o o ∘ . . . o o o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o @ ∘ . . ∘ o o o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ ∘ . . . o o o o @ ∘ . . . o @ o o o o o o o ∘ . . . o o o o @ ∘ . . . o @ o o @ | +| o o o o @ ∘ . . . o @ o o @ o . . . ∘ o @ o o o o @ ∘ . . . ∘ o o o o @ ∘ . . . o o o o @ | +| ∘ @ o o o o . . . ∘ o o o o @ o . . . . . ∘ ∘ ∘ ∘ . . . . ∘ o o o o @ o . . . ∘ @ o o o o | +| ∘ @ o o o @ ∘ . . . o @ o o o @ ∘ . . . . . . . . . . . ∘ o @ o o o o ∘ . . . o @ o o @ o | +| | +| Bjork - Joga * Favorite | +| | +| Stream: none | +| Station time: 12:00 | +| Started: 11:30:00 AM . available mpv, airplay | +| | +| . Bjork - Joga | +| . Aphex Twin - Avril 14th | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'now-playing/idle' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| No station tuned IDLE | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| ########################################################################################## | +| | +| Playback backend ready. Choose a station to start tuning. * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'search' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|› tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'search/loading' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|› tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +Searching station directories… + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Choose a category + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/agent' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba… on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/appearance' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/data' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/discovery' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B… on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/media-keys' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 · play 0 · next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/playback' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room · 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) · playing · volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/updates' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'stations' 1`] = ` +"United States ----------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'stats' 1`] = ` +"Listening stats --------------------------------------------------------------------------------- +Local listening history . never leaves this machine + ++------------------------------------------------------------------------------------------------+ +|Activity — 2026 | +| Dec Jan Feb Mar Apr May Jun Jul Aug Sep | +| | +|Mon | +| | +|Wed | +| | +|Fri | +| | +| | ++------------------------------------------------------------------------------------------------+ + ++------------------------------------------------------------------------------------------------+ +|Summary | +|Favorite station: KEXP 90.3 FM Total hours listened: 1.5h | +|Sessions: 2 Longest streak: 2 days | +|Current streak: 2 days Stations listened: 2 | +|Active days: 2/371 Stations counted after: 2 min | +| | +|Less · More | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / unicode > 'airplay-code' 1`] = ` +"AirPlay Code ────────────────────────────────────────────────────────────────────── Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits · Backspace edits · Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'airplay-settings' 1`] = ` +"AirPlay ─────────────────────────────────────────────────────────────────────────────────── Ready +Choose where AirPlay playback should go + +Audio output: Automatic · Streaming: ready · Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver +Enter selects · c opens code entry · r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/save' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW +› Save alarm · next Mon, Sep 7, 1:30 PM (you… + Cancel + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/time' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring +› Time: 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/volume' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK +› Volume: 70% [███████···] ←/→ Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +WEEKDAYS + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday + ○ Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-picker' 1`] = ` +"Primary station ───────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-picker/fallback' 1`] = ` +"Fallback station ──────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-ringing' 1`] = ` +"Alarm ringing ─────────────────────────────────────────────────────────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio + Started 1m ago · scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing · Space stops the radio until snooze ends · b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarms' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode > 'alarms/degraded' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Degraded · Native scheduler ready. +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM · needs repair · press x again to de + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode > 'alarms/empty' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +No enabled future alarm. + +› Create alarm + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ─────────────────────────────────────────────────────────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / unicode > 'countries' 1`] = ` +"Countries ─────────────────────────────────────────────────────────────────────────────────────── +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'explore' 1`] = ` +"Explore ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +┌─────────────────────────────────────────────────────────────┐ ┌────────────────────────────────┐ +│ ⢀⣀⣀⣀⣀⢀⣀⣀⣀⣀⢀ │ │Stations 2│ +│ ⢀⡠⠴⡄⢠⣏⢵⡿⡉⠑⠚⠻⣿⣿⣿⣿⣿⡿⠁ ⠈⠋⠋ ⢀⠤ ⣀⣠⣭⣥⣤⣤⡀⢀ ⠠⠤ │ │ │ +│⠤⠄⠰⣾⣷⣶⣶⣶⣶⣶⣾⣭⣯⣤⣽⡮⡏⠙⣷⡤ ⢸⣿⡿⠟⠛⢨⣠ ⣠⣴⢾⣷⡲⣤⣤⣬⣤⣶⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣷⣶⣶⡶│ │> KEXP 90.3 FM ★ │ +│ ⠈⠻⠋⠉⠙⠛⢿⣿⣿⣿⣿⣿⣧⣀⡀⣸⣦⣤⡀ ⠉⠁ ⣠ ⠘⠿⠇⣭⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠛⠋⣽⠈⠉ │ │ KCRW 89.9 FM — A deliberately…│ +│ ⠘⢻⣿⣿⣿⣿⣿⣿⣷⣿⣿⡟⡫⠄ ⠹●⣾⣿⣿⣿⡿⡿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛ ⠁ │ │ │ +│ ⠸⣿⣿⣿⣿⣿⣿⣿⡿⠃ ⣶⠟⠉⠑⠍⡟⣧⣦⣽⣷⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡻⡋⠁⡎ │ │ │ +│ ⠙⣻⣿⣿⠿⠿⢟⠁ ⢀⣾⣿⣿⣷⣄⣦⣤⣼⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷ ⠐⠉ │ │ │ +│ ⠠ ⠹⣿ ⡠⠈⠄⣀ ⢠⣿⣿⣿⣿⣿⣿⣿⣿⣯⢿⣿⣶⡏⠉⠻⣿⣿⠛⢿⣿⡿⠟⠋ │ │ │ +│ ⠈⠉⠳⡆⢀⣀⣀ ⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⣛⠁ ⢿⠁ ⠈⡟⠷ │ │ │ +│ ⠈⣹⣿⣿⣷⣦ ⠈⠛⠋⠙⣿⣿⣿⣿⣿⣿⠟ ⠁ ⢴ ⣠⡆⠙⡀ │ │ │ +│ ⣿⣿⣿⣿⣿⣷⣶⡄ ⢹⣿⣿⣿⣿⠃ ⠈⠆⣉⠙⠁⠊⠒⣦⡤⠢ │ │ │ +│ ⠹⣿⣿⣿⣿⣿⣿⠁ ⢨⣿⣿⣿⣿⠇⣠ ⢠⣰⣆⢰⠈ │ │ │ +│ ⢸⣿⣿⣿⡿⠋ ⠈⣿⣿⣿⠇ ⠏ ⢀⣶⣿⣿⣿⣿⣷⡀ │ │ │ +│ ⢸⣿⡿⠟⠁ ⠹⠿⠏ ⡿⠟⠛⢻⣿⣿⠇ │ │ │ +│ ⣿⡟⠉ ⠉⠍ ⡠⠃│ │ │ +│ ⠰⣿ ⠈⠁ │ │ │ +│ ⠈⠁ │ │ │ +│ ⢀⣔⠁ ⣀⣀⣀⣀⣀⣄⣠⣤⣶⣤⣤⠄⣀⣤⣤⣤⣴⣦⣴⣤⣤⣤⣦⣤⣤⣀⣀⣀ │ │ │ +│ ⠠⠤⢴⣴⣶⣶⣶⣶⣶⣶⣿⣶⣶⣶⡶⠚⠋⡀⢀⣀ ⢤⣤⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠁ │ │ │ +│⣶⣶⣤⣤⣬⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⣦│ │ │ +└─────────────────────────────────────────────────────────────┘ └────────────────────────────────┘" +`; + +exports[`screen baseline / full / unicode > 'help' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +Navigation +↑/↓ or n/p Move selection +←/→ or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi… Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f Favorite the station +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 · ↑/↓ scroll" +`; + +exports[`screen baseline / full / unicode > 'help/commands' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ██████████████████ +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent · 1 favorites · 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'library' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'library/empty' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'map' 1`] = ` +"World map ───────────────────────────────────────────────────────────────────────────────────── +Station density by country + +· ·················· · · ···················· · +· ···························· ···· ······································· +· ········································ ·············································· +· ······································ ··············································· +· ················US·········· · ·····································JP···· +································································································ +· · ················· · ················ ······················· +· · ····· ········· ················ ················ +· · · ·········· ··············· ············· +· · · ··········· ··········· · · ·········· +· · · ··········· · ···· · · ·············· +· · · ·········· · · · ·········· +· · · ········· · · · · ··· +· · · · · · · · +2 plotted · 0 unplaced · labels show highest station counts + +Highest station counts > US · United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / unicode > 'nearby' 1`] = ` +"Nearby ────────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'now-playing' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ · ∘ ● ◉ ◉ ◉ ● ∘ · · · ∘ • ● ◉ ◉ ◉ ◉ ● • • ∘ ∘ ∘ ∘ • ● ● ◉ ◉ ◉ ◉ ● • · · · · • ● ◉ ◉ ◉ • ∘ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode > 'now-playing/diagnostics' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • ∘ · · · ∘ • ● ● ● • ∘ · · · · • ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ● ∘ · · · · · · · · · · · ∘ • ● ◉ ◉ ◉ • ∘ · · · • ● ◉ ◉ ● • │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +│ Stream: none │ +│ Station time: 12:00 │ +│ Started: 11:30:00 AM · available mpv, airplay │ +│ │ +│ · Bjork - Joga │ +│ · Aphex Twin - Avril 14th │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode > 'now-playing/idle' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No station tuned IDLE │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ +│ │ +│ Playback backend ready. Choose a station to start tuning. ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode > 'search' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'search/loading' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +Searching station directories… + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Choose a category + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/agent' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba… on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/appearance' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/data' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/discovery' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B… on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/media-keys' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 · play 0 · next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/playback' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room · 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) · playing · volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/updates' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'stations' 1`] = ` +"United States ─────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'stats' 1`] = ` +"Listening stats ───────────────────────────────────────────────────────────────────────────────── +Local listening history · never leaves this machine + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Activity — 2026 │ +│ Dec Jan Feb Mar Apr May Jun Jul Aug Sep │ +│ │ +│Mon │ +│ │ +│Wed │ +│ │ +│Fri │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Summary │ +│Favorite station: KEXP 90.3 FM Total hours listened: 1.5h │ +│Sessions: 2 Longest streak: 2 days │ +│Current streak: 2 days Stations listened: 2 │ +│Active days: 2/371 Stations counted after: 2 min │ +│ │ +│Less · More │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`screen baseline / full / unicode-no-color > 'airplay-code' 1`] = ` +"AirPlay Code ────────────────────────────────────────────────────────────────────── Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits · Backspace edits · Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'airplay-settings' 1`] = ` +"AirPlay ─────────────────────────────────────────────────────────────────────────────────── Ready +Choose where AirPlay playback should go + +Audio output: Automatic · Streaming: ready · Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver +Enter selects · c opens code entry · r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW +› Save alarm · next Mon, Sep 7, 1:30 PM (you… + Cancel + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring +› Time: 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK +› Volume: 70% [███████···] ←/→ Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +WEEKDAYS + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday + ○ Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-picker' 1`] = ` +"Primary station ───────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ──────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ─────────────────────────────────────────────────────────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio + Started 1m ago · scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing · Space stops the radio until snooze ends · b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms/degraded' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Degraded · Native scheduler ready. +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM · needs repair · press x again to de + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms/empty' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +No enabled future alarm. + +› Create alarm + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ─────────────────────────────────────────────────────────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / unicode-no-color > 'countries' 1`] = ` +"Countries ─────────────────────────────────────────────────────────────────────────────────────── +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'explore' 1`] = ` +"Explore ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +┌─────────────────────────────────────────────────────────────┐ ┌────────────────────────────────┐ +│ ⢀⣀⣀⣀⣀⢀⣀⣀⣀⣀⢀ │ │Stations 2│ +│ ⢀⡠⠴⡄⢠⣏⢵⡿⡉⠑⠚⠻⣿⣿⣿⣿⣿⡿⠁ ⠈⠋⠋ ⢀⠤ ⣀⣠⣭⣥⣤⣤⡀⢀ ⠠⠤ │ │ │ +│⠤⠄⠰⣾⣷⣶⣶⣶⣶⣶⣾⣭⣯⣤⣽⡮⡏⠙⣷⡤ ⢸⣿⡿⠟⠛⢨⣠ ⣠⣴⢾⣷⡲⣤⣤⣬⣤⣶⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣷⣶⣶⡶│ │> KEXP 90.3 FM ★ │ +│ ⠈⠻⠋⠉⠙⠛⢿⣿⣿⣿⣿⣿⣧⣀⡀⣸⣦⣤⡀ ⠉⠁ ⣠ ⠘⠿⠇⣭⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠛⠋⣽⠈⠉ │ │ KCRW 89.9 FM — A deliberately…│ +│ ⠘⢻⣿⣿⣿⣿⣿⣿⣷⣿⣿⡟⡫⠄ ⠹●⣾⣿⣿⣿⡿⡿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛ ⠁ │ │ │ +│ ⠸⣿⣿⣿⣿⣿⣿⣿⡿⠃ ⣶⠟⠉⠑⠍⡟⣧⣦⣽⣷⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡻⡋⠁⡎ │ │ │ +│ ⠙⣻⣿⣿⠿⠿⢟⠁ ⢀⣾⣿⣿⣷⣄⣦⣤⣼⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷ ⠐⠉ │ │ │ +│ ⠠ ⠹⣿ ⡠⠈⠄⣀ ⢠⣿⣿⣿⣿⣿⣿⣿⣿⣯⢿⣿⣶⡏⠉⠻⣿⣿⠛⢿⣿⡿⠟⠋ │ │ │ +│ ⠈⠉⠳⡆⢀⣀⣀ ⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⣛⠁ ⢿⠁ ⠈⡟⠷ │ │ │ +│ ⠈⣹⣿⣿⣷⣦ ⠈⠛⠋⠙⣿⣿⣿⣿⣿⣿⠟ ⠁ ⢴ ⣠⡆⠙⡀ │ │ │ +│ ⣿⣿⣿⣿⣿⣷⣶⡄ ⢹⣿⣿⣿⣿⠃ ⠈⠆⣉⠙⠁⠊⠒⣦⡤⠢ │ │ │ +│ ⠹⣿⣿⣿⣿⣿⣿⠁ ⢨⣿⣿⣿⣿⠇⣠ ⢠⣰⣆⢰⠈ │ │ │ +│ ⢸⣿⣿⣿⡿⠋ ⠈⣿⣿⣿⠇ ⠏ ⢀⣶⣿⣿⣿⣿⣷⡀ │ │ │ +│ ⢸⣿⡿⠟⠁ ⠹⠿⠏ ⡿⠟⠛⢻⣿⣿⠇ │ │ │ +│ ⣿⡟⠉ ⠉⠍ ⡠⠃│ │ │ +│ ⠰⣿ ⠈⠁ │ │ │ +│ ⠈⠁ │ │ │ +│ ⢀⣔⠁ ⣀⣀⣀⣀⣀⣄⣠⣤⣶⣤⣤⠄⣀⣤⣤⣤⣴⣦⣴⣤⣤⣤⣦⣤⣤⣀⣀⣀ │ │ │ +│ ⠠⠤⢴⣴⣶⣶⣶⣶⣶⣶⣿⣶⣶⣶⡶⠚⠋⡀⢀⣀ ⢤⣤⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠁ │ │ │ +│⣶⣶⣤⣤⣬⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⣦│ │ │ +└─────────────────────────────────────────────────────────────┘ └────────────────────────────────┘" +`; + +exports[`screen baseline / full / unicode-no-color > 'help' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +Navigation +↑/↓ or n/p Move selection +←/→ or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi… Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f Favorite the station +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 · ↑/↓ scroll" +`; + +exports[`screen baseline / full / unicode-no-color > 'help/commands' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ██████████████████ +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent · 1 favorites · 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'library' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'library/empty' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'map' 1`] = ` +"World map ───────────────────────────────────────────────────────────────────────────────────── +Station density by country + +· ·················· · · ···················· · +· ···························· ···· ······································· +· ········································ ·············································· +· ······································ ··············································· +· ················US·········· · ·····································JP···· +································································································ +· · ················· · ················ ······················· +· · ····· ········· ················ ················ +· · · ·········· ··············· ············· +· · · ··········· ··········· · · ·········· +· · · ··········· · ···· · · ·············· +· · · ·········· · · · ·········· +· · · ········· · · · · ··· +· · · · · · · · +2 plotted · 0 unplaced · labels show highest station counts + +Highest station counts > US · United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'nearby' 1`] = ` +"Nearby ────────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'now-playing' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ · ∘ ● ◉ ◉ ◉ ● ∘ · · · ∘ • ● ◉ ◉ ◉ ◉ ● • • ∘ ∘ ∘ ∘ • ● ● ◉ ◉ ◉ ◉ ● • · · · · • ● ◉ ◉ ◉ • ∘ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode-no-color > 'now-playing/diagnostics' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • ∘ · · · ∘ • ● ● ● • ∘ · · · · • ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ● ∘ · · · · · · · · · · · ∘ • ● ◉ ◉ ◉ • ∘ · · · • ● ◉ ◉ ● • │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +│ Stream: none │ +│ Station time: 12:00 │ +│ Started: 11:30:00 AM · available mpv, airplay │ +│ │ +│ · Bjork - Joga │ +│ · Aphex Twin - Avril 14th │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode-no-color > 'now-playing/idle' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No station tuned IDLE │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ +│ │ +│ Playback backend ready. Choose a station to start tuning. ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode-no-color > 'search' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'search/loading' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +Searching station directories… + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Choose a category + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/agent' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba… on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/appearance' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/data' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/discovery' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B… on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/media-keys' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 · play 0 · next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/playback' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room · 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) · playing · volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/updates' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'stations' 1`] = ` +"United States ─────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'stats' 1`] = ` +"Listening stats ───────────────────────────────────────────────────────────────────────────────── +Local listening history · never leaves this machine + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Activity — 2026 │ +│ Dec Jan Feb Mar Apr May Jun Jul Aug Sep │ +│ │ +│Mon │ +│ │ +│Wed │ +│ │ +│Fri │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Summary │ +│Favorite station: KEXP 90.3 FM Total hours listened: 1.5h │ +│Sessions: 2 Longest streak: 2 days │ +│Current streak: 2 days Stations listened: 2 │ +│Active days: 2/371 Stations counted after: 2 min │ +│ │ +│Less · More │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`screen baseline / micro / ascii > 'airplay-code' 1`] = ` +"RC / AirPlay code · KEX… + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / ascii > 'airplay-settings' 1`] = ` +"RC / AirPlay · 1 receiv… + +> Living Room · speaker… + + + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +BASICS +> Label: Morning radio + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +REVIEW +> Save alarm . next Mon. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + Schedule: Recurring +> Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +PLAYBACK +> Volume: 70% [#######. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + @ Tuesday +> @ Wednesday + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-picker' 1`] = ` +"Primary station ------- +Favorites, imports, rec. + +> KEXP 90.3 FM . Now pl. + KCRW 89.9 FM - A deli. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------ +Favorites, imports, rec. + +> None . No fallback + KEXP 90.3 FM . Now pl. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-ringing' 1`] = ` +"Alarm ringing --------- +Wake radio . playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM . Morning . + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / ascii > 'alarms' 1`] = ` +"Alarms ---------------- +Scheduler ready +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii > 'alarms/degraded' 1`] = ` +"Alarms ---------------- +Repair scheduler +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii > 'alarms/empty' 1`] = ` +"Alarms ---------------- +Scheduler ready +No upcoming alarms. + +> Create alarm +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii > 'alarms/verification' 1`] = ` +"Alarm setup verifie. -- +Rehearsing “Morning rad. +READY WITH LIMITATIONS . + Review warnings before + Disposable job regis. +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / ascii > 'countries' 1`] = ` +"RC / Countries · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii > 'explore' 1`] = ` +"RC / Explore · 48.9N, 2… + .:+#+:: @++*#*:. + .+:. +%%-+#-. + :#: -- --. + .::--:-+++++++- +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deli…" +`; + +exports[`screen baseline / micro / ascii > 'help' 1`] = ` +"RC / Help · Scroll for … + + Navigation + ↑/↓ or n/p · Move sel… + ←/→ or Tab · Switch t… + [ / ] · Page selectio… +" +`; + +exports[`screen baseline / micro / ascii > 'help/commands' 1`] = ` +"RC / Help · Scroll for … + + :country … + :codec · … +> :language … + :bitrate · Min… +" +`; + +exports[`screen baseline / micro / ascii > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / ascii > 'library' 1`] = ` +"RC / Library · 2 statio… + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / ascii > 'library/empty' 1`] = ` +"RC / Library · 0 statio… + +Your library is empty. +Press f on a station to… + + +" +`; + +exports[`screen baseline / micro / ascii > 'map' 1`] = ` +"RC / World map · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii > 'nearby' 1`] = ` +"RC / Nearby · 2 station… + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / ascii > 'now-playing' 1`] = ` +"KEXP 90.3 FM · playing + +. ∘ @ o o o o o o @ o . +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM · playing + +. ∘ @ o o o o o o @ o . +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii > 'now-playing/idle' 1`] = ` +"No station tuned · idle + + + + +######################## +" +`; + +exports[`screen baseline / micro / ascii > 'search' 1`] = ` +"SEARCH ++----------------------+ +|› tokyo jazz | ++----------------------+ + +> KEXP 90.3 FM · indie * +" +`; + +exports[`screen baseline / micro / ascii > 'search/loading' 1`] = ` +"SEARCH ++----------------------+ +|* tokyo jazz | ++----------------------+ + +Searching station direc… +" +`; + +exports[`screen baseline / micro / ascii > 'settings' 1`] = ` +"RC / Settings · Choose … + +> Playback … 7 settin… + Appearance 6 settin… + Discovery… 4 settin… + Data & li… 2 settin… +" +`; + +exports[`screen baseline / micro / ascii > 'settings/agent' 1`] = ` +"RC / Settings · Agent c… + +> Allow loc… off + Install o… run repa… + Open TUI … on + Show Now … on +" +`; + +exports[`screen baseline / micro / ascii > 'settings/appearance' 1`] = ` +"RC / Settings · Appeara… + +> Display c… green + Receiver … pulse-gr… + Transpare… off + ASCII-saf… on +" +`; + +exports[`screen baseline / micro / ascii > 'settings/data' 1`] = ` +"RC / Settings · Data & … + +> Export pr… JSON bac… + Import pr… restore … + + +" +`; + +exports[`screen baseline / micro / ascii > 'settings/discovery' 1`] = ` +"RC / Settings · Discove… + +> Nearby lo… off + Radio Gar… off + Share fav… on + Refresh p… +" +`; + +exports[`screen baseline / micro / ascii > 'settings/media-keys' 1`] = ` +"RC / Settings · Media k… + +> Learn pre… + Learn pla… + Learn nex… + Reset lea… prev 0 ·… +" +`; + +exports[`screen baseline / micro / ascii > 'settings/playback' 1`] = ` +"RC / Settings · Playbac… + +> Audio out… Automatic + AirPlay r… Living R… + Volume up + Volume do… +" +`; + +exports[`screen baseline / micro / ascii > 'settings/updates' 1`] = ` +"RC / Settings · Updates… + +> Automatic… on + Install u… v0.3.0 a… + + +" +`; + +exports[`screen baseline / micro / ascii > 'stations' 1`] = ` +"RC / Stations · 2 stati… + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / ascii > 'stats' 1`] = ` +"RC / Listening stats · … + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'airplay-code' 1`] = ` +"RC / AirPlay code · KEX… + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'airplay-settings' 1`] = ` +"RC / AirPlay · 1 receiv… + +> Living Room · speaker… + + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +BASICS +> Label: Morning radio + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +REVIEW +> Save alarm . next Mon. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + Schedule: Recurring +> Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +PLAYBACK +> Volume: 70% [#######. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + @ Tuesday +> @ Wednesday + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-picker' 1`] = ` +"Primary station ------- +Favorites, imports, rec. + +> KEXP 90.3 FM . Now pl. + KCRW 89.9 FM - A deli. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------ +Favorites, imports, rec. + +> None . No fallback + KEXP 90.3 FM . Now pl. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing --------- +Wake radio . playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM . Morning . + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms' 1`] = ` +"Alarms ---------------- +Scheduler ready +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms/degraded' 1`] = ` +"Alarms ---------------- +Repair scheduler +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms/empty' 1`] = ` +"Alarms ---------------- +Scheduler ready +No upcoming alarms. + +> Create alarm +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verifie. -- +Rehearsing “Morning rad. +READY WITH LIMITATIONS . + Review warnings before + Disposable job regis. +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / ascii-no-color > 'countries' 1`] = ` +"RC / Countries · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'explore' 1`] = ` +"RC / Explore · 48.9N, 2… + .:+#+:: @++*#*:. + .+:. +%%-+#-. + :#: -- --. + .::--:-+++++++- +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deli…" +`; + +exports[`screen baseline / micro / ascii-no-color > 'help' 1`] = ` +"RC / Help · Scroll for … + + Navigation + ↑/↓ or n/p · Move sel… + ←/→ or Tab · Switch t… + [ / ] · Page selectio… +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'help/commands' 1`] = ` +"RC / Help · Scroll for … + + :country … + :codec · … +> :language … + :bitrate · Min… +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'library' 1`] = ` +"RC / Library · 2 statio… + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'library/empty' 1`] = ` +"RC / Library · 0 statio… + +Your library is empty. +Press f on a station to… + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'map' 1`] = ` +"RC / World map · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'nearby' 1`] = ` +"RC / Nearby · 2 station… + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'now-playing' 1`] = ` +"KEXP 90.3 FM · playing + +. ∘ @ o o o o o o @ o . +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM · playing + +. ∘ @ o o o o o o @ o . +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o @ @ o o o o ∘ +. ∘ @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'now-playing/idle' 1`] = ` +"No station tuned · idle + + + + +######################## +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'search' 1`] = ` +"SEARCH ++----------------------+ +|› tokyo jazz | ++----------------------+ + +> KEXP 90.3 FM · indie * +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'search/loading' 1`] = ` +"SEARCH ++----------------------+ +|* tokyo jazz | ++----------------------+ + +Searching station direc… +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings' 1`] = ` +"RC / Settings · Choose … + +> Playback … 7 settin… + Appearance 6 settin… + Discovery… 4 settin… + Data & li… 2 settin… +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/agent' 1`] = ` +"RC / Settings · Agent c… + +> Allow loc… off + Install o… run repa… + Open TUI … on + Show Now … on +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/appearance' 1`] = ` +"RC / Settings · Appeara… + +> Display c… green + Receiver … pulse-gr… + Transpare… off + ASCII-saf… on +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/data' 1`] = ` +"RC / Settings · Data & … + +> Export pr… JSON bac… + Import pr… restore … + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/discovery' 1`] = ` +"RC / Settings · Discove… + +> Nearby lo… off + Radio Gar… off + Share fav… on + Refresh p… +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/media-keys' 1`] = ` +"RC / Settings · Media k… + +> Learn pre… + Learn pla… + Learn nex… + Reset lea… prev 0 ·… +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/playback' 1`] = ` +"RC / Settings · Playbac… + +> Audio out… Automatic + AirPlay r… Living R… + Volume up + Volume do… +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/updates' 1`] = ` +"RC / Settings · Updates… + +> Automatic… on + Install u… v0.3.0 a… + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'stations' 1`] = ` +"RC / Stations · 2 stati… + +> KEXP 90.3 FM · indie * + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'stats' 1`] = ` +"RC / Listening stats · … + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +" +`; + +exports[`screen baseline / micro / unicode > 'airplay-code' 1`] = ` +"RC / AirPlay code · KEX… + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / unicode > 'airplay-settings' 1`] = ` +"RC / AirPlay · 1 receiv… + +> Living Room · speaker… + + + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +BASICS +› Label: Morning radio + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +REVIEW +› Save alarm · next Mon… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + Schedule: Recurring +› Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +PLAYBACK +› Volume: 70% [███████… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + ● Tuesday +› ● Wednesday + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-picker' 1`] = ` +"Primary station ─────── +Favorites, imports, rec… + +› KEXP 90.3 FM · Now pl… + KCRW 89.9 FM — A deli… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────── +Favorites, imports, rec… + +› None · No fallback + KEXP 90.3 FM · Now pl… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────── +Wake radio · playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM · Morning … + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / unicode > 'alarms' 1`] = ` +"Alarms ──────────────── +Scheduler ready +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode > 'alarms/degraded' 1`] = ` +"Alarms ──────────────── +Repair scheduler +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode > 'alarms/empty' 1`] = ` +"Alarms ──────────────── +Scheduler ready +No upcoming alarms. + +› Create alarm +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode > 'alarms/verification' 1`] = ` +"Alarm setup verifie… ── +Rehearsing “Morning rad… +READY WITH LIMITATIONS · + Review warnings before + Disposable job regis… +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / unicode > 'countries' 1`] = ` +"RC / Countries · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode > 'explore' 1`] = ` +"RC / Explore · 48.9N, 2… + ⠠⠤⢴⣶⣢⡐⠒ ●⣤⣤⣴⣶⣦⠤⠠ + ⠈⠫⢁⡀ ⢸⣾⣟⠜⠛⠿⡃⠁ + ⢈⠿⠃ ⠸⠇ ⠴⠥⢀ + ⢀⣀⣀⣈⣄⣀⣠⣤⣤⣤⣤⣤⣤⣤⣄ +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli…" +`; + +exports[`screen baseline / micro / unicode > 'help' 1`] = ` +"RC / Help · Scroll for … + + Navigation + ↑/↓ or n/p · Move sel… + ←/→ or Tab · Switch t… + [ / ] · Page selectio… +" +`; + +exports[`screen baseline / micro / unicode > 'help/commands' 1`] = ` +"RC / Help · Scroll for … + + :country … + :codec · … +> :language … + :bitrate · Min… +" +`; + +exports[`screen baseline / micro / unicode > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / unicode > 'library' 1`] = ` +"RC / Library · 2 statio… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode > 'library/empty' 1`] = ` +"RC / Library · 0 statio… + +Your library is empty. +Press f on a station to… + + +" +`; + +exports[`screen baseline / micro / unicode > 'map' 1`] = ` +"RC / World map · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode > 'nearby' 1`] = ` +"RC / Nearby · 2 station… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode > 'now-playing' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode > 'now-playing/idle' 1`] = ` +"No station tuned · idle + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / micro / unicode > 'search' 1`] = ` +"SEARCH +┌──────────────────────┐ +│› tokyo jazz │ +└──────────────────────┘ + +> KEXP 90.3 FM · indie ★ +" +`; + +exports[`screen baseline / micro / unicode > 'search/loading' 1`] = ` +"SEARCH +┌──────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────┘ + +Searching station direc… +" +`; + +exports[`screen baseline / micro / unicode > 'settings' 1`] = ` +"RC / Settings · Choose … + +> Playback … 7 settin… + Appearance 6 settin… + Discovery… 4 settin… + Data & li… 2 settin… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/agent' 1`] = ` +"RC / Settings · Agent c… + +> Allow loc… off + Install o… run repa… + Open TUI … on + Show Now … on +" +`; + +exports[`screen baseline / micro / unicode > 'settings/appearance' 1`] = ` +"RC / Settings · Appeara… + +> Display c… green + Receiver … pulse-gr… + Transpare… off + ASCII-saf… off +" +`; + +exports[`screen baseline / micro / unicode > 'settings/data' 1`] = ` +"RC / Settings · Data & … + +> Export pr… JSON bac… + Import pr… restore … + + +" +`; + +exports[`screen baseline / micro / unicode > 'settings/discovery' 1`] = ` +"RC / Settings · Discove… + +> Nearby lo… off + Radio Gar… off + Share fav… on + Refresh p… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/media-keys' 1`] = ` +"RC / Settings · Media k… + +> Learn pre… + Learn pla… + Learn nex… + Reset lea… prev 0 ·… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/playback' 1`] = ` +"RC / Settings · Playbac… + +> Audio out… Automatic + AirPlay r… Living R… + Volume up + Volume do… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/updates' 1`] = ` +"RC / Settings · Updates… + +> Automatic… on + Install u… v0.3.0 a… + + +" +`; + +exports[`screen baseline / micro / unicode > 'stations' 1`] = ` +"RC / Stations · 2 stati… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode > 'stats' 1`] = ` +"RC / Listening stats · … + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'airplay-code' 1`] = ` +"RC / AirPlay code · KEX… + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'airplay-settings' 1`] = ` +"RC / AirPlay · 1 receiv… + +> Living Room · speaker… + + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +BASICS +› Label: Morning radio + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +REVIEW +› Save alarm · next Mon… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + Schedule: Recurring +› Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +PLAYBACK +› Volume: 70% [███████… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + ● Tuesday +› ● Wednesday + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-picker' 1`] = ` +"Primary station ─────── +Favorites, imports, rec… + +› KEXP 90.3 FM · Now pl… + KCRW 89.9 FM — A deli… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────── +Favorites, imports, rec… + +› None · No fallback + KEXP 90.3 FM · Now pl… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────── +Wake radio · playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM · Morning … + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms' 1`] = ` +"Alarms ──────────────── +Scheduler ready +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms/degraded' 1`] = ` +"Alarms ──────────────── +Repair scheduler +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms/empty' 1`] = ` +"Alarms ──────────────── +Scheduler ready +No upcoming alarms. + +› Create alarm +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verifie… ── +Rehearsing “Morning rad… +READY WITH LIMITATIONS · + Review warnings before + Disposable job regis… +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / unicode-no-color > 'countries' 1`] = ` +"RC / Countries · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'explore' 1`] = ` +"RC / Explore · 48.9N, 2… + ⠠⠤⢴⣶⣢⡐⠒ ●⣤⣤⣴⣶⣦⠤⠠ + ⠈⠫⢁⡀ ⢸⣾⣟⠜⠛⠿⡃⠁ + ⢈⠿⠃ ⠸⠇ ⠴⠥⢀ + ⢀⣀⣀⣈⣄⣀⣠⣤⣤⣤⣤⣤⣤⣤⣄ +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli…" +`; + +exports[`screen baseline / micro / unicode-no-color > 'help' 1`] = ` +"RC / Help · Scroll for … + + Navigation + ↑/↓ or n/p · Move sel… + ←/→ or Tab · Switch t… + [ / ] · Page selectio… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'help/commands' 1`] = ` +"RC / Help · Scroll for … + + :country … + :codec · … +> :language … + :bitrate · Min… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'library' 1`] = ` +"RC / Library · 2 statio… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'library/empty' 1`] = ` +"RC / Library · 0 statio… + +Your library is empty. +Press f on a station to… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'map' 1`] = ` +"RC / World map · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'nearby' 1`] = ` +"RC / Nearby · 2 station… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'now-playing' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'now-playing/idle' 1`] = ` +"No station tuned · idle + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'search' 1`] = ` +"SEARCH +┌──────────────────────┐ +│› tokyo jazz │ +└──────────────────────┘ + +> KEXP 90.3 FM · indie ★ +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'search/loading' 1`] = ` +"SEARCH +┌──────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────┘ + +Searching station direc… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings' 1`] = ` +"RC / Settings · Choose … + +> Playback … 7 settin… + Appearance 6 settin… + Discovery… 4 settin… + Data & li… 2 settin… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/agent' 1`] = ` +"RC / Settings · Agent c… + +> Allow loc… off + Install o… run repa… + Open TUI … on + Show Now … on +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/appearance' 1`] = ` +"RC / Settings · Appeara… + +> Display c… green + Receiver … pulse-gr… + Transpare… off + ASCII-saf… off +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/data' 1`] = ` +"RC / Settings · Data & … + +> Export pr… JSON bac… + Import pr… restore … + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/discovery' 1`] = ` +"RC / Settings · Discove… + +> Nearby lo… off + Radio Gar… off + Share fav… on + Refresh p… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/media-keys' 1`] = ` +"RC / Settings · Media k… + +> Learn pre… + Learn pla… + Learn nex… + Reset lea… prev 0 ·… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/playback' 1`] = ` +"RC / Settings · Playbac… + +> Audio out… Automatic + AirPlay r… Living R… + Volume up + Volume do… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/updates' 1`] = ` +"RC / Settings · Updates… + +> Automatic… on + Install u… v0.3.0 a… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'stations' 1`] = ` +"RC / Stations · 2 stati… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'stats' 1`] = ` +"RC / Listening stats · … + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +" +`; diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index f7f2109..7fc82f7 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -1,15 +1,46 @@ +import {act} from 'react'; import {mkdtempSync, rmSync} from 'node:fs'; import {join} from 'node:path'; import {tmpdir} from 'node:os'; -import {afterEach, describe, expect, it, vi} from 'vitest'; -import {render} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render as inkRender} from 'ink-testing-library'; +import {ProviderManager} from '../providers/provider-manager.js'; +import * as backendInstall from '../player/backend-install.js'; +import * as airplayDiscovery from '../player/airplay-discovery.js'; +import * as updates from '../update-check.js'; +import * as session from '../agent/session.js'; +import * as presence from '../alarms/tui-presence.js'; import {JsonLibraryStore} from '../storage/store.js'; import type {Alarm, Station} from '../types.js'; import type {AlarmTuiService, TuiActiveAlarm} from './alarm-tui-service.js'; import {App} from './App.js'; const directories: string[] = []; +const pendingInputs: Array<() => void> = []; +// Exercise App and its alarm controller without host discovery or network races. +// Individual tests can still override the injected alarm/update/MCP services. +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const testHome = mkdtempSync(join(tmpdir(), 'radiocli-alarm-tui-home-')); + directories.push(testHome); + vi.stubEnv('RADIOCLI_HOME', testHome); + vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv']); + vi.spyOn(airplayDiscovery, 'discoverAirPlayDevices').mockResolvedValue([]); + vi.spyOn(ProviderManager.prototype, 'health').mockResolvedValue({}); + vi.spyOn(updates, 'checkForUpdate').mockImplementation(async ({currentVersion = '0.2.3'} = {}) => ({ + checkedAt: new Date().toISOString(), currentVersion, updateAvailable: false + })); + vi.spyOn(session, 'startRadioSession').mockResolvedValue({close: async () => undefined}); + vi.spyOn(presence, 'registerTuiPresence').mockReturnValue(() => undefined); +}); afterEach(() => { + pendingInputs.length = 0; + act(() => cleanup()); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); vi.unstubAllEnvs(); for (const path of directories.splice(0)) rmSync(path, {recursive: true, force: true}); }); @@ -37,7 +68,23 @@ function callsFor() { return { verifySetup:vi.fn(async (alarm:Alarm|undefined,_settings:unknown,onUpdate:(report:unknown)=>void)=>{const report={state:'passed' as const,alarmLabel:alarm?.label,startedAt:new Date().toISOString(),finishedAt:new Date().toISOString(),steps:[{id:'scheduler',label:'Native scheduler',state:'passed' as const,detail:'Disposable job registered and removed.',critical:true}]};onUpdate(report);return report;}) }; } function addAlarm(store: JsonLibraryStore): Alarm { return store.addAlarm({label: 'Morning', enabled: true, station, schedule: {type: 'recurring', time: '06:30', weekdays: [1,2,3,4,5], timezone: 'America/Los_Angeles'}, playback: {volume: 70, fadeSeconds: 30, stopAfterMinutes: 60}, reliability: {missedRunGraceMinutes: 15, wakeIfSupported: true, keepAwakeUntilAlarm: false}}); } -async function settle(): Promise { await new Promise(resolve => setTimeout(resolve, 25)); } +// Preserve rapid duplicate keys and multi-character input as one React batch, +// matching the existing tests' write/write/settle boundaries. +function render(tree: Parameters[0]): ReturnType { + let app!: ReturnType; + act(() => { app = inkRender(tree); }); + const write = app.stdin.write; + app.stdin.write = input => { pendingInputs.push(() => write(input)); }; + const unmount = app.unmount; + app.unmount = () => { act(() => unmount()); }; + return app; +} +async function settle(elapsed = 0): Promise { + await act(async () => { + for (const write of pendingInputs.splice(0)) write(); + if (elapsed > 0) await vi.advanceTimersByTimeAsync(elapsed); + }); +} async function moveDown(app: ReturnType, count: number): Promise { for (let index = 0; index < count; index += 1) app.stdin.write('\u001B[B'); await settle(); } describe('Settings TUI integration', () => { @@ -223,7 +270,7 @@ describe('alarm TUI integration', () => { }); it('does not let background active polling move a non-alarm screen cursor', async () => { - const {store,service}=fixture();const app=render();await settle();app.stdin.write('9');await settle();await moveDown(app,4);const selectedLine=app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '));expect(selectedLine).toBeTruthy();vi.useFakeTimers();try{await vi.advanceTimersByTimeAsync(1_600);expect(app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '))).toBe(selectedLine);}finally{app.unmount();vi.useRealTimers();} + const {store,service}=fixture();const app=render();await settle();app.stdin.write('9');await settle();await moveDown(app,4);const selectedLine=app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '));expect(selectedLine).toBeTruthy();vi.useFakeTimers();try{await act(async () => vi.advanceTimersByTimeAsync(1_600));expect(app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '))).toBe(selectedLine);}finally{app.unmount();vi.useRealTimers();} }); it('toggles, confirms deletion with a second x, and cleans the native job through the injected service', async () => { @@ -283,7 +330,8 @@ describe('alarm TUI integration', () => { expect(app.lastFrame()).toContain('Primary station'); app.stdin.write('\r'); await settle(); expect(app.lastFrame()).toContain('Station: KEXP'); app.stdin.write('\u0013'); await settle(); expect(store.listAlarms()).toHaveLength(1); const original = store.listAlarms()[0]!; app.stdin.write('\r'); await settle(); expect(app.lastFrame()).toContain('Edit alarm'); - app.stdin.write('\u001B'); await settle(); expect(store.getAlarm(original.id)?.label).toBe(original.label); + // Ink waits 20ms to distinguish a lone Escape from a split escape sequence. + app.stdin.write('\u001B'); await settle(20); expect(store.getAlarm(original.id)?.label).toBe(original.label); app.stdin.write('r'); await settle(); expect(calls.syncAll.mock.calls.length).toBeGreaterThanOrEqual(2); expect(app.lastFrame()).toContain('synchronized'); app.unmount(); }); diff --git a/src/ui/visual-baseline.test.tsx b/src/ui/visual-baseline.test.tsx new file mode 100644 index 0000000..1df32fe --- /dev/null +++ b/src/ui/visual-baseline.test.tsx @@ -0,0 +1,228 @@ +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {stripVTControlCharacters} from 'node:util'; +import type {ComponentProps} from 'react'; +import {Box, renderToString} from 'ink'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {receiverStyleNames, themeNames, type Alarm, type LibraryState, type Screen, type Station} from '../types.js'; +import {AppContent} from './AppContent.js'; +import {defaultExploreCursor} from './app-state.js'; +import {draftFromAlarm} from './alarm-editor.js'; +import {DisplayContext, resolveDisplayMode} from './display-context.js'; +import {computeTerminalLayout} from './layout.js'; +import {settingsGroups} from './screen-items.js'; +import {alarmPickerChoices} from './screens/AlarmsScreen.js'; + +type ContentProps = ComponentProps; +type BaselineCase = { + name: string; + screen: Screen; + props?: Partial; + alarm?: Partial; + receiverStyle?: LibraryState['settings']['receiverStyle']; +}; + +// Resolve Ink's own Chalk instance, including nested dependency installations. +// Its explicit color level makes these captures independent of the host TTY, +// CI, and FORCE_COLOR while retaining the actual renderer's style sequences. +const inkRequire = createRequire(import.meta.resolve('ink')); +const inkChalk = (await import(pathToFileURL(inkRequire.resolve('chalk')).href)).default as {level: number}; +const originalColorLevel = inkChalk.level; +const now = '2026-09-07T12:00:00.000Z'; +const station: Station = { + id: 'kexp', provider: 'radio-browser', name: 'KEXP 90.3 FM', + country: 'United States', countryCode: 'US', city: 'Seattle', + tags: ['indie'], codec: 'MP3', bitrate: 128, + latitude: 47.6062, longitude: -122.3321, + streamUrl: 'https://example.test/kexp' +}; +const otherStation: Station = { + ...station, id: 'kcrw', name: 'KCRW 89.9 FM — A deliberately long station name', + city: 'Santa Monica', tags: ['eclectic', 'public radio'], + latitude: 34.0195, longitude: -118.4912 +}; +const alarm: Alarm = { + id: 'morning', label: 'Morning radio', enabled: true, station, + schedule: {type: 'recurring', time: '06:30', weekdays: [1, 2, 3, 4, 5], timezone: 'America/Los_Angeles'}, + playback: {volume: 70, fadeSeconds: 30, stopAfterMinutes: 60, fallbackStation: otherStation}, + reliability: {missedRunGraceMinutes: 15, wakeIfSupported: true, keepAwakeUntilAlarm: true}, + createdAt: '2026-08-22T00:00:00.000Z', updatedAt: '2026-08-22T00:00:00.000Z' +}; +const library: LibraryState = { + favorites: [station], + recent: [{station: otherStation, playedAt: '2026-09-06T12:00:00.000Z'}], + imported: [otherStation], + activity: {sessions: [ + {id: 'listen-1', station, startedAt: '2026-09-06T12:00:00.000Z', endedAt: '2026-09-06T13:00:00.000Z', listenedSeconds: 3600}, + {id: 'listen-2', station: otherStation, startedAt: '2026-09-07T09:00:00.000Z', endedAt: '2026-09-07T09:30:00.000Z', listenedSeconds: 1800} + ]}, + trackHistory: [ + {title: 'Bjork - Joga', stationKey: 'radio-browser:kexp', stationName: station.name, at: '2026-09-07T11:58:00.000Z'}, + {title: 'Aphex Twin - Avril 14th', stationKey: 'radio-browser:kexp', stationName: station.name, at: '2026-09-07T11:54:00.000Z'} + ], + searchHistory: ['tokyo jazz'], + alarms: [alarm], + settings: { + theme: 'green', receiverStyle: 'pulse-grid', volume: 70, + enableRadioGarden: false, enableNearbyLocation: false, shareDirectoryVotes: true, + preferredBackend: 'auto', preferredAirPlayDevice: 'living-room', tuneTimeoutSeconds: 12, + skipBrokenStreams: true, mediaKeys: {previous: [], playPause: [], next: []}, + resumeOnLaunch: true, asciiMode: false, reduceMotion: false, + transparentBackground: false, automaticUpdateChecks: true + } +}; +const playback: ContentProps['playback'] = {backend: 'mpv', state: 'playing', volume: 70, muted: false, ready: true}; +const runtime: ContentProps['alarmTui']['runtime'] = { + capabilities: {supported: true, exactWake: false, catchUpAfterWake: true, message: 'ready'}, + degradedAlarmIds: new Set(), message: 'Native scheduler ready.' +}; +const verification: NonNullable = { + state: 'warning', alarmLabel: alarm.label, startedAt: now, finishedAt: now, + steps: [ + {id: 'scheduler', label: 'Native scheduler', state: 'passed', detail: 'Disposable job registered and removed.', critical: true}, + {id: 'power', label: 'Sleep protection', state: 'warning', detail: 'Closed-lid policy cannot be overridden.', critical: false} + ] +}; +const activeAlarms: ContentProps['alarmTui']['activeAlarms'] = [{ + key: 'morning-session', + status: {alarmId: alarm.id, scheduledAt: '2026-09-07T11:59:00.000Z', stationName: station.name, station, startedAt: '2026-09-07T11:59:00.000Z', state: 'playing'}, + dismiss: async () => undefined, snooze: async () => undefined, keepPlaying: async () => undefined +}]; + +// Keep this exhaustive: adding a route must also make its baseline intentional. +const screens: Record> = { + home: {}, 'now-playing': {}, library: {}, explore: {}, search: {}, countries: {}, + nearby: {}, map: {}, stations: {}, stats: {}, settings: {}, + 'airplay-settings': {}, 'airplay-code': {playback: {...playback, backend: 'airplay', state: 'loading', ready: false, message: 'AirPlay code required.'}}, + help: {}, alarms: {selected: 1}, 'alarm-editor': {}, 'alarm-picker': {}, 'alarm-ringing': {} +}; +const screenCases: BaselineCase[] = [ + ...Object.entries(screens).map(([screen, props]) => ({name: screen, screen: screen as Screen, props})), + ...settingsGroups.map(group => ({name: `settings/${group.id}`, screen: 'settings' as const, props: {settingsPage: group.id}})), + {name: 'now-playing/diagnostics', screen: 'now-playing', props: {showDiagnostics: true}}, + {name: 'now-playing/idle', screen: 'now-playing', props: {playingStation: null, nowPlaying: null, playback: {...playback, state: 'idle', ready: false}}}, + {name: 'search/loading', screen: 'search', props: {loadingStations: true, displayStations: []}}, + {name: 'library/empty', screen: 'library', props: {displayStations: [], library: {...library, favorites: [], imported: [], recent: []}}}, + {name: 'help/commands', screen: 'help', props: {selected: 40}}, + {name: 'alarms/empty', screen: 'alarms', props: {library: {...library, alarms: []}}}, + {name: 'alarms/degraded', screen: 'alarms', props: {selected: 1}, alarm: {runtime: {...runtime, degradedAlarmIds: new Set([alarm.id])}, deletingId: alarm.id}}, + {name: 'alarms/verification', screen: 'alarms', alarm: {verification}}, + {name: 'alarm-editor/time', screen: 'alarm-editor', alarm: {editorField: 'time', editorControl: 'time', timeSegment: 'minute'}}, + {name: 'alarm-editor/weekdays', screen: 'alarm-editor', alarm: {editorField: 'weekdays', editorControl: 'weekdays', weekdayIndex: 2}}, + {name: 'alarm-editor/volume', screen: 'alarm-editor', alarm: {editorField: 'volume', editorControl: 'number'}}, + {name: 'alarm-editor/save', screen: 'alarm-editor', alarm: {editorField: 'save'}}, + {name: 'alarm-picker/fallback', screen: 'alarm-picker', alarm: {pickerFallback: true, pickerChoices: alarmPickerChoices([alarm], [station], [otherStation], library.recent, station, true)}} +]; +const sizes = [ + {name: 'full', columns: 100, rows: 30}, + {name: 'compact', columns: 50, rows: 16}, + {name: 'micro', columns: 24, rows: 8} +] as const; +const profiles = [ + {name: 'unicode', ascii: false, noColor: false}, + {name: 'ascii', ascii: true, noColor: false}, + {name: 'unicode-no-color', ascii: false, noColor: true}, + {name: 'ascii-no-color', ascii: true, noColor: true} +] as const; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(now)); + vi.stubEnv('TZ', 'UTC'); +}); +afterEach(() => { + inkChalk.level = originalColorLevel; + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +for (const size of sizes) { + for (const profile of profiles) { + describe(`screen baseline / ${size.name} / ${profile.name}`, () => { + it.each(screenCases)('$name', baseline => capture(baseline, size, profile)); + }); + } +} + +describe('receiver baseline / full / unicode', () => { + it.each(receiverStyleNames)('%s', receiverStyle => capture({name: receiverStyle, screen: 'now-playing', receiverStyle}, sizes[0], profiles[0])); +}); + +const representativeReceivers = ['gallop-fine', 'pulse-grid', 'ultracode', 'braille-wave', 'manhattan'] as const; +for (const size of sizes) { + for (const profile of profiles.filter(profile => !profile.noColor && (size.name !== 'full' || profile.ascii))) { + describe(`receiver baseline / ${size.name} / ${profile.name}`, () => { + it.each(representativeReceivers)('%s', receiverStyle => capture({name: receiverStyle, screen: 'now-playing', receiverStyle}, size, profile)); + }); + } +} + +// Most baselines stay readable as terminal text. These focused ANSI captures +// retain the exact accents, heatmap levels, selection fills, and scene colors. +describe('palette baseline', () => { + const paletteCases = themeNames.flatMap(theme => ['home', 'stats'].map(screen => ({ + name: `${theme}/${screen}`, screen: screen as Screen, props: {theme} + }))); + it.each(paletteCases)('$name', baseline => capture(baseline, sizes[0], profiles[0], true)); + it.each([ + {name: 'receiver accent', screen: 'now-playing'}, + {name: 'alarm selection', screen: 'alarms', props: {selected: 1}}, + {name: 'settings selection', screen: 'settings', props: {settingsPage: 'appearance'}}, + {name: 'default receiver', screen: 'now-playing', receiverStyle: 'gallop-fine'}, + {name: 'scene', screen: 'now-playing', receiverStyle: 'manhattan'}, + {name: 'mono scene', screen: 'now-playing', receiverStyle: 'manhattan', props: {theme: 'mono'}} + ])('$name', baseline => capture(baseline, sizes[0], profiles[0], true)); +}); + +function capture(baseline: BaselineCase, size: (typeof sizes)[number], profile: (typeof profiles)[number], includeColor = false): void { + inkChalk.level = profile.noColor ? 0 : 3; + const layout = computeTerminalLayout(size.columns, size.rows); + const baselineLibrary = baseline.props?.library ?? library; + const renderLibrary: LibraryState = { + ...baselineLibrary, + settings: {...baselineLibrary.settings, theme: baseline.props?.theme ?? baselineLibrary.settings.theme, asciiMode: profile.ascii, receiverStyle: baseline.receiverStyle ?? baselineLibrary.settings.receiverStyle} + }; + const display = resolveDisplayMode(renderLibrary.settings, profile.noColor ? {NO_COLOR: '1'} : {}); + const props: ContentProps = { + airPlayDevices: [{id: 'living-room', name: 'Living Room', host: 'speaker.local', port: 7000, txt: [], requiresPassword: true, airplay2: true}], + airPlayCode: '1234', appVersion: '0.2.3', backends: ['mpv', 'airplay'], + countryFilter: '', diagnostics: {backend: 'mpv', availableBackends: ['mpv', 'airplay'], preferredBackend: 'auto', active: true, volume: 70, muted: false, ready: true, startedAt: '2026-09-07T11:30:00.000Z'}, + displayStations: [station, otherStation], editingCountryFilter: false, editingSearch: true, + exploreCursor: defaultExploreCursor, favoriteKeys: new Set(['radio-browser:kexp']), filterLabel: 'none', + filteredCountries: [{name: 'United States', code: 'US', stationCount: 12_345}, {name: 'Japan', code: 'JP', stationCount: 456}], + frameWidth: layout.frameWidth, layout, library: renderLibrary, loadingCountries: false, loadingStations: false, + nowPlaying: {title: 'Bjork - Joga', updatedAt: now}, playback, playingStation: station, + providerHealth: {'radio-browser': 'online', 'radio-garden': 'disabled'}, searchQuery: 'tokyo jazz', + screen: baseline.screen, settingsPage: 'root', selected: 0, showDiagnostics: false, + sleepLabel: 'Sleep off', stationContext: {title: baseline.screen === 'library' ? 'Library' : baseline.screen === 'nearby' ? 'Nearby' : baseline.screen === 'stations' ? 'United States' : 'Explore', subtitle: 'Saved and discovered stations', stations: [station, otherStation]}, + stationFavorite: true, stationTime: '12:00', storePath: '/fixture/radiocli.json', theme: 'green', + updateCheck: {checkedAt: now, currentVersion: '0.2.3', latestVersion: '0.3.0', updateAvailable: true}, + alarmTui: { + draft: draftFromAlarm(alarm), editorField: 'label', editingField: false, editorControl: null, + timeSegment: 'hour', weekdayIndex: 0, validationError: null, saving: false, + pickerChoices: alarmPickerChoices([alarm], [station], [otherStation], library.recent, station, false), + pickerFallback: false, runtime, verification: null, activeAlarms, activeSelected: 0, + snoozeMinutes: 10, busyAlarmIds: new Set(), itemCount: () => undefined, + handleInput: () => false, openForStation: () => undefined, openActive: () => undefined, + ...baseline.alarm + }, + ...baseline.props + }; + props.library = renderLibrary; + // Match App's content viewport. renderToString runs the real Ink renderer and + // tears down its Yoga tree and effects after capturing the initial frame. + const frame = renderToString( + + + + + , + {columns: layout.frameWidth} + ); + const lines = stripVTControlCharacters(frame).split('\n'); + expect(lines.length).toBeLessThanOrEqual(layout.contentRows); + expect(frame).not.toContain('Unknown screen.'); + if (profile.noColor) expect(frame).not.toContain('\u001B['); + const snapshot = includeColor ? frame.replaceAll('\u001B', '') : stripVTControlCharacters(frame); + expect(snapshot.split('\n').map(line => line.trimEnd()).join('\n')).toMatchSnapshot(); +} From d6f73b78d43a5d9597212b42dd4a63b9b8991b63 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:24:24 -0500 Subject: [PATCH 02/25] refactor: centralize platform profiles paths and capabilities --- src/alarms/power-guard-store.ts | 11 +- src/cli.test.ts | 9 ++ src/cli.tsx | 26 +++- src/platform/capabilities.test.ts | 52 +++++++ src/platform/capabilities.ts | 68 +++++++++ src/platform/desktop.ts | 27 ++++ src/platform/executables.ts | 216 +++++++++++++++++++++++++++++ src/platform/packages.ts | 124 +++++++++++++++++ src/platform/paths.test.ts | 45 ++++++ src/platform/paths.ts | 37 +++++ src/platform/runtime.test.ts | 51 +++++++ src/platform/runtime.ts | 89 ++++++++++++ src/player/backend-install.ts | 40 +----- src/player/command.ts | 220 +----------------------------- src/providers/cache.ts | 35 +---- src/setup.ts | 126 +---------------- src/storage/store.ts | 34 +---- src/ui/system-actions.ts | 33 +---- 18 files changed, 766 insertions(+), 477 deletions(-) create mode 100644 src/platform/capabilities.test.ts create mode 100644 src/platform/capabilities.ts create mode 100644 src/platform/desktop.ts create mode 100644 src/platform/executables.ts create mode 100644 src/platform/packages.ts create mode 100644 src/platform/paths.test.ts create mode 100644 src/platform/paths.ts create mode 100644 src/platform/runtime.test.ts create mode 100644 src/platform/runtime.ts diff --git a/src/alarms/power-guard-store.ts b/src/alarms/power-guard-store.ts index a1bd2fd..bb69023 100644 --- a/src/alarms/power-guard-store.ts +++ b/src/alarms/power-guard-store.ts @@ -8,7 +8,7 @@ import { statSync, writeFileSync } from 'node:fs'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import {z} from 'zod'; import type {AlarmPowerGuardState} from '../types.js'; @@ -149,14 +149,7 @@ export class AlarmPowerGuardStore { } function defaultAlarmPowerGuardPath(): string { - if (process.env.RADIOCLI_HOME) return join(process.env.RADIOCLI_HOME, 'runtime', 'alarm-power-guards.json'); - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radiocli', 'runtime', 'alarm-power-guards.json'); - } - if (process.platform === 'win32') { - return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime', 'alarm-power-guards.json'); - } - return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli', 'alarm-power-guards.json'); + return join(platformPaths().alarmRuntime, 'alarm-power-guards.json'); } function validDate(date: Date, label: string): Date { diff --git a/src/cli.test.ts b/src/cli.test.ts index 4110225..f85503c 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -92,6 +92,8 @@ describe('CLI command dispatch', () => { backends: string[]; commands: Record; mpv: {path: string | null; discovery: string; launchable: boolean}; + host: {id: string; arch: string}; + capabilities: Record; guidance: string[]; }; expect(report.radioCliVersion).toMatch(/^\d+\.\d+\.\d+/); @@ -100,6 +102,13 @@ describe('CLI command dispatch', () => { expect(report.commands).toHaveProperty('mpv'); expect(report.mpv).toMatchObject({discovery: expect.any(String), launchable: expect.any(Boolean)}); expect(report.guidance).toContain('playback=missing'); + expect(report.host).toMatchObject({id: expect.any(String), arch: process.arch}); + expect(report.capabilities.playback?.status).toBe('unavailable'); + expect(report.capabilities.storage?.status).toBe('unverified'); + expect(report.capabilities).toHaveProperty('backgroundScheduling'); + expect(report.capabilities).toHaveProperty('terminalReopening'); + expect(report.capabilities).toHaveProperty('screenReader'); + expect(JSON.stringify(report)).not.toContain(radioCliHome); expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); }); diff --git a/src/cli.tsx b/src/cli.tsx index 7303a6b..4d56fef 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -19,6 +19,10 @@ import {decodeAgentCommand} from './agent/service.js'; import {runHeadlessAgentHost} from './agent/headless-host.js'; import {configureMcpIntegrations} from './agent/mcp-install.js'; import {defaultAgentControlSettings} from './types.js'; +import {identifyPlatform} from './platform/runtime.js'; +import {platformCapabilities} from './platform/capabilities.js'; +import {hasGraphicalSession} from './platform/desktop.js'; +import {detectPackageManager} from './platform/packages.js'; const runtime = {nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url)}; @@ -97,13 +101,17 @@ export async function runCommand(args: string[]): Promise { if (command === 'doctor') { const backends = detectPlaybackBackends(); const mpvDiagnostic = diagnoseCommand('mpv'); + const report = doctorReport(backends, mpvDiagnostic); if (rest.includes('--json')) { - console.log(JSON.stringify(doctorReport(backends, mpvDiagnostic), null, 2)); + console.log(JSON.stringify(report, null, 2)); return; } console.log(`backends=${backends.join(',') || 'none'}`); printMpvDiagnostic(mpvDiagnostic); printPlaybackBackendStatus(backends); + for (const [name, capability] of Object.entries(report.capabilities)) { + console.log(`capability_${name}=${capability.status} ${capability.message}`); + } return; } @@ -277,16 +285,30 @@ function printPlaybackBackendStatus(backends: string[]): void { } } -function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic): Record { +function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic) { const commands = Object.fromEntries( ['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]) ); const airPlay = airPlaySenderHealth(); + const host = identifyPlatform(); + const integrationCommands = [ + 'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit', + 'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer', + 'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel' + ].filter(command => resolveCommand(command) !== null); + const capabilities = platformCapabilities(host, { + backends, + commands: integrationCommands, + graphicalSession: hasGraphicalSession(host), + packageManager: detectPackageManager(process.platform, host.osRelease) + }); return { radioCliVersion: appVersion(), nodeVersion: process.version, platform: process.platform, architecture: process.arch, + host: {id: host.id, arch: host.arch, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl}, + capabilities, backends, commands, mpv: redactDiagnostic(mpvDiagnostic), diff --git a/src/platform/capabilities.test.ts b/src/platform/capabilities.test.ts new file mode 100644 index 0000000..4714da1 --- /dev/null +++ b/src/platform/capabilities.test.ts @@ -0,0 +1,52 @@ +import {describe, expect, it} from 'vitest'; +import {identifyPlatform} from './runtime.js'; +import {platformCapabilities} from './capabilities.js'; + +describe('independent capabilities', () => { + it('keeps playback and private storage independent of an unavailable scheduler/desktop', () => { + const report = platformCapabilities(identifyPlatform({platform: 'freebsd', env: {}}), {backends: ['mpv'], commands: [], graphicalSession: false}); + expect(report.playback.status).toBe('available'); + expect(report.playbackControls.status).toBe('available'); + expect(report.backgroundScheduling.status).toBe('unavailable'); + expect(report.externalUrl.status).toBe('unavailable'); + expect(report.clipboard.status).toBe('unavailable'); + expect(report.filePermissions.status).toBe('available'); + expect(report.storage.status).toBe('unverified'); + expect(report.airPlay.status).toBe('unavailable'); + }); + + it('never turns executable discovery into scheduler or wake verification', () => { + const host = identifyPlatform({platform: 'linux', env: {}}); + const evidence = {backends: ['mpv'], commands: ['systemctl', 'systemd-inhibit'], graphicalSession: false}; + expect(platformCapabilities(host, evidence).backgroundScheduling.status).toBe('unverified'); + expect(platformCapabilities(host, {...evidence, scheduler: {supported: false, message: 'No user manager'}}).backgroundScheduling).toMatchObject({status: 'unavailable', message: 'No user manager'}); + expect(platformCapabilities(host, {...evidence, scheduler: {supported: true, message: 'User manager responded'}}).backgroundScheduling.status).toBe('available'); + expect(platformCapabilities(host, evidence).wakeRequests.status).toBe('unavailable'); + expect(platformCapabilities(host, evidence).sleepInhibition.status).toBe('unverified'); + }); + + it.each(['ffplay', 'vlc'])('reports %s playback with explicitly unavailable interactive controls', backend => { + const report = platformCapabilities(identifyPlatform({platform: 'linux', env: {}}), {backends: [backend], commands: [], graphicalSession: false}); + expect(report.playback.status).toBe('available'); + expect(report.playbackControls).toMatchObject({status: 'unavailable'}); + expect(report.playbackControls.message).toContain('mpv'); + }); + + it('reports an absent optional command instead of advertising a usable integration', () => { + const report = platformCapabilities(identifyPlatform({platform: 'darwin', env: {}}), {backends: [], commands: [], graphicalSession: true}); + expect(report.playback.status).toBe('unavailable'); + expect(report.externalUrl.status).toBe('unavailable'); + expect(report.clipboard.status).toBe('unavailable'); + expect(report.sleepInhibition.status).toBe('unavailable'); + expect(report.airPlay.status).toBe('unavailable'); + }); + + it('keeps read-only storage and missing tools useful to doctor', () => { + const report = platformCapabilities(identifyPlatform({platform: 'win32', env: {}}), {backends: ['mpv'], commands: [], graphicalSession: false, storageWritable: false}); + expect(report.storage).toMatchObject({status: 'unavailable'}); + expect(report.storage.message).toContain('RADIOCLI_HOME'); + expect(report.filePermissions.message).toContain('ACL'); + expect(report.atomicWrites.status).toBe('unavailable'); + expect(report.playback.status).toBe('available'); + }); +}); diff --git a/src/platform/capabilities.ts b/src/platform/capabilities.ts new file mode 100644 index 0000000..3e27a05 --- /dev/null +++ b/src/platform/capabilities.ts @@ -0,0 +1,68 @@ +import {browserCommands, clipboardCandidates} from './desktop.js'; +import {nativeAdapters, type PlatformProfile} from './runtime.js'; + +export type Capability = {status: 'available' | 'unavailable' | 'unverified'; message: string; adapter?: string}; +type CapabilityEvidence = { + backends: readonly string[]; + commands: readonly string[]; + graphicalSession: boolean; + packageManager?: string | null; + storageWritable?: boolean; + scheduler?: {supported: boolean; message: string; catchUpAfterWake?: boolean}; + unicode?: boolean; + color?: boolean; + screenReader?: boolean; +}; + +const available = (message: string, adapter?: string): Capability => ({status: 'available', message, ...(adapter ? {adapter} : {})}); +const unavailable = (message: string): Capability => ({status: 'unavailable', message}); +const unverified = (message: string, adapter?: string): Capability => ({status: 'unverified', message, ...(adapter ? {adapter} : {})}); + +/** + * Availability is independent per feature. Tool discovery never substitutes for + * desktop, scheduler, audio-device, permission, or upstream runtime verification. + */ +export function platformCapabilities(host: PlatformProfile, evidence: CapabilityEvidence) { + const adapters = nativeAdapters(host); + const commands = new Set(evidence.commands); + const has = (name: string) => commands.has(name); + const backend = ['mpv', 'ffplay', 'vlc', 'airplay'].find(name => evidence.backends.includes(name)); + const desktop = evidence.graphicalSession; + const opener = browserCommands(host).find(item => has(item.command)); + const clipboard = clipboardCandidates(host).find(item => has(item.command)); + const schedulerTool = adapters.scheduler === 'launchd' ? 'launchctl' + : adapters.scheduler === 'task-scheduler' ? 'schtasks.exe' : adapters.scheduler === 'systemd' ? 'systemctl' : null; + const backgroundScheduling = evidence.scheduler + ? evidence.scheduler.supported ? available(evidence.scheduler.message, adapters.scheduler ?? undefined) : unavailable(evidence.scheduler.message) + : schedulerTool && has(schedulerTool) ? unverified('Native scheduler command found; verify the per-user session with radiocli alarm doctor.', adapters.scheduler ?? undefined) + : unavailable(adapters.scheduler ? `The ${schedulerTool} scheduler command is unavailable.` : `Reliable background scheduling has no verified adapter on ${host.id}.`); + const sleepTool = adapters.inhibitor === 'caffeinate' ? 'caffeinate' : adapters.inhibitor === 'logind' ? 'systemd-inhibit' : adapters.inhibitor === 'windows' ? 'powershell.exe' : null; + const volumeTool = adapters.volume === 'macos' ? has('osascript') ? 'osascript' : null + : adapters.volume === 'windows' ? ['powershell.exe', 'pwsh.exe'].find(has) + : adapters.volume === 'unix-audio' ? ['wpctl', 'pactl', 'amixer'].find(has) : null; + const storage = evidence.storageWritable === true ? available('The local data directory is writable; operations still verify each write.') + : evidence.storageWritable === false ? unavailable('Local data is not writable. Set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.') + : unverified('Local JSON data uses the existing private user paths; writability is checked when used.'); + return { + playback: backend ? available(`${backend} executable detected; stream and audio-device readiness are checked when tuning.`, backend) : unavailable('No playback backend detected. Run radiocli setup or configure a native player path.'), + playbackControls: backend === 'mpv' ? available('mpv IPC provides pause, mute, volume, metadata, and media keys.', 'mpv') + : unavailable(backend === 'airplay' ? 'AirPlay provides volume and mute; pause and media-key playback controls require local mpv.' : 'Pause, mute, volume, metadata, and media-key playback controls require mpv.'), + packageInstallation: evidence.packageManager ? available('Explicit setup plans require confirmation or --yes; --dry-run never installs.', evidence.packageManager) : unavailable('No supported package manager detected; install playback prerequisites manually.'), + storage, + filePermissions: adapters.posixPermissions ? available('Private files use mode 0600 and private directories use mode 0700; the filesystem must enforce POSIX permissions.') : available('Private user directories inherit Windows ACLs; POSIX modes do not configure Windows ACLs.'), + atomicWrites: evidence.storageWritable === false ? unavailable('Atomic replacement requires a writable destination directory.') : available('Temporary files are renamed within the destination directory; write failures are reported.'), + externalUrl: desktop && opener ? available('A desktop URL handler is available; execution is checked when used.', opener.command) : unavailable('No usable desktop URL handler/session detected. Open the station homepage manually.'), + clipboard: desktop && clipboard ? available('A clipboard command is available; execution is checked when used.', clipboard.command) : unavailable('No usable clipboard command/session detected. RadioCLI can display the text for manual copying.'), + terminalReopening: desktop && adapters.terminal ? unverified('A supported installed terminal must be found and its launch verified.', adapters.terminal) : unavailable('No supported graphical terminal session. Use the current TUI or explicitly select headless agent playback.'), + backgroundScheduling, + catchUp: backgroundScheduling.status === 'available' && evidence.scheduler?.catchUpAfterWake ? available('The native scheduler can deliver a missed occurrence; RadioCLI still enforces the configured grace window.') + : backgroundScheduling.status === 'unverified' ? unverified('Catch-up depends on an accessible native user scheduler and its session.') : unavailable('Catch-up delivery is not verified in this session.'), + wakeRequests: adapters.scheduler === 'task-scheduler' ? unverified('Task Scheduler can request WakeToRun; hardware, login state, and power policy decide delivery.') : unavailable('No privileged or exact wake request is made on this platform. Catch-up and idle-sleep inhibition are separate capabilities.'), + sleepInhibition: sleepTool && has(sleepTool) ? unverified('A sleep-inhibitor tool is installed; its lease must remain alive to provide protection.', sleepTool) : unavailable('No usable per-process sleep inhibitor. Normal playback remains available; sleep may interrupt it.'), + systemVolume: volumeTool ? unverified('The system mixer must respond before alarm volume can be raised and restored.', volumeTool) : unavailable('System output volume cannot be adjusted. Set the device volume manually; player volume remains independent.'), + unicode: evidence.unicode === false ? unavailable('ASCII decoration mode is selected; international station data is preserved.') : available('Unicode decoration is enabled; terminal font coverage determines glyph appearance.'), + color: evidence.color === false ? unavailable('Color is disabled for the terminal or by user preference.') : available('Color is enabled at the terminal supported depth.'), + screenReader: available(evidence.screenReader ? 'Screen-reader mode is enabled; decorative animation is disabled.' : 'Screen-reader mode is available through INK_SCREEN_READER=true.'), + airPlay: adapters.airPlay && evidence.backends.includes('airplay') ? available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay') : unavailable(adapters.airPlay ? 'Experimental AirPlay requires FFmpeg, Bonjour, and the safe optional sender.' : 'AirPlay output is available only on macOS.') + }; +} diff --git a/src/platform/desktop.ts b/src/platform/desktop.ts new file mode 100644 index 0000000..7f2ee38 --- /dev/null +++ b/src/platform/desktop.ts @@ -0,0 +1,27 @@ +import {identifyPlatform, type PlatformProfile} from './runtime.js'; + +export type SystemCommand = {command: string; args: string[]}; + +/** Command plans only. Callers must check the session and observe execution. */ +export function browserCommands(host: PlatformProfile = identifyPlatform()): SystemCommand[] { + if (host.id === 'darwin') return [{command: 'open', args: []}]; + if (host.id === 'win32') return [{command: 'explorer', args: []}]; + if (host.id === 'linux') return [{command: 'xdg-open', args: []}]; + return []; +} + +export function clipboardCandidates(host: PlatformProfile = identifyPlatform()): SystemCommand[] { + if (host.id === 'darwin') return [{command: 'pbcopy', args: []}]; + if (host.id === 'win32') return [{command: 'clip', args: []}]; + if (host.id === 'linux') return [ + {command: 'wl-copy', args: []}, + {command: 'xclip', args: ['-selection', 'clipboard']}, + {command: 'xsel', args: ['--clipboard', '--input']} + ]; + return []; +} + +export function hasGraphicalSession(host: PlatformProfile, env: NodeJS.ProcessEnv = process.env): boolean { + if (host.id === 'darwin' || host.id === 'win32') return !env.SSH_CONNECTION && !env.SSH_TTY; + return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); +} diff --git a/src/platform/executables.ts b/src/platform/executables.ts new file mode 100644 index 0000000..4843b17 --- /dev/null +++ b/src/platform/executables.ts @@ -0,0 +1,216 @@ +import {spawnSync} from 'node:child_process'; +import {accessSync, constants, existsSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {posix, win32} from 'node:path'; + +export type CommandDiscovery = 'path' | 'package-manager-shim' | 'application-directory' | 'windows-registry' | 'missing'; + +export type CommandResolution = { + path: string | null; + discovery: CommandDiscovery; +}; + +type CommandResolutionOptions = { + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + home: string; + isRunnable: (path: string) => boolean; + registryPaths: (command: string) => string[]; +}; + +export function commandExists(command: string): boolean { + return resolveCommand(command) !== null; +} + +// Resolve a runnable path for a command. GUI-launched terminals (macOS .app +// terminals, some Linux desktop launchers) frequently do not inherit the login +// shell PATH, while Windows installers often omit PATH entries entirely. +export function resolveCommand(command: string): string | null { + return resolveCommandDetails(command).path; +} + +export function resolveCommandDetails( + command: string, + overrides?: Partial +): CommandResolution { + if (!overrides) { + const cached = commandCache.get(command); + if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs) return cached.resolution; + } + + const platform = overrides?.platform ?? process.platform; + const options: CommandResolutionOptions = { + platform, + env: overrides?.env ?? process.env, + home: overrides?.home ?? homedir(), + isRunnable: overrides?.isRunnable ?? (path => isRunnable(path, platform)), + registryPaths: overrides?.registryPaths ?? (name => windowsRegistryPaths(name, platform)) + }; + const resolution = resolveUncached(command, options); + + if (!overrides) commandCache.set(command, {resolution, checkedAt: Date.now()}); + return resolution; +} + +function resolveUncached(command: string, options: CommandResolutionOptions): CommandResolution { + const pathMatch = lookupOnPath(command, options); + if (pathMatch) return {path: pathMatch, discovery: 'path'}; + + const shimMatch = firstRunnable(knownBinaryCandidates(command, options), options.isRunnable); + if (shimMatch) return {path: shimMatch, discovery: 'package-manager-shim'}; + + const appMatch = firstRunnable(applicationCandidates(command, options), options.isRunnable); + if (appMatch) return {path: appMatch, discovery: 'application-directory'}; + + if (options.platform === 'win32') { + const registryMatch = firstRunnable(options.registryPaths(command), options.isRunnable); + if (registryMatch) return {path: registryMatch, discovery: 'windows-registry'}; + } + + return {path: null, discovery: 'missing'}; +} + +function lookupOnPath(command: string, options: CommandResolutionOptions): string | null { + if (command.includes('/') || command.includes('\\')) return options.isRunnable(command) ? command : null; + + const pathApi = options.platform === 'win32' ? win32 : posix; + const separator = options.platform === 'win32' ? ';' : ':'; + const pathEntries = (options.env.PATH ?? '').split(separator).filter(Boolean); + for (const directory of pathEntries) { + for (const name of candidateNames(command, options.platform, options.env)) { + const candidate = pathApi.join(directory.replace(/^"|"$/g, ''), name); + if (options.isRunnable(candidate)) return candidate; + } + } + return null; +} + +function knownBinaryCandidates(command: string, options: CommandResolutionOptions): string[] { + const pathApi = options.platform === 'win32' ? win32 : posix; + const candidates: string[] = []; + for (const directory of knownBinaryDirs(options)) { + for (const name of candidateNames(command, options.platform, options.env)) { + candidates.push(pathApi.join(directory, name)); + } + } + return candidates; +} + +function candidateNames(command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] { + if (platform === 'win32' && !/\.[a-z0-9]+$/i.test(command)) { + const extensions = (env.PATHEXT ?? '.EXE;.COM;.BAT;.CMD').split(';').filter(Boolean); + return [...extensions.map(extension => `${command}${extension.toLowerCase()}`), command]; + } + return [command]; +} + +const commandCacheTtlMs = 5000; +const commandCache = new Map(); + +export function clearCommandCache(): void { + commandCache.clear(); +} + +function isRunnable(path: string, platform: NodeJS.Platform): boolean { + if (!existsSync(path)) return false; + if (platform === 'win32') return true; + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +function knownBinaryDirs({platform, env, home}: CommandResolutionOptions): string[] { + if (platform === 'darwin') { + return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', posix.join(home, '.local', 'bin')]; + } + + if (platform === 'win32') { + const dirs: string[] = []; + if (env.LOCALAPPDATA) dirs.push(win32.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links')); + dirs.push(win32.join(home, 'scoop', 'shims')); + if (env.ProgramData) dirs.push(win32.join(env.ProgramData, 'chocolatey', 'bin')); + return dirs; + } + + return [ + '/usr/bin', + '/usr/local/bin', + '/bin', + '/usr/local/sbin', + '/snap/bin', + '/var/lib/flatpak/exports/bin', + posix.join(home, '.local', 'bin') + ]; +} + +// Media-player installers commonly skip PATH. Probe only their documented, +// narrow install locations so startup stays fast and filesystem access bounded. +function applicationCandidates(command: string, {platform, env, home}: CommandResolutionOptions): string[] { + if ((command === 'vlc' || command === 'cvlc') && platform === 'darwin') { + return [ + '/Applications/VLC.app/Contents/MacOS/VLC', + posix.join(home, 'Applications', 'VLC.app', 'Contents', 'MacOS', 'VLC') + ]; + } + + if (platform !== 'win32') return []; + + if (command === 'vlc' || command === 'cvlc') { + return windowsProgramRoots(env).map(root => win32.join(root, 'VideoLAN', 'VLC', 'vlc.exe')); + } + + if (command === 'mpv') { + const candidates = windowsProgramRoots(env).flatMap(root => [ + win32.join(root, 'MPV Player', 'mpv.exe'), + win32.join(root, 'MPV', 'mpv.exe'), + win32.join(root, 'mpv', 'mpv.exe') + ]); + if (env.LOCALAPPDATA) { + candidates.push( + win32.join(env.LOCALAPPDATA, 'Programs', 'MPV Player', 'mpv.exe'), + win32.join(env.LOCALAPPDATA, 'Programs', 'MPV', 'mpv.exe'), + win32.join(env.LOCALAPPDATA, 'mpv', 'mpv.exe') + ); + } + return candidates; + } + + return []; +} + +function windowsProgramRoots(env: NodeJS.ProcessEnv): string[] { + return [env.ProgramFiles, env['ProgramFiles(x86)']].filter((root): root is string => Boolean(root)); +} + +function windowsRegistryPaths(command: string, platform: NodeJS.Platform): string[] { + if (platform !== 'win32' || command !== 'mpv') return []; + + const paths = [ + queryRegistryPath('HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\mpv.exe', '/ve'), + queryRegistryPath('HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\mpv.exe', '/ve'), + queryRegistryPath('HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\MPV_Player_Automated_is1', '/v', 'InstallLocation') + ]; + return paths.flatMap(path => path ? [path.toLowerCase().endsWith('.exe') ? path : win32.join(path, 'mpv.exe')] : []); +} + +function queryRegistryPath(key: string, ...args: string[]): string | null { + const result = spawnSync('reg.exe', ['query', key, ...args], { + encoding: 'utf8', + timeout: 1000, + windowsHide: true + }); + if (result.error || result.status !== 0) return null; + + const match = result.stdout.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/m); + return match?.[1]?.trim() || null; +} + +function firstRunnable(paths: string[], isRunnablePath: (path: string) => boolean): string | null { + for (const path of paths) { + if (isRunnablePath(path)) return path; + } + return null; +} diff --git a/src/platform/packages.ts b/src/platform/packages.ts new file mode 100644 index 0000000..b40a676 --- /dev/null +++ b/src/platform/packages.ts @@ -0,0 +1,124 @@ +import {existsSync, readFileSync} from 'node:fs'; +import {commandExists} from './executables.js'; + +export type SetupComponent = 'mpv' | 'ffmpeg' | 'vlc'; +export type SetupPackageManager = 'brew' | 'winget' | 'scoop' | 'choco' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper'; + +export type SetupCommand = { + component: SetupComponent | null; + label: string; + program: string; + args: string[]; + display: string; +}; + +export const packageManagers: SetupPackageManager[] = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper']; + +export function detectPackageManager( + platform: NodeJS.Platform, + osRelease: string, + hasCommand: (command: string) => boolean = commandExists +): SetupPackageManager | null { + if (platform === 'darwin') return hasCommand('brew') ? 'brew' : null; + if (platform === 'win32') return firstAvailable(['winget', 'scoop', 'choco'], hasCommand); + if (platform !== 'linux') return null; + + const ids = linuxReleaseIds(osRelease); + const preferred: SetupPackageManager[] = hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop']) + ? ['apt'] + : hasAny(ids, ['fedora', 'rhel', 'centos']) + ? ['dnf'] + : hasAny(ids, ['arch', 'manjaro']) + ? ['pacman'] + : hasAny(ids, ['alpine']) + ? ['apk'] + : hasAny(ids, ['opensuse', 'suse']) + ? ['zypper'] + : ['apt', 'dnf', 'pacman', 'apk', 'zypper']; + return firstAvailable(preferred, hasCommand); +} + +export function packageInstallCommand(manager: SetupPackageManager, component: SetupComponent): SetupCommand { + const packages: Record> = { + brew: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + winget: {mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC'}, + scoop: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + choco: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + apt: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + dnf: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + pacman: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + apk: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + zypper: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'} + }; + const packageName = packages[manager][component]; + let program: string = manager; + let args: string[]; + + if (manager === 'brew') args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])]; + else if (manager === 'winget') args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements']; + else if (manager === 'scoop') args = ['install', packageName]; + else if (manager === 'choco') args = ['install', packageName, '-y']; + else if (manager === 'apt') { + program = 'sudo'; + args = ['apt-get', 'install', '-y', packageName]; + } else if (manager === 'dnf') { + program = 'sudo'; + args = ['dnf', 'install', '-y', packageName]; + } else if (manager === 'pacman') { + program = 'sudo'; + args = ['pacman', '-S', '--needed', '--noconfirm', packageName]; + } else if (manager === 'apk') { + program = 'sudo'; + args = ['apk', 'add', packageName]; + } else { + program = 'sudo'; + args = ['zypper', '--non-interactive', 'install', packageName]; + } + + return {component, label: componentLabel(component), program, args, display: [program, ...args].join(' ')}; +} + +export function platformLabel(platform: NodeJS.Platform, osRelease: string): string { + if (platform === 'darwin') return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`; + if (platform === 'win32') return 'Windows'; + if (platform === 'linux') return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux'; + return platform; +} + +export function componentLabel(component: SetupComponent): string { + if (component === 'ffmpeg') return 'FFmpeg / ffplay'; + if (component === 'vlc') return 'VLC fallback'; + return 'mpv'; +} + +function firstAvailable(values: T[], hasCommand: (command: string) => boolean): T | null { + return values.find(hasCommand) ?? null; +} + +export function readLinuxOsRelease(platform: NodeJS.Platform = process.platform): string { + if (platform !== 'linux' || !existsSync('/etc/os-release')) return ''; + try { + return readFileSync('/etc/os-release', 'utf8'); + } catch { + return ''; + } +} + +export function linuxReleaseIds(osRelease: string): Set { + const ids = new Set(); + for (const line of osRelease.split('\n')) { + const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); + if (!match) continue; + for (const value of match[2]!.replaceAll('"', '').split(/\s+/)) if (value.trim()) ids.add(value.trim().toLowerCase()); + } + return ids; +} + +function osReleaseValue(osRelease: string, key: string): string { + const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`)); + return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? ''; +} + +export function hasAny(values: Set, candidates: string[]): boolean { + return candidates.some(candidate => values.has(candidate)); +} diff --git a/src/platform/paths.test.ts b/src/platform/paths.test.ts new file mode 100644 index 0000000..9d579c5 --- /dev/null +++ b/src/platform/paths.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, it} from 'vitest'; +import {platformPaths} from './paths.js'; + +describe('portable paths and historical compatibility', () => { + it('preserves macOS data/cache paths and the two existing runtime namespaces', () => { + const paths = platformPaths({platform: 'darwin', home: '/Users/Listener', env: {}}); + expect(paths).toMatchObject({ + library: '/Users/Listener/Library/Application Support/radiocli/radiocli.json', + legacyLibrary: '/Users/Listener/Library/Application Support/radio-atlas/radio-atlas.json', + cache: '/Users/Listener/Library/Caches/radiocli/radiocli-cache.json', + legacyCache: '/Users/Listener/Library/Application Support/radio-atlas/radio-atlas-cache.json', + runtime: '/Users/Listener/.local/state/radiocli', + alarmRuntime: '/Users/Listener/Library/Application Support/radiocli/runtime' + }); + }); + + it('uses Windows path semantics even in a mocked host test', () => { + expect(platformPaths({platform: 'win32', home: 'C:\\Users\\Zoë', env: {APPDATA: 'D:\\Roaming Data', LOCALAPPDATA: 'D:\\Local Data'}})).toMatchObject({ + library: 'D:\\Roaming Data\\RadioCLI\\radiocli.json', + cache: 'D:\\Local Data\\RadioCLI\\radiocli-cache.json', + runtime: 'D:\\Local Data\\RadioCLI\\runtime', + alarmRuntime: 'D:\\Local Data\\RadioCLI\\runtime' + }); + }); + + it.each(['linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const)( + 'retains the existing XDG paths on %s', platform => { + expect(platformPaths({platform, home: '/home/Zoë', env: {XDG_DATA_HOME: '/data space', XDG_CACHE_HOME: '/cache space', XDG_RUNTIME_DIR: '/runtime space'}})).toMatchObject({ + library: '/data space/radiocli/radiocli.json', + cache: '/cache space/radiocli/radiocli-cache.json', + runtime: '/runtime space/radiocli', + alarmRuntime: '/runtime space/radiocli' + }); + } + ); + + it('keeps explicit current and legacy home overrides in their original precedence', () => { + expect(platformPaths({platform: 'linux', home: '/home/test', env: {RADIOCLI_HOME: '/radio 新', RADIO_ATLAS_HOME: '/old'}})).toMatchObject({ + library: '/radio 新/radiocli.json', cache: '/radio 新/radiocli-cache.json', runtime: '/radio 新/runtime', alarmRuntime: '/radio 新/runtime' + }); + expect(platformPaths({platform: 'linux', home: '/home/test', env: {RADIO_ATLAS_HOME: '/old'}})).toMatchObject({ + library: '/old/radio-atlas.json', cache: '/old/radio-atlas-cache.json', runtime: '/home/test/.local/state/radiocli' + }); + }); +}); diff --git a/src/platform/paths.ts b/src/platform/paths.ts new file mode 100644 index 0000000..d6117ae --- /dev/null +++ b/src/platform/paths.ts @@ -0,0 +1,37 @@ +import {homedir} from 'node:os'; +import {posix, win32} from 'node:path'; + +type PathInput = {platform?: string; home?: string; env?: NodeJS.ProcessEnv}; + +/** + * Keep historical namespaces stable. In particular, macOS alarm-control/Guard + * files and general agent/occurrence runtime files already use different roots. + * Moving either would strand active authenticated sessions during an upgrade. + */ +export function platformPaths({platform = process.platform, home = homedir(), env = process.env}: PathInput = {}) { + const path = platform === 'win32' ? win32 : posix; + const dataRoot = platform === 'darwin' ? path.join(home, 'Library', 'Application Support') + : platform === 'win32' ? env.APPDATA ?? path.join(home, 'AppData', 'Roaming') + : env.XDG_DATA_HOME ?? path.join(home, '.local', 'share'); + const cacheRoot = platform === 'darwin' ? path.join(home, 'Library', 'Caches') + : platform === 'win32' ? env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local') + : env.XDG_CACHE_HOME ?? path.join(home, '.cache'); + const appName = platform === 'win32' ? 'RadioCLI' : 'radiocli'; + const legacyRoot = platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : env.XDG_DATA_HOME ?? path.join(home, '.local', 'share'); + const legacyCacheRoot = platform === 'darwin' ? legacyRoot : env.XDG_CACHE_HOME ?? path.join(home, '.cache'); + const override = env.RADIOCLI_HOME || env.RADIO_ATLAS_HOME; + const overrideName = env.RADIOCLI_HOME ? 'radiocli' : 'radio-atlas'; + const library = override ? path.join(override, `${overrideName}.json`) : path.join(dataRoot, appName, 'radiocli.json'); + const cache = override ? path.join(override, `${overrideName}-cache.json`) : path.join(cacheRoot, appName, 'radiocli-cache.json'); + const runtime = env.RADIOCLI_HOME ? path.join(env.RADIOCLI_HOME, 'runtime') + : platform === 'win32' ? path.join(cacheRoot, appName, 'runtime') + : path.join(env.XDG_RUNTIME_DIR ?? path.join(home, '.local', 'state'), 'radiocli'); + return { + library, + legacyLibrary: override ? library : path.join(legacyRoot, 'radio-atlas', 'radio-atlas.json'), + cache, + legacyCache: override ? cache : path.join(legacyCacheRoot, 'radio-atlas', 'radio-atlas-cache.json'), + runtime, + alarmRuntime: !env.RADIOCLI_HOME && platform === 'darwin' ? path.join(dataRoot, 'radiocli', 'runtime') : runtime + }; +} diff --git a/src/platform/runtime.test.ts b/src/platform/runtime.test.ts new file mode 100644 index 0000000..4503e69 --- /dev/null +++ b/src/platform/runtime.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it} from 'vitest'; +import {identifyPlatform, nativeAdapters} from './runtime.js'; + +describe('platform identity and adapter policy', () => { + it.each(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'sunos', 'aix', 'haiku'] as const)( + 'identifies %s without borrowing another operating system identity', platform => { + const host = identifyPlatform({platform, arch: 'x64', env: {}, release: '1', nodeVersion: '22.23.2'}); + expect(host.id).toBe(platform); + expect(host.platform).toBe(platform); + expect(host.isWsl).toBe(false); + } + ); + + it('distinguishes Android/Termux from ordinary Linux and musl', () => { + expect(identifyPlatform({platform: 'android', env: {TERMUX_VERSION: '0.118.3'}})).toMatchObject({id: 'termux', libc: 'bionic'}); + expect(identifyPlatform({platform: 'linux', env: {TERMUX_VERSION: '0.118.3', PREFIX: '/data/data/com.termux/files/usr'}})).toMatchObject({id: 'termux', libc: 'bionic'}); + expect(identifyPlatform({platform: 'android', env: {}})).toMatchObject({id: 'android', libc: 'bionic'}); + expect(identifyPlatform({platform: 'darwin', env: {TERMUX_VERSION: '0.118.3'}}).id).toBe('darwin'); + }); + + it('records WSL and libc independently of Linux adapters', () => { + const host = identifyPlatform({platform: 'linux', release: '6.6.87.2-microsoft-standard-WSL2', libc: 'glibc', env: {}}); + expect(host).toMatchObject({id: 'linux', isWsl: true, libc: 'glibc'}); + expect(nativeAdapters(host).scheduler).toBe('systemd'); + expect(identifyPlatform({platform: 'linux', release: '6.6', libc: 'musl', env: {}})).toMatchObject({isWsl: false, libc: 'musl'}); + }); + + it('does not invent a native service for BSD, Termux, Haiku, illumos, or AIX', () => { + for (const platform of ['freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const) { + const policy = nativeAdapters(identifyPlatform({platform, env: {}})); + expect(policy.scheduler, platform).toBeNull(); + expect(policy.inhibitor, platform).toBeNull(); + expect(policy.airPlay, platform).toBe(false); + expect(policy.ipc, platform).toBe('unix-socket'); + expect(policy.posixPermissions, platform).toBe(true); + } + }); + + it('preserves the existing macOS, Linux, and Windows adapter selections', () => { + const host = (platform: NodeJS.Platform) => identifyPlatform({platform, env: {}}); + expect(nativeAdapters(host('darwin'))).toMatchObject({scheduler: 'launchd', inhibitor: 'caffeinate', volume: 'macos', terminal: 'macos', airPlay: true, ipc: 'unix-socket'}); + expect(nativeAdapters(host('linux'))).toMatchObject({scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix', airPlay: false, ipc: 'unix-socket'}); + expect(nativeAdapters(host('win32'))).toMatchObject({scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', airPlay: false, ipc: 'named-pipe', posixPermissions: false}); + }); + + it('keeps unknown runtimes explicit rather than calling them Linux', () => { + const host = identifyPlatform({platform: 'future-os', arch: 'future-cpu', nodeVersion: '24.20.0', env: {}}); + expect(host).toMatchObject({id: 'unknown', platform: 'future-os', arch: 'future-cpu', nodeMajor: 24}); + expect(nativeAdapters(host)).toMatchObject({scheduler: null, inhibitor: null, volume: null, terminal: null, airPlay: false}); + }); +}); diff --git a/src/platform/runtime.ts b/src/platform/runtime.ts new file mode 100644 index 0000000..52f531a --- /dev/null +++ b/src/platform/runtime.ts @@ -0,0 +1,89 @@ +import {readFileSync} from 'node:fs'; +import {endianness, release} from 'node:os'; + +type PlatformId = 'darwin' | 'win32' | 'linux' | 'freebsd' | 'openbsd' | 'netbsd' | 'android' | 'termux' | 'haiku' | 'sunos' | 'aix' | 'unknown'; +type Libc = 'glibc' | 'musl' | 'bionic' | 'unknown' | 'none'; + +export type PlatformProfile = { + id: PlatformId; + platform: string; + arch: string; + endianness: 'LE' | 'BE'; + release: string; + nodeVersion: string; + nodeMajor: number; + libc: Libc; + isWsl: boolean; + osRelease: string; +}; + +type PlatformInput = { + platform?: string; + arch?: string; + endianness?: 'LE' | 'BE'; + release?: string; + nodeVersion?: string; + libc?: Libc; + env?: NodeJS.ProcessEnv; + osRelease?: string; +}; + +const knownPlatforms = new Set(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix']); + +/** Host facts are independent from service readiness and project support tiers. */ +export function identifyPlatform(input: PlatformInput = {}): PlatformProfile { + const platform = input.platform ?? process.platform; + const env = input.env ?? process.env; + const termux = (platform === 'android' || platform === 'linux') && Boolean(env.TERMUX_VERSION || env.PREFIX?.match(/^\/data\/(?:data|user\/\d+)\/com\.termux(?:\.[^/]+)?\/files\/usr\/?$/)); + const id: PlatformId = termux ? 'termux' : knownPlatforms.has(platform) ? platform as PlatformId : 'unknown'; + const kernelRelease = input.release ?? release(); + const nodeVersion = (input.nodeVersion ?? process.versions.node).replace(/^v/, ''); + return { + id, + platform, + arch: input.arch ?? process.arch, + endianness: input.endianness ?? endianness(), + release: kernelRelease, + nodeVersion, + nodeMajor: Number.parseInt(nodeVersion, 10), + libc: platform === 'android' || termux ? 'bionic' : input.libc ?? (platform === 'linux' ? nativeLibc(platform) : 'none'), + isWsl: id === 'linux' && Boolean(env.WSL_INTEROP || env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(kernelRelease)), + osRelease: input.osRelease ?? (platform === 'linux' && platform === process.platform ? readOsRelease() : '') + }; +} + +export type NativeAdapterPolicy = { + scheduler: 'launchd' | 'task-scheduler' | 'systemd' | null; + inhibitor: 'caffeinate' | 'logind' | 'windows' | null; + volume: 'macos' | 'windows' | 'unix-audio' | null; + terminal: 'macos' | 'windows' | 'unix' | null; + ipc: 'named-pipe' | 'unix-socket'; + posixPermissions: boolean; + airPlay: boolean; +}; + +/** Selecting a native adapter does not establish that its service is running. */ +export function nativeAdapters(host: PlatformProfile = identifyPlatform()): NativeAdapterPolicy { + const portable: NativeAdapterPolicy = {scheduler: null, inhibitor: null, volume: null, terminal: null, ipc: 'unix-socket', posixPermissions: true, airPlay: false}; + if (host.id === 'darwin') return {...portable, scheduler: 'launchd', inhibitor: 'caffeinate', volume: 'macos', terminal: 'macos', airPlay: true}; + if (host.id === 'win32') return {...portable, scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', ipc: 'named-pipe', posixPermissions: false}; + if (host.id === 'linux') return {...portable, scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix'}; + return portable; +} + +function readOsRelease(): string { + try { return readFileSync('/etc/os-release', 'utf8'); } + catch { return ''; } +} + +let detectedLibc: Libc | undefined; +function nativeLibc(platform: string): Libc { + if (platform !== process.platform) return 'unknown'; + if (detectedLibc) return detectedLibc; + try { + const report = process.report.getReport() as {header?: {glibcVersionRuntime?: string}; sharedObjects?: string[]}; + detectedLibc = report.header?.glibcVersionRuntime ? 'glibc' + : report.sharedObjects?.some(path => /(?:ld-musl|libc\.musl)/.test(path)) ? 'musl' : 'unknown'; + } catch { detectedLibc = 'unknown'; } + return detectedLibc; +} diff --git a/src/player/backend-install.ts b/src/player/backend-install.ts index 2163333..0072eb6 100644 --- a/src/player/backend-install.ts +++ b/src/player/backend-install.ts @@ -1,4 +1,5 @@ -import {existsSync, readFileSync} from 'node:fs'; +import {hasAny, linuxReleaseIds, readLinuxOsRelease} from '../platform/packages.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; import {commandExists} from './command.js'; import {airPlaySenderHealth} from './airplay-sender-health.js'; @@ -30,7 +31,7 @@ export function detectPlaybackBackends({ backends.push('vlc'); } - if (platform === 'darwin' && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) { + if (nativeAdapters(identifyPlatform({platform})).airPlay && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) { backends.push('airplay'); } @@ -241,38 +242,3 @@ function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRe return 'install FFmpeg with your system package manager'; } - -function readLinuxOsRelease(): string { - if (process.platform !== 'linux' || !existsSync('/etc/os-release')) { - return ''; - } - - try { - return readFileSync('/etc/os-release', 'utf8'); - } catch { - return ''; - } -} - -function linuxReleaseIds(osRelease: string): Set { - const ids = new Set(); - for (const line of osRelease.split('\n')) { - const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); - if (!match) { - continue; - } - - for (const value of match[2]!.replaceAll('"', '').split(/\s+/)) { - const normalized = value.trim().toLowerCase(); - if (normalized) { - ids.add(normalized); - } - } - } - - return ids; -} - -function hasAny(values: Set, candidates: string[]): boolean { - return candidates.some(candidate => values.has(candidate)); -} diff --git a/src/player/command.ts b/src/player/command.ts index 4843b17..f45dc72 100644 --- a/src/player/command.ts +++ b/src/player/command.ts @@ -1,216 +1,4 @@ -import {spawnSync} from 'node:child_process'; -import {accessSync, constants, existsSync} from 'node:fs'; -import {homedir} from 'node:os'; -import {posix, win32} from 'node:path'; - -export type CommandDiscovery = 'path' | 'package-manager-shim' | 'application-directory' | 'windows-registry' | 'missing'; - -export type CommandResolution = { - path: string | null; - discovery: CommandDiscovery; -}; - -type CommandResolutionOptions = { - platform: NodeJS.Platform; - env: NodeJS.ProcessEnv; - home: string; - isRunnable: (path: string) => boolean; - registryPaths: (command: string) => string[]; -}; - -export function commandExists(command: string): boolean { - return resolveCommand(command) !== null; -} - -// Resolve a runnable path for a command. GUI-launched terminals (macOS .app -// terminals, some Linux desktop launchers) frequently do not inherit the login -// shell PATH, while Windows installers often omit PATH entries entirely. -export function resolveCommand(command: string): string | null { - return resolveCommandDetails(command).path; -} - -export function resolveCommandDetails( - command: string, - overrides?: Partial -): CommandResolution { - if (!overrides) { - const cached = commandCache.get(command); - if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs) return cached.resolution; - } - - const platform = overrides?.platform ?? process.platform; - const options: CommandResolutionOptions = { - platform, - env: overrides?.env ?? process.env, - home: overrides?.home ?? homedir(), - isRunnable: overrides?.isRunnable ?? (path => isRunnable(path, platform)), - registryPaths: overrides?.registryPaths ?? (name => windowsRegistryPaths(name, platform)) - }; - const resolution = resolveUncached(command, options); - - if (!overrides) commandCache.set(command, {resolution, checkedAt: Date.now()}); - return resolution; -} - -function resolveUncached(command: string, options: CommandResolutionOptions): CommandResolution { - const pathMatch = lookupOnPath(command, options); - if (pathMatch) return {path: pathMatch, discovery: 'path'}; - - const shimMatch = firstRunnable(knownBinaryCandidates(command, options), options.isRunnable); - if (shimMatch) return {path: shimMatch, discovery: 'package-manager-shim'}; - - const appMatch = firstRunnable(applicationCandidates(command, options), options.isRunnable); - if (appMatch) return {path: appMatch, discovery: 'application-directory'}; - - if (options.platform === 'win32') { - const registryMatch = firstRunnable(options.registryPaths(command), options.isRunnable); - if (registryMatch) return {path: registryMatch, discovery: 'windows-registry'}; - } - - return {path: null, discovery: 'missing'}; -} - -function lookupOnPath(command: string, options: CommandResolutionOptions): string | null { - if (command.includes('/') || command.includes('\\')) return options.isRunnable(command) ? command : null; - - const pathApi = options.platform === 'win32' ? win32 : posix; - const separator = options.platform === 'win32' ? ';' : ':'; - const pathEntries = (options.env.PATH ?? '').split(separator).filter(Boolean); - for (const directory of pathEntries) { - for (const name of candidateNames(command, options.platform, options.env)) { - const candidate = pathApi.join(directory.replace(/^"|"$/g, ''), name); - if (options.isRunnable(candidate)) return candidate; - } - } - return null; -} - -function knownBinaryCandidates(command: string, options: CommandResolutionOptions): string[] { - const pathApi = options.platform === 'win32' ? win32 : posix; - const candidates: string[] = []; - for (const directory of knownBinaryDirs(options)) { - for (const name of candidateNames(command, options.platform, options.env)) { - candidates.push(pathApi.join(directory, name)); - } - } - return candidates; -} - -function candidateNames(command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] { - if (platform === 'win32' && !/\.[a-z0-9]+$/i.test(command)) { - const extensions = (env.PATHEXT ?? '.EXE;.COM;.BAT;.CMD').split(';').filter(Boolean); - return [...extensions.map(extension => `${command}${extension.toLowerCase()}`), command]; - } - return [command]; -} - -const commandCacheTtlMs = 5000; -const commandCache = new Map(); - -export function clearCommandCache(): void { - commandCache.clear(); -} - -function isRunnable(path: string, platform: NodeJS.Platform): boolean { - if (!existsSync(path)) return false; - if (platform === 'win32') return true; - try { - accessSync(path, constants.X_OK); - return true; - } catch { - return false; - } -} - -function knownBinaryDirs({platform, env, home}: CommandResolutionOptions): string[] { - if (platform === 'darwin') { - return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', posix.join(home, '.local', 'bin')]; - } - - if (platform === 'win32') { - const dirs: string[] = []; - if (env.LOCALAPPDATA) dirs.push(win32.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links')); - dirs.push(win32.join(home, 'scoop', 'shims')); - if (env.ProgramData) dirs.push(win32.join(env.ProgramData, 'chocolatey', 'bin')); - return dirs; - } - - return [ - '/usr/bin', - '/usr/local/bin', - '/bin', - '/usr/local/sbin', - '/snap/bin', - '/var/lib/flatpak/exports/bin', - posix.join(home, '.local', 'bin') - ]; -} - -// Media-player installers commonly skip PATH. Probe only their documented, -// narrow install locations so startup stays fast and filesystem access bounded. -function applicationCandidates(command: string, {platform, env, home}: CommandResolutionOptions): string[] { - if ((command === 'vlc' || command === 'cvlc') && platform === 'darwin') { - return [ - '/Applications/VLC.app/Contents/MacOS/VLC', - posix.join(home, 'Applications', 'VLC.app', 'Contents', 'MacOS', 'VLC') - ]; - } - - if (platform !== 'win32') return []; - - if (command === 'vlc' || command === 'cvlc') { - return windowsProgramRoots(env).map(root => win32.join(root, 'VideoLAN', 'VLC', 'vlc.exe')); - } - - if (command === 'mpv') { - const candidates = windowsProgramRoots(env).flatMap(root => [ - win32.join(root, 'MPV Player', 'mpv.exe'), - win32.join(root, 'MPV', 'mpv.exe'), - win32.join(root, 'mpv', 'mpv.exe') - ]); - if (env.LOCALAPPDATA) { - candidates.push( - win32.join(env.LOCALAPPDATA, 'Programs', 'MPV Player', 'mpv.exe'), - win32.join(env.LOCALAPPDATA, 'Programs', 'MPV', 'mpv.exe'), - win32.join(env.LOCALAPPDATA, 'mpv', 'mpv.exe') - ); - } - return candidates; - } - - return []; -} - -function windowsProgramRoots(env: NodeJS.ProcessEnv): string[] { - return [env.ProgramFiles, env['ProgramFiles(x86)']].filter((root): root is string => Boolean(root)); -} - -function windowsRegistryPaths(command: string, platform: NodeJS.Platform): string[] { - if (platform !== 'win32' || command !== 'mpv') return []; - - const paths = [ - queryRegistryPath('HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\mpv.exe', '/ve'), - queryRegistryPath('HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\mpv.exe', '/ve'), - queryRegistryPath('HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\MPV_Player_Automated_is1', '/v', 'InstallLocation') - ]; - return paths.flatMap(path => path ? [path.toLowerCase().endsWith('.exe') ? path : win32.join(path, 'mpv.exe')] : []); -} - -function queryRegistryPath(key: string, ...args: string[]): string | null { - const result = spawnSync('reg.exe', ['query', key, ...args], { - encoding: 'utf8', - timeout: 1000, - windowsHide: true - }); - if (result.error || result.status !== 0) return null; - - const match = result.stdout.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/m); - return match?.[1]?.trim() || null; -} - -function firstRunnable(paths: string[], isRunnablePath: (path: string) => boolean): string | null { - for (const path of paths) { - if (isRunnablePath(path)) return path; - } - return null; -} +// Keep the existing playback import path stable; executable discovery is shared +// by every platform integration. +export {clearCommandCache, commandExists, resolveCommand, resolveCommandDetails} from '../platform/executables.js'; +export type {CommandDiscovery} from '../platform/executables.js'; diff --git a/src/providers/cache.ts b/src/providers/cache.ts index 1788910..17b0b66 100644 --- a/src/providers/cache.ts +++ b/src/providers/cache.ts @@ -1,6 +1,6 @@ import {chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync} from 'node:fs'; import {createHash} from 'node:crypto'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {join} from 'node:path'; type CacheEnvelope = { @@ -116,37 +116,8 @@ export class ProviderCache { } function defaultProviderCachePath(): string { - if (process.env.RADIOCLI_HOME) { - return join(process.env.RADIOCLI_HOME, 'radiocli-cache.json'); - } - - if (process.env.RADIO_ATLAS_HOME) { - return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas-cache.json'); - } - - const currentPath = currentDefaultProviderCachePath(); - const legacyPath = legacyDefaultProviderCachePath(); - return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath; -} - -function currentDefaultProviderCachePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Caches', 'radiocli', 'radiocli-cache.json'); - } - - if (process.platform === 'win32') { - return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'radiocli-cache.json'); - } - - return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radiocli', 'radiocli-cache.json'); -} - -function legacyDefaultProviderCachePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas-cache.json'); - } - - return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radio-atlas', 'radio-atlas-cache.json'); + const {cache, legacyCache} = platformPaths(); + return existsSync(cache) || !existsSync(legacyCache) ? cache : legacyCache; } function writeJsonAtomically(filePath: string, value: unknown): void { diff --git a/src/setup.ts b/src/setup.ts index 51d0916..e32c79f 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,5 +1,8 @@ +import {componentLabel, detectPackageManager, packageInstallCommand, packageManagers, platformLabel, readLinuxOsRelease, type SetupCommand, type SetupComponent, type SetupPackageManager} from './platform/packages.js'; +export {detectPackageManager}; +export type {SetupComponent}; import {spawn, type SpawnOptions} from 'node:child_process'; -import {existsSync, readFileSync, realpathSync} from 'node:fs'; +import {realpathSync} from 'node:fs'; import {createInterface} from 'node:readline/promises'; import type {Readable, Writable} from 'node:stream'; import {clearCommandCache, commandExists} from './player/command.js'; @@ -8,17 +11,6 @@ import {configureMcpIntegrations} from './agent/mcp-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; -export type SetupComponent = 'mpv' | 'ffmpeg' | 'vlc'; -export type SetupPackageManager = 'brew' | 'winget' | 'scoop' | 'choco' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper'; - -type SetupCommand = { - component: SetupComponent | null; - label: string; - program: string; - args: string[]; - display: string; -}; - export type SetupPlan = { platform: NodeJS.Platform; platformLabel: string; @@ -49,7 +41,6 @@ type ParsedSetupArgs = { }; const components: SetupComponent[] = ['mpv', 'ffmpeg', 'vlc']; -const packageManagers: SetupPackageManager[] = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper']; export async function runSetup(options: SetupOptions = {}): Promise { const platform = options.platform ?? process.platform; @@ -175,30 +166,6 @@ export function createSetupPlan({ }; } -export function detectPackageManager( - platform: NodeJS.Platform, - osRelease: string, - hasCommand: (command: string) => boolean = commandExists -): SetupPackageManager | null { - if (platform === 'darwin') return hasCommand('brew') ? 'brew' : null; - if (platform === 'win32') return firstAvailable(['winget', 'scoop', 'choco'], hasCommand); - if (platform !== 'linux') return null; - - const ids = linuxReleaseIds(osRelease); - const preferred: SetupPackageManager[] = hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop']) - ? ['apt'] - : hasAny(ids, ['fedora', 'rhel', 'centos']) - ? ['dnf'] - : hasAny(ids, ['arch', 'manjaro']) - ? ['pacman'] - : hasAny(ids, ['alpine']) - ? ['apk'] - : hasAny(ids, ['opensuse', 'suse']) - ? ['zypper'] - : ['apt', 'dnf', 'pacman', 'apk', 'zypper']; - return firstAvailable(preferred, hasCommand); -} - export function parseSetupArgs(args: string[]): ParsedSetupArgs { let all = false; let dryRun = false; @@ -279,46 +246,6 @@ function parsePackageManager(value: string | undefined): SetupPackageManager { return value as SetupPackageManager; } -function packageInstallCommand(manager: SetupPackageManager, component: SetupComponent): SetupCommand { - const packages: Record> = { - brew: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - winget: {mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC'}, - scoop: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - choco: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - apt: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - dnf: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - pacman: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - apk: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - zypper: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'} - }; - const packageName = packages[manager][component]; - let program: string = manager; - let args: string[]; - - if (manager === 'brew') args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])]; - else if (manager === 'winget') args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements']; - else if (manager === 'scoop') args = ['install', packageName]; - else if (manager === 'choco') args = ['install', packageName, '-y']; - else if (manager === 'apt') { - program = 'sudo'; - args = ['apt-get', 'install', '-y', packageName]; - } else if (manager === 'dnf') { - program = 'sudo'; - args = ['dnf', 'install', '-y', packageName]; - } else if (manager === 'pacman') { - program = 'sudo'; - args = ['pacman', '-S', '--needed', '--noconfirm', packageName]; - } else if (manager === 'apk') { - program = 'sudo'; - args = ['apk', 'add', packageName]; - } else { - program = 'sudo'; - args = ['zypper', '--non-interactive', 'install', packageName]; - } - - return {component, label: componentLabel(component), program, args, display: [program, ...args].join(' ')}; -} - function defaultComponents(platform: NodeJS.Platform, all: boolean): SetupComponent[] { if (all) return [...components]; return platform === 'darwin' ? ['mpv', 'ffmpeg'] : ['mpv']; @@ -433,19 +360,6 @@ function writeHeader(output: Writable): void { output.write(`${accent(output, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}\n`); } -function platformLabel(platform: NodeJS.Platform, osRelease: string): string { - if (platform === 'darwin') return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`; - if (platform === 'win32') return 'Windows'; - if (platform === 'linux') return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux'; - return platform; -} - -function componentLabel(component: SetupComponent): string { - if (component === 'ffmpeg') return 'FFmpeg / ffplay'; - if (component === 'vlc') return 'VLC fallback'; - return 'mpv'; -} - function isInteractive(input: Readable, output: Writable): boolean { return Boolean((input as NodeJS.ReadStream).isTTY && (output as NodeJS.WriteStream).isTTY); } @@ -494,35 +408,3 @@ async function runVisibleCommand(program: string, args: string[]): Promise child.once('close', code => code === 0 ? resolve() : reject(new Error(`${program} ${args.join(' ')} exited with code ${code}.`))); }); } - -function firstAvailable(values: T[], hasCommand: (command: string) => boolean): T | null { - return values.find(hasCommand) ?? null; -} - -function readLinuxOsRelease(platform: NodeJS.Platform): string { - if (platform !== 'linux' || !existsSync('/etc/os-release')) return ''; - try { - return readFileSync('/etc/os-release', 'utf8'); - } catch { - return ''; - } -} - -function linuxReleaseIds(osRelease: string): Set { - const ids = new Set(); - for (const line of osRelease.split('\n')) { - const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); - if (!match) continue; - for (const value of match[2]!.replaceAll('"', '').split(/\s+/)) if (value.trim()) ids.add(value.trim().toLowerCase()); - } - return ids; -} - -function osReleaseValue(osRelease: string, key: string): string { - const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`)); - return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? ''; -} - -function hasAny(values: Set, candidates: string[]): boolean { - return candidates.some(candidate => values.has(candidate)); -} diff --git a/src/storage/store.ts b/src/storage/store.ts index 026c425..f71cc79 100644 --- a/src/storage/store.ts +++ b/src/storage/store.ts @@ -1,3 +1,4 @@ +import {platformPaths} from '../platform/paths.js'; import {chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; import {dirname, isAbsolute, join, resolve} from 'node:path'; @@ -698,37 +699,8 @@ function recoverActiveListeningSessionInState(state: LibraryState): LibraryState } function defaultStorePath(): string { - if (process.env.RADIOCLI_HOME) { - return join(process.env.RADIOCLI_HOME, 'radiocli.json'); - } - - if (process.env.RADIO_ATLAS_HOME) { - return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas.json'); - } - - const currentPath = currentDefaultStorePath(); - const legacyPath = legacyDefaultStorePath(); - return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath; -} - -function currentDefaultStorePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json'); - } - - if (process.platform === 'win32') { - return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json'); - } - - return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json'); -} - -function legacyDefaultStorePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas.json'); - } - - return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radio-atlas', 'radio-atlas.json'); + const {library, legacyLibrary} = platformPaths(); + return existsSync(library) || !existsSync(legacyLibrary) ? library : legacyLibrary; } function resolveUserPath(requestedPath: string): string { diff --git a/src/ui/system-actions.ts b/src/ui/system-actions.ts index a0fcc09..0229242 100644 --- a/src/ui/system-actions.ts +++ b/src/ui/system-actions.ts @@ -1,42 +1,19 @@ import {spawn} from 'node:child_process'; import {resolveCommand} from '../player/command.js'; import {safeExternalHttpUrl} from '../safety.js'; - -type SystemCommand = { - command: string; - args: string[]; -}; +import {browserCommands, clipboardCandidates, type SystemCommand} from '../platform/desktop.js'; +import {identifyPlatform} from '../platform/runtime.js'; // The command used to open a URL in the platform's default handler. export function openExternalCommand(platform: NodeJS.Platform = process.platform): SystemCommand { - if (platform === 'darwin') { - return {command: 'open', args: []}; - } - - if (platform === 'win32') { - // Direct invocation avoids cmd.exe interpreting URL query characters. - return {command: 'explorer', args: []}; - } - - return {command: 'xdg-open', args: []}; + return browserCommands(identifyPlatform({platform}))[0] ?? {command: 'xdg-open', args: []}; } // Candidate clipboard tools per platform, in priority order. pbcopy/clip ship // with macOS/Windows; Linux/BSD rely on whichever of these is installed. export function clipboardCommands(platform: NodeJS.Platform = process.platform): SystemCommand[] { - if (platform === 'darwin') { - return [{command: 'pbcopy', args: []}]; - } - - if (platform === 'win32') { - return [{command: 'clip', args: []}]; - } - - return [ - {command: 'wl-copy', args: []}, - {command: 'xclip', args: ['-selection', 'clipboard']}, - {command: 'xsel', args: ['--clipboard', '--input']} - ]; + const commands = clipboardCandidates(identifyPlatform({platform})); + return commands.length ? commands : clipboardCandidates(identifyPlatform({platform: 'linux', env: {}})); } export function openExternal(url: string, platform: NodeJS.Platform = process.platform): boolean { From b06662b83aedd581ab46c7a8c07dd07ad6f7e49a Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:38:20 -0500 Subject: [PATCH 03/25] fix: verify optional integration cleanup and isolate local control --- docs/cross-platform/audit.md | 6 ++ src/agent/session.test.ts | 116 +++++++++++++++++++++++++- src/agent/session.ts | 24 +++--- src/alarms/active-session.test.ts | 35 ++++++++ src/alarms/active-session.ts | 17 ++-- src/alarms/guard.test.ts | 72 +++++++++++++++- src/alarms/guard.ts | 83 +++++++++++++----- src/alarms/inhibitor.test.ts | 13 ++- src/alarms/inhibitor.ts | 39 ++++----- src/alarms/runner.test.ts | 19 +++++ src/alarms/runner.ts | 19 +++-- src/alarms/scheduler.test.ts | 103 +++++++++++++++++++++++ src/alarms/scheduler.ts | 87 ++++++++++++++----- src/alarms/setup-verification.test.ts | 1 + src/alarms/setup-verification.ts | 3 +- src/alarms/system-volume.test.ts | 26 +++++- src/alarms/system-volume.ts | 23 ++++- src/alarms/terminal-launcher.test.ts | 18 ++++ src/alarms/terminal-launcher.ts | 18 ++-- src/alarms/tui-presence.ts | 4 +- src/platform/loopback.test.ts | 70 ++++++++++++++++ src/platform/loopback.ts | 47 +++++++++++ src/ui/App.tsx | 10 +-- src/ui/system-actions.test.ts | 68 ++++++++++++++- src/ui/system-actions.ts | 81 ++++++++++-------- 25 files changed, 850 insertions(+), 152 deletions(-) create mode 100644 src/platform/loopback.test.ts create mode 100644 src/platform/loopback.ts diff --git a/docs/cross-platform/audit.md b/docs/cross-platform/audit.md index 526d116..48e3ce3 100644 --- a/docs/cross-platform/audit.md +++ b/docs/cross-platform/audit.md @@ -39,6 +39,12 @@ were inspected before implementation. No repository-specific `AGENTS.md` or shar repository memory was present. The initial local-only editor configuration was removed at the owner's subsequent request for a clean checkout. +The initial local test counts also included 216 duplicate tests from an ignored +legacy security-audit scratch copy. It was discovered during comparison with +the isolated staged tree and removed under the owner's clean-checkout request. +The actual original repository contains 690 tests; the committed baseline tree +passes all 1,268 tests (690 original plus 578 captures) on Node 22.23.2. + The original CI runs Node 22 and 24 on Ubuntu, macOS, and Windows. Live data, playback, and fresh packed-install checks are skipped on pull requests. Therefore an ordinary successful PR check does not establish packed-install or playback diff --git a/src/agent/session.test.ts b/src/agent/session.test.ts index bd99347..18b1b6d 100644 --- a/src/agent/session.test.ts +++ b/src/agent/session.test.ts @@ -1,4 +1,6 @@ -import {mkdtempSync, rmSync} from 'node:fs'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {createServer} from 'node:http'; +import {spawn} from 'node:child_process'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {afterEach, describe, expect, it} from 'vitest'; @@ -54,4 +56,116 @@ describe('agent radio session', () => { await first.close(); } }); + + for (const host of ['127.0.0.1', '::1']) { + it(`authenticates directly to a discovered ${host} endpoint`, async context => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-family-')); + roots.push(root); + const path = join(root, 'session.json'); + let authenticatedRequests = 0; + const server = createServer((request, response) => { + if (request.headers.authorization === 'Bearer test-session-token') authenticatedRequests += 1; + request.resume(); + response.end(JSON.stringify({ok: true, message: 'ok', status: idle})); + }); + try { + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, host, resolve);}); + } catch (error) { + if (host === '::1' && ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip('The host does not provide IPv6 loopback.'); + } + throw error; + } + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + writeFileSync(path, JSON.stringify({version: 1, host, port: address.port, token: 'test-session-token', pid: process.pid, createdAt: new Date().toISOString()})); + const client = await connectRadioSession(path); + expect(client).not.toBeNull(); + expect(await client!.status()).toEqual(idle); + expect(authenticatedRequests).toBe(2); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + } + + it('rejects a discovery hostname without contacting the advertised port', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-host-')); + roots.push(root); + const path = join(root, 'session.json'); + let requests = 0; + const server = createServer((_request, response) => {requests += 1;response.end('{}');}); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + writeFileSync(path, JSON.stringify({version: 1, host: 'localhost', port: address.port, token: 'test-session-token', pid: process.pid, createdAt: new Date().toISOString()})); + expect(await connectRadioSession(path)).toBeNull(); + expect(requests).toBe(0); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } + }); + + it.skipIf(!process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))( + 'keeps radio and alarm control tokens away from environment proxies in a fresh Node process', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-control-proxy-')); + roots.push(root); + let proxyRequests = 0; + let proxySawAuthorization = false; + const proxy = createServer((request, response) => { + proxyRequests += 1; + proxySawAuthorization ||= Boolean(request.headers.authorization); + request.resume(); + response.writeHead(401); + response.end('{}'); + }); + await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve)); + try { + const address = proxy.address(); + if (!address || typeof address === 'string') throw new Error('No proxy address.'); + const env = {...process.env}; + for (const key of Object.keys(env)) if (/^(?:https?|all|no)_proxy$/i.test(key) || key === 'NODE_OPTIONS') delete env[key]; + env.NODE_USE_ENV_PROXY = '1'; + env.HTTP_PROXY = `http://127.0.0.1:${address.port}`; + env.NO_PROXY = ''; + const source = ` + import {join} from 'node:path'; + import {startRadioSession, connectRadioSession} from ${JSON.stringify(new URL('./session.ts', import.meta.url).href)}; + import {startActiveAlarmSession, connectActiveAlarm} from ${JSON.stringify(new URL('../alarms/active-session.ts', import.meta.url).href)}; + const root = process.argv[1]; + const idle = ${JSON.stringify(idle)}; + const radioPath = join(root, 'radio.json'); + const alarmPath = join(root, 'alarm.json'); + const radio = await startRadioSession(async () => ({ok: true, message: 'ok', status: idle}), radioPath); + const alarm = await startActiveAlarmSession({alarmId: 'test', scheduledAt: '2030-01-01T00:00:00Z', stationName: 'Local alarm', startedAt: '2030-01-01T00:00:00Z'}, {filePath: alarmPath, onDismiss() {}, onSnooze() {}, onKeepPlaying() {}}); + try { + const radioClient = await connectRadioSession(radioPath); + const alarmClient = await connectActiveAlarm(alarmPath); + console.log(JSON.stringify({radio: Boolean(radioClient), alarm: Boolean(alarmClient)})); + } finally { + await alarm.close(); + await radio.close(); + } + `; + const output = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', source, root], {env, timeout: 8000}); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => {stdout += String(chunk);}); + child.stderr.on('data', chunk => {stderr += String(chunk);}); + child.once('error', reject); + child.once('close', code => code === 0 ? resolve(stdout) : reject(new Error(`Control probe exited ${code}: ${stderr}`))); + }); + expect(proxyRequests).toBe(0); + expect(proxySawAuthorization).toBe(false); + expect(JSON.parse(output)).toEqual({radio: true, alarm: true}); + } finally { + proxy.closeAllConnections(); + await new Promise(resolve => proxy.close(() => resolve())); + } + } + ); }); diff --git a/src/agent/session.ts b/src/agent/session.ts index 280332b..d4d0803 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -1,9 +1,10 @@ import {randomBytes} from 'node:crypto'; import {chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {createServer, request} from 'node:http'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import type {AirPlayDevice, AppSettings, PlaybackState, Station} from '../types.js'; +import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; export type RadioSessionStatus = { owner: 'tui' | 'headless'; @@ -32,7 +33,7 @@ export type RadioSessionCommand = | {type: 'update-settings'; settings: Partial>}; export type RadioSessionResult = {ok: boolean; message: string; status: RadioSessionStatus; data?: AirPlayDevice[]}; -type Discovery = {version: 1; host: '127.0.0.1'; port: number; token: string; pid: number; createdAt: string}; +type Discovery = {version: 1; host: LoopbackHost; port: number; token: string; pid: number; createdAt: string}; export type RadioSessionClient = { call(command: RadioSessionCommand): Promise; @@ -70,15 +71,10 @@ export async function startRadioSession( }); }); try { - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Unable to create RadioCLI control endpoint.'); + const address = await listenLoopback(server); const discovery: Discovery = { version: 1, - host: '127.0.0.1', + host: address.host, port: address.port, token, pid: process.pid, @@ -104,7 +100,7 @@ export async function connectRadioSession(filePath = radioSessionPath()): Promis let discovery: Discovery; try { discovery = JSON.parse(readFileSync(filePath, 'utf8')) as Discovery; - if (discovery.version !== 1 || discovery.host !== '127.0.0.1' || !Number.isInteger(discovery.port) || !discovery.token) { + if (discovery.version !== 1 || !isLoopbackHost(discovery.host) || !Number.isInteger(discovery.port) || !discovery.token) { throw new Error('invalid'); } } catch { @@ -115,7 +111,9 @@ export async function connectRadioSession(filePath = radioSessionPath()): Promis const payload = JSON.stringify(command); return new Promise((resolve, reject) => { const req = request({ - host: '127.0.0.1', + host: discovery.host, + // Local control tokens must never be forwarded by an environment proxy. + agent: false, port: discovery.port, path: '/command', method: 'POST', @@ -184,9 +182,7 @@ async function waitForSession(timeoutMs: number): Promise { } function runtimeDirectory(): string { - if (process.env.RADIOCLI_HOME) return join(process.env.RADIOCLI_HOME, 'runtime'); - if (process.platform === 'win32') return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime'); - return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli'); + return platformPaths().runtime; } function acquireOwner(path: string): void { diff --git a/src/alarms/active-session.test.ts b/src/alarms/active-session.test.ts index dfe32f9..e61adde 100644 --- a/src/alarms/active-session.test.ts +++ b/src/alarms/active-session.test.ts @@ -21,6 +21,41 @@ describe('active alarm IPC',()=>{ await server.close(); expect(await connectActiveAlarm(file)).toBeNull(); }); + + for (const host of ['127.0.0.1', '::1']) { + it(`authenticates directly to a discovered ${host} alarm endpoint`, async context => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-alarm-family-')); + dirs.push(root); + const file = join(root, 'active.json'); + const status = {alarmId: 'family', scheduledAt: '2030-01-01T00:00:00Z', stationName: 'Local radio', startedAt: '2030-01-01T00:00:00Z'}; + let authenticatedRequests = 0; + const server = createServer((request, response) => { + if (request.headers.authorization === 'Bearer test-alarm-token') authenticatedRequests += 1; + request.resume(); + response.end(JSON.stringify(status)); + }); + try { + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, host, resolve);}); + } catch (error) { + if (host === '::1' && ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip('The host does not provide IPv6 loopback.'); + } + throw error; + } + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + writeFileSync(file, JSON.stringify({version: 1, host, port: address.port, token: 'test-alarm-token', pid: process.pid, alarmId: 'family'})); + const client = await connectActiveAlarm(file); + expect(client).not.toBeNull(); + expect(await client!.status()).toEqual(status); + expect(authenticatedRequests).toBe(2); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + } }); it('keeps concurrent alarm sessions independently discoverable and owned',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-many-'));dirs.push(root);const first=await startActiveAlarmSession({alarmId:'one',scheduledAt:'2030-01-01T00:00:00Z',stationName:'One',startedAt:'2030-01-01T00:00:00Z'},{filePath:join(root,'one.json'),onDismiss:vi.fn(),onSnooze:vi.fn(),onKeepPlaying:vi.fn()});const second=await startActiveAlarmSession({alarmId:'two',scheduledAt:'2030-01-01T00:00:00Z',stationName:'Two',startedAt:'2030-01-01T00:00:00Z'},{filePath:join(root,'two.json'),onDismiss:vi.fn(),onSnooze:vi.fn(),onKeepPlaying:vi.fn()});expect(await connectActiveAlarms(root)).toHaveLength(2);await first.close();expect((await connectActiveAlarms(root))).toHaveLength(1);await second.close();}); diff --git a/src/alarms/active-session.ts b/src/alarms/active-session.ts index 3d0f7dd..bb3e5c6 100644 --- a/src/alarms/active-session.ts +++ b/src/alarms/active-session.ts @@ -1,9 +1,10 @@ import {randomBytes} from 'node:crypto'; import {chmodSync, existsSync, mkdirSync, readFileSync,readdirSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {createServer, request} from 'node:http'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import type {Station} from '../types.js'; +import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; export type ActiveAlarmStatus = { alarmId: string; @@ -14,7 +15,7 @@ export type ActiveAlarmStatus = { state?: 'starting'|'playing'|'stopping'|'failed'; keepPlaying?: boolean; }; -type Discovery={version:1;host:'127.0.0.1';port:number;token:string;pid:number;alarmId:string;createdAt?:string}; +type Discovery={version:1;host:LoopbackHost;port:number;token:string;pid:number;alarmId:string;createdAt?:string}; export type ActiveAlarmHandlers={filePath?:string;onDismiss():void|Promise;onSnooze(minutes:number):void|Promise;onKeepPlaying():void|Promise;onHandoff?():void|Promise}; export type ActiveAlarmServer={update(status:Partial):void;close():Promise}; export type ActiveAlarmClient={status():Promise;dismiss():Promise;snooze(minutes:number):Promise;keepPlaying():Promise;handoff():Promise}; @@ -33,25 +34,25 @@ export async function startActiveAlarmSession(initial:ActiveAlarmStatus,handlers res.statusCode=404;res.end('{}'); }catch(error){res.statusCode=400;res.end(JSON.stringify({error:error instanceof Error?error.message:'request failed'}));} }); - await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>resolve());}); - const address=server.address();if(!address||typeof address==='string')throw new Error('Unable to create alarm control endpoint.'); - const discovery={version:1,host:'127.0.0.1',port:address.port,token,pid:process.pid,alarmId:initial.alarmId,createdAt:new Date().toISOString()} satisfies Discovery;const temp=`${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;try{mkdirSync(dirname(filePath),{recursive:true,mode:0o700});writeFileSync(temp,`${JSON.stringify(discovery)}\n`,{mode:0o600});renameSync(temp,filePath);if(process.platform!=='win32')chmodSync(filePath,0o600);}catch(error){rmSync(temp,{force:true});removeDiscoveryIfOwned(filePath,discovery);await new Promise(resolve=>server.close(()=>resolve()));throw error;} + const address=await listenLoopback(server); + const discovery={version:1,host:address.host,port:address.port,token,pid:process.pid,alarmId:initial.alarmId,createdAt:new Date().toISOString()} satisfies Discovery;const temp=`${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;try{mkdirSync(dirname(filePath),{recursive:true,mode:0o700});writeFileSync(temp,`${JSON.stringify(discovery)}\n`,{mode:0o600});renameSync(temp,filePath);if(process.platform!=='win32')chmodSync(filePath,0o600);}catch(error){rmSync(temp,{force:true});removeDiscoveryIfOwned(filePath,discovery);await new Promise(resolve=>server.close(()=>resolve()));throw error;} return {update(change){status={...status,...change};},async close(){await new Promise(resolve=>server.close(()=>resolve()));removeDiscoveryIfOwned(filePath,discovery);}}; } export async function connectActiveAlarm(filePath?:string):Promise{ if(!filePath){const clients=await connectActiveAlarms();return clients[0]??null;} if(!existsSync(filePath))return null;let discovery:Discovery; - try{discovery=JSON.parse(readFileSync(filePath,'utf8')) as Discovery;if(discovery.version!==1||discovery.host!=='127.0.0.1'||!Number.isInteger(discovery.port)||!discovery.token)throw new Error('invalid');} + try{discovery=JSON.parse(readFileSync(filePath,'utf8')) as Discovery;if(discovery.version!==1||!isLoopbackHost(discovery.host)||!Number.isInteger(discovery.port)||!discovery.token)throw new Error('invalid');} catch{return null;} - const call=async(method:string,path:string,body?:unknown)=>{const payload=body===undefined?'':JSON.stringify(body);return new Promise((resolve,reject)=>{const req=request({host:'127.0.0.1',port:discovery.port,path,method,headers:{authorization:`Bearer ${discovery.token}`,'content-type':'application/json','content-length':Buffer.byteLength(payload)}},res=>{let text='';res.on('data',value=>text+=String(value));res.on('end',()=>res.statusCode&&res.statusCode<300?resolve(text?JSON.parse(text):{}):reject(new Error('Alarm control request failed.')));});req.once('error',reject);req.setTimeout(1000,()=>req.destroy(new Error('Alarm control request timed out.')));req.end(payload);});}; + // A fresh direct agent keeps local control tokens out of environment proxies. + const call=async(method:string,path:string,body?:unknown)=>{const payload=body===undefined?'':JSON.stringify(body);return new Promise((resolve,reject)=>{const req=request({host:discovery.host,agent:false,port:discovery.port,path,method,headers:{authorization:`Bearer ${discovery.token}`,'content-type':'application/json','content-length':Buffer.byteLength(payload)}},res=>{let text='';res.on('data',value=>text+=String(value));res.on('end',()=>res.statusCode&&res.statusCode<300?resolve(text?JSON.parse(text):{}):reject(new Error('Alarm control request failed.')));});req.once('error',reject);req.setTimeout(1000,()=>req.destroy(new Error('Alarm control request timed out.')));req.end(payload);});}; try{await call('GET','/status');}catch{if(!processAlive(discovery.pid)||discoveryAgeMs(filePath,discovery)>5*60_000)removeDiscoveryIfOwned(filePath,discovery);return null;} return {status:async()=>await call('GET','/status') as ActiveAlarmStatus,dismiss:async()=>{await call('POST','/dismiss');},snooze:async minutes=>{await call('POST','/snooze',{minutes});},keepPlaying:async()=>{await call('POST','/keep-playing');},handoff:async()=>{await call('POST','/handoff');}}; } export async function connectActiveAlarms(directory=defaultActiveAlarmDirectory()):Promise{if(!existsSync(directory))return[];const paths=readdirSync(directory).filter(name=>name.endsWith('.json')).map(name=>join(directory,name));const clients=await Promise.all(paths.map(path=>connectActiveAlarm(path)));return clients.filter((client):client is ActiveAlarmClient=>Boolean(client));} -function defaultActiveAlarmDirectory():string{if(process.env.RADIOCLI_HOME)return join(process.env.RADIOCLI_HOME,'runtime','active-alarms');if(process.platform==='darwin')return join(homedir(),'Library','Application Support','radiocli','runtime','active-alarms');if(process.platform==='win32')return join(process.env.LOCALAPPDATA??join(homedir(),'AppData','Local'),'RadioCLI','runtime','active-alarms');return join(process.env.XDG_RUNTIME_DIR??join(homedir(),'.local','state'),'radiocli','active-alarms');} +function defaultActiveAlarmDirectory():string{return join(platformPaths().alarmRuntime,'active-alarms');} function defaultActiveAlarmPath(alarmId='active',occurrenceAt='current'):string{return join(defaultActiveAlarmDirectory(),`${Buffer.from(`${alarmId}\0${occurrenceAt}`).toString('base64url')}.json`);} function readBody(req:import('node:http').IncomingMessage):Promise{return new Promise((resolve,reject)=>{let body='';req.on('data',value=>{body+=String(value);if(body.length>4096)req.destroy(new Error('Request too large.'));});req.on('end',()=>resolve(body));req.on('error',reject);});} function removeDiscoveryIfOwned(filePath:string,owner:Discovery){let current:Partial;try{current=JSON.parse(readFileSync(filePath,'utf8')) as Partial;}catch{return;}if(!sameDiscovery(current,owner))return;const quarantine=`${filePath}.closing-${process.pid}-${randomBytes(6).toString('hex')}`;try{renameSync(filePath,quarantine);const moved=JSON.parse(readFileSync(quarantine,'utf8')) as Partial;if(sameDiscovery(moved,owner)){rmSync(quarantine,{force:true});return;}if(!existsSync(filePath))renameSync(quarantine,filePath);}catch{if(existsSync(quarantine)&&!existsSync(filePath))try{renameSync(quarantine,filePath);}catch{}} } diff --git a/src/alarms/guard.test.ts b/src/alarms/guard.test.ts index 0751582..23f1dbc 100644 --- a/src/alarms/guard.test.ts +++ b/src/alarms/guard.test.ts @@ -2,6 +2,7 @@ import {mkdirSync,mkdtempSync,readFileSync,rmSync,utimesSync,writeFileSync} from import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {request} from 'node:http'; +import {Server} from 'node:net'; import {describe,expect,it,vi} from 'vitest'; import type {Alarm} from '../types.js'; import {AlarmGuardService,runAlarmGuard,terminateGuardTree} from './guard.js'; @@ -45,14 +46,59 @@ describe('one-shot alarm guard',()=>{ }finally{rmSync(root,{recursive:true,force:true});} }); - it('does not signal a reused PID without a fresh ownership heartbeat',async()=>{ + it('retains a live unverified PID for repair without signaling it',async()=>{ const root=mkdtempSync(join(tmpdir(),'radiocli-guard-stale-')); try{ const directory=join(root,'guards');const store=new AlarmPowerGuardStore(join(root,'power.json')); const stop=vi.fn(async()=>true);const service=new AlarmGuardService(store,'node','cli.js',vi.fn(),()=>new Date(),directory,async()=>false,stop); const alarmId='a';const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`${alarmId}\0${occurrenceAt}`).toString('base64url')}.json`);mkdirSync(directory,{recursive:true}); writeFileSync(path,JSON.stringify({alarmId,occurrenceAt,pid:process.pid,port:43210,token:'a'.repeat(64)}),{mode:0o600}); - expect((await service.status()).active).toBe(false);expect(()=>readFileSync(path)).toThrow();expect(stop).not.toHaveBeenCalled(); + expect(await service.status()).toMatchObject({active:false,guards:[],unresolvedGuards:[{alarmId,occurrenceAt,pid:process.pid}]}); + expect(await service.stop(alarmId)).toBe(false);expect(readFileSync(path,'utf8')).toContain('"alarmId":"a"');expect(stop).not.toHaveBeenCalled(); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it('blocks replacement when the previous live guard cannot prove ownership',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-unresolved-')); + try{ + const directory=join(root,'guards');const store=new AlarmPowerGuardStore(join(root,'power.json')); + const stop=vi.fn(async()=>true);const spawn=vi.fn();const service=new AlarmGuardService(store,'node','cli.js',spawn,()=>new Date('2030-01-01T00:00:00Z'),directory,async()=>false,stop); + const alarm:Alarm={id:'a',label:'Wake',enabled:true,station:{id:'x',provider:'radio-browser',name:'X',tags:[]},schedule:{type:'once',at:'2030-01-01T00:02:00.000Z'},playback:{volume:30,fadeSeconds:0,stopAfterMinutes:30},reliability:{missedRunGraceMinutes:10,wakeIfSupported:false},createdAt:'x',updatedAt:'x'}; + const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`a\0${occurrenceAt}`).toString('base64url')}.json`);mkdirSync(directory,{recursive:true}); + writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt,pid:process.pid,port:43210,token:'a'.repeat(64)}),{mode:0o600}); + await expect(service.start(alarm)).rejects.toThrow(/ownership.*repair/i); + expect(spawn).not.toHaveBeenCalled();expect(stop).not.toHaveBeenCalled();expect(readFileSync(path,'utf8')).toContain(occurrenceAt); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it('removes a guard record only when its process is confirmed absent',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-dead-')); + const kill=vi.spyOn(process,'kill').mockImplementation(()=>{throw Object.assign(new Error('No process'),{code:'ESRCH'});}); + try{ + const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`a\0${occurrenceAt}`).toString('base64url')}.json`); + writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt,pid:42,port:43210,token:'a'.repeat(64)})); + const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory,async()=>false,vi.fn()); + expect(await service.status()).toMatchObject({active:false,guards:[],unresolvedGuards:[]});expect(()=>readFileSync(path)).toThrow();expect(kill).toHaveBeenCalledWith(42,0); + }finally{kill.mockRestore();rmSync(root,{recursive:true,force:true});} + }); + + it('retains permission-denied process state as unresolved',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-eperm-')); + const kill=vi.spyOn(process,'kill').mockImplementation(()=>{throw Object.assign(new Error('Permission denied'),{code:'EPERM'});}); + try{ + const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`a\0${occurrenceAt}`).toString('base64url')}.json`); + writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt,pid:42,port:43210,token:'a'.repeat(64)})); + const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory,async()=>false,vi.fn()); + expect(await service.stop('a')).toBe(false);expect(await service.status()).toMatchObject({active:false,unresolvedGuards:[{alarmId:'a',pid:42}]});expect(readFileSync(path,'utf8')).toContain('"pid":42');expect(kill.mock.calls.every(call=>call[1]===0)).toBe(true); + }finally{kill.mockRestore();rmSync(root,{recursive:true,force:true});} + }); + + it('does not contact a non-loopback host from an ownership artifact',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-host-')); + try{ + const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const path=join(directory,'untrusted.json');writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt:'2030-01-01T00:01:00.000Z',pid:process.pid,host:'example.com',port:43210,token:'a'.repeat(64)})); + const verify=vi.fn(async()=>true);const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory,verify); + expect(await service.status()).toMatchObject({active:false,guards:[],unresolvedGuards:[expect.any(Object)]});expect(verify).not.toHaveBeenCalled();expect(readFileSync(path,'utf8')).toContain('example.com'); }finally{rmSync(root,{recursive:true,force:true});} }); @@ -61,7 +107,27 @@ describe('one-shot alarm guard',()=>{ it('serves an authenticated loopback challenge and stop control',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-guard-control-'));try{const path=join(root,'guard.json');const token='b'.repeat(64);const store=new AlarmPowerGuardStore(join(root,'power.json'));const release=vi.fn(async()=>{});const running=runAlarmGuard('a','2030-01-01T00:01:00Z',{status:vi.fn(),acquire:vi.fn(async()=>({release}))},store,()=>new Date('2030-01-01T00:00:00Z'),()=>new Promise(()=>{}),path,token);for(let attempt=0;attempt<20&&!readFile(path);attempt+=1)await new Promise(resolve=>setTimeout(resolve,5));const artifact=JSON.parse(readFileSync(path,'utf8')) as {port:number};expect(await controlRequest(artifact.port,token,'GET','/challenge')).toMatchObject({status:200,body:expect.stringContaining('"alarmId":"a"')});expect(await controlRequest(artifact.port,'wrong','POST','/stop')).toMatchObject({status:401});expect(await controlRequest(artifact.port,token,'POST','/stop')).toMatchObject({status:200});await running;expect(release).toHaveBeenCalled();expect(()=>readFileSync(path)).toThrow();}finally{rmSync(root,{recursive:true,force:true});}}); it('does not delete a transiently malformed ownership artifact while reading status',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-guard-parse-'));try{const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const path=join(directory,'partial.json');writeFileSync(path,'{"alarmId":');const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory);expect((await service.status()).active).toBe(false);expect(readFileSync(path,'utf8')).toBe('{"alarmId":');}finally{rmSync(root,{recursive:true,force:true});}}); + + it('publishes and challenges its IPv6 loopback endpoint when IPv4 is unavailable',async context=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-ipv6-'));const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const path=join(directory,'guard.json');const token='c'.repeat(64);const store=new AlarmPowerGuardStore(join(root,'power.json'));const release=vi.fn(async()=>{}); + const listen=vi.spyOn(Server.prototype,'listen').mockImplementationOnce(function(this:Server){queueMicrotask(()=>this.emit('error',Object.assign(new Error('IPv4 unavailable'),{code:'EAFNOSUPPORT'})));return this;}); + let failed:unknown;const running=runAlarmGuard('a','2030-01-01T00:01:00Z',{status:vi.fn(),acquire:vi.fn(async()=>({release}))},store,()=>new Date('2030-01-01T00:00:00Z'),()=>new Promise(()=>{}),path,token);void running.catch(error=>{failed=error;}); + let artifact:{host:'::1';port:number}|undefined; + try{ + for(let attempt=0;attempt<40&&!readFile(path)&&!failed;attempt+=1)await new Promise(resolve=>setTimeout(resolve,5)); + if(failed&&['EAFNOSUPPORT','EPROTONOSUPPORT','EADDRNOTAVAIL'].includes((failed as NodeJS.ErrnoException).code??''))context.skip('The host does not provide IPv6 loopback.'); + if(failed)throw failed; + artifact=JSON.parse(readFileSync(path,'utf8')) as typeof artifact;expect(artifact?.host).toBe('::1'); + const service=new AlarmGuardService(store,'node','cli.js',vi.fn(),()=>new Date(),directory); + expect(await service.status()).toMatchObject({active:true,guards:[{alarmId:'a',pid:process.pid}],unresolvedGuards:[]}); + expect(await controlRequest(artifact!.port,token,'POST','/stop',artifact!.host)).toMatchObject({status:200});await running;expect(release).toHaveBeenCalled(); + }finally{ + if(artifact&&readFile(path))await controlRequest(artifact.port,token,'POST','/stop',artifact.host).catch(()=>undefined); + if(artifact||failed)await running.catch(()=>undefined); + listen.mockRestore();rmSync(root,{recursive:true,force:true}); + } + }); }); function readFile(path:string){try{readFileSync(path);return true;}catch{return false;}} -function controlRequest(port:number,token:string,method:string,path:string):Promise<{status:number;body:string}>{return new Promise((resolve,reject)=>{const req=request({host:'127.0.0.1',port,path,method,headers:{authorization:`Bearer ${token}`}},res=>{let body='';res.on('data',chunk=>body+=String(chunk));res.on('end',()=>resolve({status:res.statusCode??0,body}));});req.once('error',reject);req.end();});} +function controlRequest(port:number,token:string,method:string,path:string,host='127.0.0.1'):Promise<{status:number;body:string}>{return new Promise((resolve,reject)=>{const req=request({host,port,path,method,agent:false,headers:{authorization:`Bearer ${token}`}},res=>{let body='';res.on('data',chunk=>body+=String(chunk));res.on('end',()=>resolve({status:res.statusCode??0,body}));});req.once('error',reject);req.end();});} diff --git a/src/alarms/guard.ts b/src/alarms/guard.ts index 0e0d63b..9fc3cb4 100644 --- a/src/alarms/guard.ts +++ b/src/alarms/guard.ts @@ -2,15 +2,17 @@ import {spawn as nodeSpawn} from 'node:child_process'; import {randomBytes} from 'node:crypto'; import {chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {createServer,request} from 'node:http'; -import {dirname, join} from 'node:path'; +import {basename, dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {Alarm} from '../types.js'; import {AlarmPowerGuardStore} from './power-guard-store.js'; import type {PowerInhibitor} from './inhibitor.js'; import {nextOccurrenceForAlarm} from './schedule.js'; import {defaultAlarmRuntimeDirectory} from './runner.js'; +import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; -type GuardPid = {alarmId: string; occurrenceAt: string; pid: number; port: number; token: string}; +type GuardPid = {alarmId: string; occurrenceAt: string; pid: number; host?: LoopbackHost; port: number; token: string}; export type GuardSpawn = (command: string, args: string[], env: NodeJS.ProcessEnv) => {pid?: number; unref(): void}; type GuardVerifier=(guard:GuardPid)=>Promise; type GuardStopper=(guard:GuardPid)=>Promise; @@ -41,6 +43,7 @@ export class AlarmGuardService { // A recurring alarm may have been edited/rescheduled while its old guard is // alive. Stop that exact process tree before replacing its occurrence state. await this.stopOwned(alarm.id); + if (this.hasOwnershipRecords(alarm.id)) throw new Error('Previous Alarm Guard ownership is unresolved; retain the alarm for repair before starting another guard.'); this.store.request(alarm.id, occurrenceAt); const token=randomBytes(32).toString('hex'); const child = this.spawn( @@ -75,17 +78,20 @@ export class AlarmGuardService { async stop(alarmId?: string): Promise { if(alarmId)return this.withOwnership(alarmId,()=>this.stopOwned(alarmId)); const ids=[...new Set(this.pidFiles().map(readPid).filter((item):item is GuardPid=>Boolean(item)).map(item=>item.alarmId))]; - const results=await Promise.all(ids.map(id=>this.withOwnership(id,()=>this.stopOwned(id))));return results.every(Boolean); + const results=await Promise.all(ids.map(id=>this.withOwnership(id,()=>this.stopOwned(id))));return results.every(Boolean)&&!this.hasOwnershipRecords(); } private async stopOwned(alarmId?:string):Promise{ const targets = this.pidFiles() - .map(readPid) - .filter((item): item is GuardPid => Boolean(item)) - .filter(item => !alarmId || item.alarmId === alarmId); + .map(path => ({path, owner: readPid(path)})) + .filter((item): item is {path: string; owner: GuardPid} => Boolean(item.owner)) + .filter(item => !alarmId || item.owner.alarmId === alarmId); if (!targets.length) return false; - for (const current of targets) { - const path = this.path(current.alarmId, current.occurrenceAt); - if(!(await this.verify(current))){rmSync(path,{force:true});markIfCurrent(this.store,current,'Guard ownership challenge failed; the PID was not signaled.');continue;} + for (const {path, owner: current} of targets) { + if (!(await this.verify(current))) { + if (!isAlive(current.pid)) rmSync(path, {force: true}); + markIfCurrent(this.store, current, 'Guard ownership challenge failed; the PID was not signaled.'); + continue; + } if(!(await this.requestStop(current))){markIfCurrent(this.store,current,'Guard rejected its authenticated stop request; the PID was not signaled.');continue;} for(let attempt=0;attempt<20&&isAlive(current.pid);attempt+=1)await delay(50); if(isAlive(current.pid)&&await this.verify(current))await terminateGuardTree(current.pid); @@ -100,17 +106,28 @@ export class AlarmGuardService { this.store.markReleased(current.alarmId, new Date(), current.occurrenceAt); } } - return targets.every(item => !isAlive(item.pid)); + return !this.hasOwnershipRecords(alarmId); } async status() { const guards=[] as Array<{active:true;alarmId:string;occurrenceAt:string;pid:number}>; + const unresolvedGuards=[] as Array<{alarmId?:string;occurrenceAt?:string;pid?:number;message:string}>; for(const path of this.pidFiles()){ const pid = readPid(path); - if (!pid) continue; + if (!pid) { + const alarmId=alarmIdFromPath(path); + unresolvedGuards.push({...(alarmId?{alarmId}:{}),message:'Guard ownership metadata cannot be read; its record was retained for repair.'}); + continue; + } if (!(await this.verify(pid))) { - rmSync(path, {force: true}); - markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.'); + if (isAlive(pid.pid)) { + const message='Guard ownership cannot be verified; its record was retained and the PID was not signaled.'; + unresolvedGuards.push({alarmId:pid.alarmId,occurrenceAt:pid.occurrenceAt,pid:pid.pid,message}); + markIfCurrent(this.store,pid,message); + } else { + rmSync(path, {force: true}); + markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.'); + } continue; } guards.push({active:true,alarmId:pid.alarmId,occurrenceAt:pid.occurrenceAt,pid:pid.pid}); @@ -118,7 +135,10 @@ export class AlarmGuardService { return { active: guards.length > 0, guards, - message: guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.' + unresolvedGuards, + message: unresolvedGuards.length + ? `${guards.length} alarm guards verified active; ${unresolvedGuards.length} ownership records require repair.` + : guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.' }; } @@ -130,6 +150,12 @@ export class AlarmGuardService { if (!existsSync(this.directory)) return []; return readdirSync(this.directory).filter(name => name.endsWith('.json')).map(name => join(this.directory, name)); } + private hasOwnershipRecords(alarmId?:string):boolean { + return this.pidFiles().some(path=>{ + const ownerId=readPid(path)?.alarmId??alarmIdFromPath(path); + return !alarmId||!ownerId||ownerId===alarmId; + }); + } private async withOwnership(alarmId:string,work:()=>Promise):Promise{const lock=join(this.directory,`.lock-${Buffer.from(alarmId).toString('base64url')}`);mkdirSync(this.directory,{recursive:true,mode:0o700});const deadline=Date.now()+3000;while(true){try{mkdirSync(lock,{mode:0o700});writeFileSync(join(lock,'created'),String(Date.now()),{mode:0o600});break;}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;let stale=false;try{const created=Number(readFileSync(join(lock,'created'),'utf8'));stale=Number.isFinite(created)?Date.now()-created>10_000:Date.now()-statSync(lock).mtimeMs>10_000;}catch{try{stale=Date.now()-statSync(lock).mtimeMs>10_000;}catch{}}if(stale){rmSync(lock,{recursive:true,force:true});continue;}if(Date.now()>deadline)throw new Error('Alarm Guard ownership is busy.');await delay(20);}}try{return await work();}finally{rmSync(lock,{recursive:true,force:true});}} } @@ -200,41 +226,56 @@ function readPid(path: string): GuardPid | undefined { if (!existsSync(path)) return undefined; try { const value = JSON.parse(readFileSync(path, 'utf8')) as GuardPid; - if (!Number.isInteger(value.pid) || !Number.isInteger(value.port)||value.port<1||value.port>65535||!value.alarmId || !/^[a-f0-9]{64}$/.test(value.token)||!/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt))) throw new Error(); + if (!Number.isInteger(value.pid) || value.pid<1 || !Number.isInteger(value.port)||value.port<1||value.port>65535||typeof value.alarmId!=='string'||!value.alarmId || (value.host!==undefined&&!isLoopbackHost(value.host)) || !/^[a-f0-9]{64}$/.test(value.token)||!/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt))) throw new Error(); return value; } catch { return undefined; } } +function alarmIdFromPath(path:string):string|undefined { + const name=basename(path,'.json');const decoded=Buffer.from(name,'base64url').toString(); + if(Buffer.from(decoded).toString('base64url')!==name)return undefined; + const separator=decoded.indexOf('\0'); + return separator>0?decoded.slice(0,separator):undefined; +} function writePrivate(path: string, value: GuardPid): void { mkdirSync(dirname(path), {recursive: true, mode: 0o700}); const temp=`${path}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`; writeFileSync(temp, JSON.stringify(value), {mode: 0o600}); renameSync(temp,path); - if (process.platform !== 'win32') chmodSync(path, 0o600); + if (nativeAdapters().posixPermissions) chmodSync(path, 0o600); } function isAlive(pid: number): boolean { - try { process.kill(pid, 0); return true; } catch { return false; } + try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; } } function ownsArtifact(path: string, token: string): boolean { return readPid(path)?.token === token; } -async function startGuardControl(path:string,token:string,alarmId:string,occurrenceAt:string,onStop:()=>void){const server=createServer((req,res)=>{if(req.headers.authorization!==`Bearer ${token}`){res.statusCode=401;res.end('{}');return;}if(req.method==='POST'&&req.url==='/stop'){onStop();res.end('{}');return;}if(req.method!=='GET'||req.url!=='/challenge'){res.statusCode=404;res.end('{}');return;}res.setHeader('content-type','application/json');res.end(JSON.stringify({alarmId,occurrenceAt,pid:process.pid}));});await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});const address=server.address();if(!address||typeof address==='string'){server.close();throw new Error('Unable to create Guard ownership endpoint.');}writePrivate(path,{alarmId,occurrenceAt,pid:process.pid,port:address.port,token});return{close:()=>new Promise(resolve=>server.close(()=>resolve()))};} +async function startGuardControl(path:string,token:string,alarmId:string,occurrenceAt:string,onStop:()=>void){ + const server=createServer((req,res)=>{if(req.headers.authorization!==`Bearer ${token}`){res.statusCode=401;res.end('{}');return;}if(req.method==='POST'&&req.url==='/stop'){onStop();res.end('{}');return;}if(req.method!=='GET'||req.url!=='/challenge'){res.statusCode=404;res.end('{}');return;}res.setHeader('content-type','application/json');res.end(JSON.stringify({alarmId,occurrenceAt,pid:process.pid}));}); + const close=()=>new Promise(resolve=>server.close(()=>resolve())); + try { + const {host,port}=await listenLoopback(server); + writePrivate(path,{alarmId,occurrenceAt,pid:process.pid,host,port,token}); + return {close}; + } catch(error) { await close();throw error; } +} function challengeGuard(guard:GuardPid):Promise{return callGuard(guard,'GET','/challenge',true);} function requestGuardStop(guard:GuardPid):Promise{return callGuard(guard,'POST','/stop',false);} -function callGuard(guard:GuardPid,method:string,path:string,validateIdentity:boolean):Promise{return new Promise(resolve=>{const req=request({host:'127.0.0.1',port:guard.port,path,method,headers:{authorization:`Bearer ${guard.token}`}},res=>{let body='';res.on('data',value=>body+=String(value));res.on('end',()=>{if(!validateIdentity){resolve(res.statusCode===200);return;}try{const reply=JSON.parse(body) as {alarmId?:string;occurrenceAt?:string;pid?:number};resolve(res.statusCode===200&&reply.alarmId===guard.alarmId&&reply.occurrenceAt===guard.occurrenceAt&&reply.pid===guard.pid);}catch{resolve(false);}});});req.once('error',()=>resolve(false));req.setTimeout(500,()=>req.destroy());req.end();});} +function callGuard(guard:GuardPid,method:string,path:string,validateIdentity:boolean):Promise{return new Promise(resolve=>{const req=request({host:guard.host??'127.0.0.1',port:guard.port,path,method,agent:false,headers:{authorization:`Bearer ${guard.token}`}},res=>{let body='';res.on('data',value=>body+=String(value));res.on('end',()=>{if(!validateIdentity){resolve(res.statusCode===200);return;}try{const reply=JSON.parse(body) as {alarmId?:string;occurrenceAt?:string;pid?:number};resolve(res.statusCode===200&&reply.alarmId===guard.alarmId&&reply.occurrenceAt===guard.occurrenceAt&&reply.pid===guard.pid);}catch{resolve(false);}});});req.once('error',()=>resolve(false));req.setTimeout(500,()=>req.destroy());req.end();});} export type GuardProcessOps={platform:NodeJS.Platform;isAlive(pid:number):boolean;kill(pid:number,signal:NodeJS.Signals):void;taskkill(pid:number,force:boolean):Promise;wait(milliseconds:number):Promise}; export async function terminateGuardTree(pid: number,ops:GuardProcessOps={platform:process.platform,isAlive,kill:(target,signal)=>process.kill(target,signal),taskkill:taskkillTree,wait:delay}): Promise { + const windows=nativeAdapters(identifyPlatform({platform:ops.platform})).ipc==='named-pipe'; // Kill the entire detached tree first on Windows so a dying parent cannot // orphan its PowerShell execution-state helper. - if (ops.platform === 'win32') { + if (windows) { await ops.taskkill(pid, false); } else { try { ops.kill(pid, 'SIGTERM'); } catch { return; } } for (let index = 0; index < 20 && ops.isAlive(pid); index += 1) await ops.wait(50); if (!ops.isAlive(pid)) return; - if (ops.platform === 'win32') await ops.taskkill(pid, true); + if (windows) await ops.taskkill(pid, true); else { // The guard is a detached process-group leader; force-killing the negative // PID guarantees its inhibitor helper cannot survive an unresponsive parent. diff --git a/src/alarms/inhibitor.test.ts b/src/alarms/inhibitor.test.ts index ab06f79..72c2a3d 100644 --- a/src/alarms/inhibitor.test.ts +++ b/src/alarms/inhibitor.test.ts @@ -1,4 +1,7 @@ import {describe, expect, it, vi} from 'vitest'; +import {mkdtempSync,rmSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import {createPowerInhibitor} from './inhibitor.js'; describe('power inhibitor', () => { @@ -16,5 +19,13 @@ describe('power inhibitor', () => { expect(kill).toHaveBeenCalled(); }); - it('reports a helper that exits unexpectedly after startup',async()=>{let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const inhibitor=createPowerInhibitor({platform:'darwin',spawn:()=>({pid:1,kill:()=>true,exited}),commandExists:()=>true});const lease=await inhibitor.acquire('Alarm');exit();await expect(lease.unexpectedExit).resolves.toMatchObject({message:expect.stringMatching(/unexpectedly/)});await lease.release();}); + it('reports lost protection when the helper exits unexpectedly after startup',async()=>{let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const inhibitor=createPowerInhibitor({platform:'darwin',spawn:()=>({pid:1,kill:()=>true,exited}),commandExists:()=>true});const lease=await inhibitor.acquire('Alarm');expect(inhibitor.status().active).toBe(true);exit();await expect(lease.unexpectedExit).resolves.toMatchObject({message:expect.stringMatching(/unexpectedly/)});expect(inhibitor.status().active).toBe(false);await lease.release();}); + + it('explains a missing helper without claiming that sleep is prevented',async()=>{const spawn=vi.fn();const inhibitor=createPowerInhibitor({platform:'darwin',spawn,commandExists:()=>false});expect(inhibitor.status()).toMatchObject({supported:false,active:false,message:expect.stringMatching(/caffeinate.*(?:missing|unavailable|not found)/i)});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/caffeinate.*(?:missing|unavailable|not found)/i);expect(spawn).not.toHaveBeenCalled();}); + + it('does not treat a Termux process reporting Linux as a logind host',async()=>{const spawn=vi.fn();const exists=vi.fn(()=>true);const inhibitor=createPowerInhibitor({platform:'linux',env:{TERMUX_VERSION:'0.118.3'},spawn,commandExists:exists});expect(inhibitor.status()).toMatchObject({supported:false,active:false,message:expect.stringContaining('termux')});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/unsupported.*termux/i);expect(exists).not.toHaveBeenCalled();expect(spawn).not.toHaveBeenCalled();}); + + it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('reports unavailable sleep protection on %s without spawning a surrogate',async platform=>{const spawn=vi.fn();const inhibitor=createPowerInhibitor({platform,spawn,commandExists:()=>true});expect(inhibitor.status()).toMatchObject({supported:false,active:false});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/unsupported/i);expect(spawn).not.toHaveBeenCalled();}); + + it('spawns the executable resolved from the supplied PATH',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-inhibitor-path-'));try{const command=join(root,'systemd-inhibit');writeFileSync(command,'#!/bin/sh\nexit 0\n',{mode:0o700});let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const spawn=vi.fn(()=>({pid:42,kill:()=>{exit();return true;},exited}));const inhibitor=createPowerInhibitor({platform:'linux',env:{PATH:root},spawn});const lease=await inhibitor.acquire('Alarm');expect(spawn).toHaveBeenCalledWith(command,expect.any(Array));await lease.release();}finally{rmSync(root,{recursive:true,force:true});}}); }); diff --git a/src/alarms/inhibitor.ts b/src/alarms/inhibitor.ts index e857bf8..b1ea0b5 100644 --- a/src/alarms/inhibitor.ts +++ b/src/alarms/inhibitor.ts @@ -1,6 +1,6 @@ import {spawn as nodeSpawn} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {delimiter, join} from 'node:path'; +import {resolveCommandDetails} from '../player/command.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; type InhibitorStatus = {supported: boolean; active: boolean; message: string}; export type InhibitorLease = {release(): Promise;unexpectedExit?:Promise}; @@ -11,6 +11,7 @@ export type PowerInhibitor = { }; export type InhibitorDeps = { platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; spawn?: (command:string,args:string[])=>Spawned; commandExists?: (command:string)=>boolean; }; @@ -21,34 +22,34 @@ const windowsInhibitorScript = (parentPid:number) => `$s='[DllImport("kernel32.d export function createPowerInhibitor(deps: InhibitorDeps = {}): PowerInhibitor { const platform = deps.platform ?? process.platform; - const spawn = deps.spawn ?? spawnDetached; - const exists = deps.commandExists ?? executableExists; - const spec = platform === 'darwin' + const env=deps.env??process.env; + const host=identifyPlatform({platform,env}); + const adapter=nativeAdapters(host).inhibitor; + const spawn = deps.spawn ?? ((command,args)=>spawnDetached(command,args,env)); + const spec = adapter === 'caffeinate' ? {command:'caffeinate',args:['-i','-w',String(process.pid)],message:'Prevents idle system sleep; the display may sleep.'} - : platform === 'linux' + : adapter === 'logind' ? {command:'systemd-inhibit',args:['--what=sleep','--who=RadioCLI','--why=Scheduled radio','--mode=block','sh','-c','while kill -0 "$1" 2>/dev/null; do sleep 2; done','radiocli-inhibitor',String(process.pid)],message:'Uses a logind sleep inhibitor; explicit sleep and lid policy may override it.'} - : platform === 'win32' + : adapter === 'windows' ? {command:'powershell.exe',args:['-NoLogo','-NoProfile','-NonInteractive','-Command',windowsInhibitorScript(process.pid)],message:'Prevents idle system sleep; explicit sleep and lid policy may override it.'} : null; let active = false; - const supported = Boolean(spec && exists(spec.command)); + const command=spec?(deps.commandExists?(deps.commandExists(spec.command)?spec.command:null):resolveCommandDetails(spec.command,{platform,env}).path):null; + const supported = Boolean(command); + const message=!spec?`Power inhibition is unsupported on ${host.id==='unknown'?platform:host.id}.`:!command?`${spec.command} is unavailable; sleep protection cannot be acquired.`:spec.message; return { - status:()=>({supported,active,message:spec?.message ?? `Power inhibition is unsupported on ${platform}.`}), + status:()=>({supported,active,message}), async acquire(_reason){ - if (!spec || !supported) throw new Error(spec?.message ?? `Power inhibition is unsupported on ${platform}.`); - const child=spawn(spec.command,spec.args);const exitedEarly=await Promise.race([child.exited.then(()=>true),new Promise(resolve=>setTimeout(()=>resolve(false),50))]);if(exitedEarly)throw new Error(`${spec.command} exited before sleep inhibition became active.`);active=true; let released=false; - const unexpectedExit=child.exited.then(()=>released?new Promise(()=>{}):new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`)); + if (!spec || !command) throw new Error(message); + const child=spawn(command,spec.args);const exitedEarly=await exitedWithin(child.exited,50);if(exitedEarly)throw new Error(`${spec.command} exited before sleep inhibition became active.`);active=true; let released=false; + const unexpectedExit=child.exited.then(()=>{active=false;return released?new Promise(()=>{}):new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`);}); return {unexpectedExit,async release(){if(released)return;released=true;active=false;child.kill('SIGTERM');if(await exitedWithin(child.exited,1000))return;child.kill('SIGKILL');if(!(await exitedWithin(child.exited,500)))throw new Error(`${spec.command} did not terminate; sleep protection state is unknown.`);}}; } }; } -function executableExists(command:string):boolean { - if (existsSync(command)) return true; - return Boolean(process.env.PATH?.split(delimiter).some(path=>existsSync(join(path,command)) || (process.platform==='win32'&&existsSync(join(path,`${command}.exe`))))); -} -function spawnDetached(command:string,args:string[]):Spawned { - const child=nodeSpawn(command,args,{stdio:'ignore',windowsHide:true}); +function spawnDetached(command:string,args:string[],env:NodeJS.ProcessEnv):Spawned { + const child=nodeSpawn(command,args,{env,stdio:'ignore',windowsHide:true}); return {pid:child.pid,kill:signal=>child.kill(signal),exited:new Promise(resolve=>{child.once('exit',resolve);child.once('error',resolve);})}; } -async function exitedWithin(exited:Promise,milliseconds:number){return Promise.race([exited.then(()=>true),new Promise(resolve=>setTimeout(()=>resolve(false),milliseconds))]);} +async function exitedWithin(exited:Promise,milliseconds:number){let timer:NodeJS.Timeout|undefined;try{return await Promise.race([exited.then(()=>true),new Promise(resolve=>{timer=setTimeout(()=>resolve(false),milliseconds);})]);}finally{if(timer)clearTimeout(timer);}} diff --git a/src/alarms/runner.test.ts b/src/alarms/runner.test.ts index 51e5c08..4ad6822 100644 --- a/src/alarms/runner.test.ts +++ b/src/alarms/runner.test.ts @@ -76,6 +76,25 @@ describe('alarm runner',()=>{ it('does not disable a valid one-time alarm when launched early',async()=>{const d=deps({now:()=>new Date('2030-01-01T07:59:00Z')});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('failed');expect(d.store.toggleAlarm).not.toHaveBeenCalled();}); + it('does not complete a future native occurrence after an early invocation',async()=>{const completeOccurrence=vi.fn(async()=>{});const d=deps({now:()=>new Date('2030-01-01T07:59:00Z'),scheduler:{sync:vi.fn(async()=>null),completeOccurrence}});await runAlarm('a',scheduledAt,d);expect(completeOccurrence).not.toHaveBeenCalled();}); + + it.each([['early','2030-01-01T08:10:00Z'],['stale','2030-01-01T08:00:00Z']] as const)('preserves a future snooze override after an ignored %s invocation',async(_kind,invocation)=>{const snoozed={...alarm,nextOverride:{at:'2030-01-01T08:10:00Z',reason:'snooze' as const,createdAt:'2030-01-01T08:00:00Z'}};const d=deps({now:()=>new Date('2030-01-01T08:05:00Z'),store:{...deps().store,getAlarm:vi.fn(()=>snoozed)}});await runAlarm('a',invocation,d);expect(d.store.recordAlarmOutcome).toHaveBeenCalledWith('a',expect.any(Object),{clearNextOverride:false});expect(d.player.play).not.toHaveBeenCalled();}); + + it('allows the real occurrence to run after an early native dispatch', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-early-occurrence-')); + try { + let current = new Date('2030-01-01T07:59:00Z'); + const d = deps({now: () => current, acquireLock: (id, at) => acquireOccurrenceLock(id, at, root)}); + expect((await runAlarm('a', scheduledAt, d)).status).toBe('failed'); + expect(d.player.play).not.toHaveBeenCalled(); + current = new Date('2030-01-01T08:00:00Z'); + expect((await runAlarm('a', scheduledAt, d)).duplicate).not.toBe(true); + expect(d.player.play).toHaveBeenCalledOnce(); + expect((await runAlarm('a', scheduledAt, d)).duplicate).toBe(true); + expect(d.player.play).toHaveBeenCalledOnce(); + } finally { rmSync(root, {recursive: true, force: true}); } + }); + it('records a valid but overdue occurrence as missed and disables one-time alarms',async()=>{const d=deps({now:()=>new Date('2030-01-01T08:20:00Z')});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('missed');expect(d.store.toggleAlarm).toHaveBeenCalledWith('a',false);}); it('records normal stop-after completion as played',async()=>{const d=deps({createSession:vi.fn(async()=>({update:vi.fn(),close:vi.fn(async()=>{})}))});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('played');expect(d.store.toggleAlarm).toHaveBeenCalledWith('a',false);}); diff --git a/src/alarms/runner.ts b/src/alarms/runner.ts index c268510..dd0a13d 100644 --- a/src/alarms/runner.ts +++ b/src/alarms/runner.ts @@ -1,5 +1,5 @@ import {existsSync,mkdirSync,readFileSync,readdirSync,rmSync,statSync,writeFileSync} from 'node:fs'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import type {Alarm,AlarmRunRecord,PlaybackState,ResolvedStream,Station} from '../types.js'; import type {PlaybackControlResult} from '../player/player-controller.js'; @@ -25,7 +25,7 @@ type RunnerProvider={resolve(station:Station):Promise}; type RunnerScheduler=Pick & Partial>; export type AlarmRunnerDeps={ now():Date; store:RunnerStore; providers:RunnerProvider; player:RunnerPlayer; scheduler:RunnerScheduler; inhibitor:PowerInhibitor; - acquireLock(alarmId:string,scheduledAt:string):(()=>void)|null; + acquireLock(alarmId:string,scheduledAt:string):((completed?:boolean)=>void)|null; createSession(status:ActiveAlarmStatus,handlers:ActiveAlarmHandlers):Promise; openControls?(status:ActiveAlarmStatus):Promise; preemptInteractivePlayback?():Promise; @@ -44,6 +44,7 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR let preserveNextOverride=false; let preserveSystemVolume=false; let validTerminalOccurrence=false; + let completeLock=true; let signalReceived=false;let resolveEarlySignal:()=>void=()=>{};let onPlaybackSignal:()=>void=()=>{};const earlySignal=new Promise(resolve=>{resolveEarlySignal=resolve;});const unsubscribeSignals=deps.subscribeSignals?.(()=>{signalReceived=true;resolveEarlySignal();onPlaybackSignal();void deps.player.stop().catch(()=>undefined);}); const finish=(status:AlarmRunRecord['status'],message?:string):AlarmRunRecord=>({status,scheduledAt:scheduledAt.toISOString(),...(firedAt?{firedAt:firedAt.toISOString()}:{}),finishedAt:deps.now().toISOString(),...(message?{message}:{})}); let action:'dismissed'|'snoozed'|'handoff'|'timeout'|'signal'|undefined;let keepPlaying=false;let resolveAction:(value:'dismissed'|'snoozed'|'handoff'|'signal')=>void=()=>{}; @@ -52,7 +53,7 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(!alarm||!alarm.enabled)return{message:'Alarm is missing or disabled.'}; const expected=nextExpectedOccurrence(alarm,scheduledAt);if(!expected||Math.abs(expected.getTime()-scheduledAt.getTime())>1000){outcome=finish('missed','Ignored a stale native task whose occurrence no longer matches this alarm.');return{status:'missed',message:outcome.message};} const assessment=assessScheduledOccurrence(scheduledAt,deps.now(),alarm.reliability.missedRunGraceMinutes); - if(assessment==='pending'){outcome=finish('failed','Scheduler launched the alarm before its occurrence.');return{status:outcome.status,message:outcome.message};} + if(assessment==='pending'){completeLock=false;outcome=finish('failed','Scheduler launched the alarm before its occurrence.');return{status:outcome.status,message:outcome.message};} validTerminalOccurrence=true; if(assessment==='missed'){outcome=finish('missed','Alarm was outside its missed-run grace window.');return{status:outcome.status,message:outcome.message};} const claimingStatus:ActiveAlarmStatus={alarmId,scheduledAt:scheduledAt.toISOString(),stationName:alarm.station.name,station:alarm.station,startedAt:deps.now().toISOString(),state:'starting'}; @@ -94,21 +95,21 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(listening){try{deps.store.checkpointActiveListeningSession(deps.now());deps.store.finishActiveListeningSession(deps.now());}catch{}} try{await lease?.release();if(lease)deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:true,active:false,message:'Playback sleep inhibition released.'});}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:true,message:`Unable to verify inhibitor release: ${errorMessage(error)}`});}catch{}} try{await session?.close();}catch{} - if(alarm&&outcome){try{deps.store.recordAlarmOutcome(alarmId,outcome,{clearNextOverride:!preserveNextOverride});const latest=deps.store.getAlarm(alarmId);if(alarm.schedule.type==='once'&&latest?.schedule.type==='once'&&latest.schedule.at===alarm.schedule.at&&validTerminalOccurrence&&!preserveNextOverride)deps.store.toggleAlarm(alarmId,false);}catch{}} + if(alarm&&outcome){try{deps.store.recordAlarmOutcome(alarmId,outcome,{clearNextOverride:validTerminalOccurrence&&!preserveNextOverride});const latest=deps.store.getAlarm(alarmId);if(alarm.schedule.type==='once'&&latest?.schedule.type==='once'&&latest.schedule.at===alarm.schedule.at&&validTerminalOccurrence&&!preserveNextOverride)deps.store.toggleAlarm(alarmId,false);}catch{}} alarm=deps.store.getAlarm(alarmId);if(alarm)try{if(deps.scheduler.syncClaimed)await deps.scheduler.syncClaimed(alarm,scheduledAt);else await deps.scheduler.sync(alarm);}catch{} - try{releaseLock();}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Occurrence lock cleanup failed: ${errorMessage(error)}`});}catch{}} - try{await deps.scheduler.completeOccurrence?.(alarmId,scheduledAt);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'scheduler',healthy:false,message:`Completed launch job cleanup failed: ${errorMessage(error)}`});}catch{}} + try{releaseLock(completeLock);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Occurrence lock cleanup failed: ${errorMessage(error)}`});}catch{}} + if(completeLock)try{await deps.scheduler.completeOccurrence?.(alarmId,scheduledAt);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'scheduler',healthy:false,message:`Completed launch job cleanup failed: ${errorMessage(error)}`});}catch{}} } } -export function acquireOccurrenceLock(alarmId:string,scheduledAt:string,root=defaultAlarmRuntimeDirectory()):(()=>void)|null{ +export function acquireOccurrenceLock(alarmId:string,scheduledAt:string,root=defaultAlarmRuntimeDirectory()):((completed?:boolean)=>void)|null{ const safe=`${alarmId.replace(/[^a-zA-Z0-9_-]/g,'_').slice(0,80)}-${Buffer.from(scheduledAt).toString('base64url')}`;const path=join(root,'locks',safe);mkdirSync(dirname(path),{recursive:true,mode:0o700});pruneCompletedOccurrenceLocks(root); try{mkdirSync(path,{mode:0o700});}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;try{if(existsSync(join(path,'completed')))return null;const pid=Number(readFileSync(join(path,'running'),'utf8'));if(Number.isInteger(pid)&&processAlive(pid))return null;rmSync(path,{recursive:true,force:true});mkdirSync(path,{mode:0o700});}catch{return null;}} writeFileSync(join(path,'running'),String(process.pid),{mode:0o600}); - return()=>{rmSync(join(path,'running'),{force:true});writeFileSync(join(path,'completed'),new Date().toISOString(),{mode:0o600});}; + return(completed=true)=>{if(!completed){rmSync(path,{recursive:true,force:true});return;}rmSync(join(path,'running'),{force:true});writeFileSync(join(path,'completed'),new Date().toISOString(),{mode:0o600});}; } export function pruneCompletedOccurrenceLocks(root=defaultAlarmRuntimeDirectory(),olderThanMs=30*24*60*60_000,now=Date.now()){const directory=join(root,'locks');if(!existsSync(directory))return 0;let removed=0;for(const name of readdirSync(directory)){const completed=join(directory,name,'completed');try{if(existsSync(completed)&&now-statSync(completed).mtimeMs>olderThanMs){rmSync(join(directory,name),{recursive:true,force:true});removed+=1;}}catch{}}return removed;} -export function defaultAlarmRuntimeDirectory():string{if(process.env.RADIOCLI_HOME)return join(process.env.RADIOCLI_HOME,'runtime');if(process.platform==='win32')return join(process.env.LOCALAPPDATA??join(homedir(),'AppData','Local'),'RadioCLI','runtime');return join(process.env.XDG_RUNTIME_DIR??join(homedir(),'.local','state'),'radiocli');} +export function defaultAlarmRuntimeDirectory():string{return platformPaths().runtime;} export const defaultRunnerUtilities={acquireLock:acquireOccurrenceLock,createSession:startActiveAlarmSession,wait:(milliseconds:number)=>new Promise(resolve=>setTimeout(resolve,milliseconds)),subscribeSignals:(handler:()=>void)=>{process.once('SIGTERM',handler);process.once('SIGHUP',handler);process.once('SIGINT',handler);return()=>{process.off('SIGTERM',handler);process.off('SIGHUP',handler);process.off('SIGINT',handler);};}}; function errorMessage(error:unknown){return error instanceof Error?error.message:String(error);} function processAlive(pid:number){try{process.kill(pid,0);return true;}catch{return false;}} diff --git a/src/alarms/scheduler.test.ts b/src/alarms/scheduler.test.ts index 01f90d4..66b8ed2 100644 --- a/src/alarms/scheduler.test.ts +++ b/src/alarms/scheduler.test.ts @@ -113,4 +113,107 @@ describe('native alarm schedulers', () => { it('fails disable/removal when an active Guard cannot be verified stopped',async()=>{const adapter:SchedulerAdapter={capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'ok'}),install:vi.fn(),remove:vi.fn(),status:vi.fn()};const record=vi.fn();const guard={start:vi.fn(),stop:vi.fn(async()=>false),status:vi.fn(async()=>({guards:[{alarmId:alarm.id}]}))};const service=new SchedulerService(adapter,()=>new Date('2029-01-01T00:00:00Z'),{record,list:vi.fn(()=>[]),get:vi.fn(()=>[]),remove:vi.fn()} as never,guard);await expect(service.sync({...alarm,enabled:false})).rejects.toThrow(/teardown|verified/i);expect(record).toHaveBeenCalledWith(expect.objectContaining({component:'power',healthy:false}));await expect(service.remove(alarm.id)).rejects.toThrow(/retained/i);expect(adapter.remove).toHaveBeenCalledTimes(1);}); it('accepts false Guard stop only when status verifies no matching Guard exists',async()=>{const adapter:SchedulerAdapter={capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'ok'}),install:vi.fn(),remove:vi.fn(),status:vi.fn()};const guard={start:vi.fn(),stop:vi.fn(async()=>false),status:vi.fn(async()=>({guards:[]}))};const service=new SchedulerService(adapter,()=>new Date('2029-01-01T00:00:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[]),remove:vi.fn()} as never,guard);await expect(service.sync({...alarm,enabled:false})).resolves.toBeNull();await expect(service.remove(alarm.id)).resolves.toBeUndefined();}); it('accepts a successful authenticated Guard teardown',async()=>{const adapter:SchedulerAdapter={capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'ok'}),install:vi.fn(),remove:vi.fn(),status:vi.fn()};const guard={start:vi.fn(),stop:vi.fn(async()=>true),status:vi.fn()};const service=new SchedulerService(adapter,()=>new Date('2029-01-01T00:00:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[]),remove:vi.fn()} as never,guard);await expect(service.sync({...alarm,enabled:false})).resolves.toBeNull();expect(guard.status).not.toHaveBeenCalled();}); + + it.each(['darwin', 'linux', 'win32'] as const)('retains %s repair artifacts when native removal fails and absence is uncertain', async platform => { + const removeFile = vi.fn(); + const run = vi.fn(async () => ({code: 1, stdout: '', stderr: 'Permission denied'})); + const adapter = createSchedulerAdapter({platform, home: '/missing-radiocli-test-home', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(/remov|absen|disable|bootout|delete/i); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'linux', 'win32'] as const)('retains %s repair artifacts when the targeted native job is still registered', async platform => { + const removeFile = vi.fn(); + const run = vi.fn(async (_command: string, args: string[]) => { + if (args[0] === 'print' || args[0] === '/Query') return {code: 0, stdout: 'registered', stderr: ''}; + if (args.includes('show')) return {code: 0, stdout: 'LoadState=loaded\nActiveState=active\n', stderr: ''}; + return {code: 1, stdout: '', stderr: 'busy'}; + }); + const adapter = createSchedulerAdapter({platform, home: '/missing-radiocli-test-home', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'linux', 'win32'] as const)('accepts failed %s removal only after a targeted absence response', async platform => { + const removeFile = vi.fn(); + const run = vi.fn(async (_command: string, args: string[]) => { + if (args[0] === 'print') return {code: 113, stdout: '', stderr: `Could not find service "${args[1]!.split('/').at(-1)}" in domain for user gui: 501`}; + if (args.includes('show')) return {code: 1, stdout: 'LoadState=not-found\nActiveState=inactive\n', stderr: ''}; + if (args[0] === '/Query') return {code: 1, stdout: '', stderr: 'ERROR: The system cannot find the file specified.'}; + if (args.includes('daemon-reload')) return {code: 0, stdout: '', stderr: ''}; + return {code: 1, stdout: '', stderr: 'already gone'}; + }); + const adapter = createSchedulerAdapter({platform, home: '/missing-radiocli-test-home', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).resolves.toBeUndefined(); + expect(removeFile).toHaveBeenCalled(); + }); + + it('reports an unreachable systemd user manager without writing native jobs', async () => { + const writeFile = vi.fn(); + const run = vi.fn(async (_command: string, _args: string[]) => ({code: 1, stdout: '', stderr: 'Failed to connect to user bus'})); + const adapter = createSchedulerAdapter({platform: 'linux', env: {}, commandExists: () => true, writeFile, removeFile: vi.fn(), run}); + const service = new SchedulerService(adapter, () => new Date(), {record: vi.fn(), list: () => [], get: () => []} as never); + expect(adapter.capabilities().message).toMatch(/detect|verif|check/i); + const report = await service.runtimeStatus(); + expect(report.capabilities).toMatchObject({supported: false, catchUpAfterWake: false}); + expect(report.capabilities.message).toMatch(/user.*manager|user bus/i); + await expect(adapter.install(alarm, new Date(alarm.schedule.type === 'once' ? alarm.schedule.at : ''))).rejects.toThrow(/user.*manager|user bus/i); + expect(writeFile).not.toHaveBeenCalled(); + expect(run.mock.calls.every(call => call[1].includes('show'))).toBe(true); + }); + + it('bounds a stalled systemd user-manager probe', async () => { + vi.useFakeTimers(); + try { + const adapter = createSchedulerAdapter({platform: 'linux', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile: vi.fn(), run: () => new Promise(() => {})}); + const service = new SchedulerService(adapter, () => new Date(), {record: vi.fn(), list: () => [], get: () => []} as never); + const pending = service.runtimeStatus(); + await vi.advanceTimersByTimeAsync(3_000); + expect((await pending).capabilities).toMatchObject({supported: false}); + } finally { vi.useRealTimers(); } + }); + + it('uses the injected executable environment for scheduler discovery', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-scheduler-path-')); + vi.stubEnv('PATH', '/missing-radiocli-executables'); + try { + writeFileSync(join(root, 'systemctl'), '#!/bin/sh\nexit 0\n', {mode: 0o755}); + const adapter = createSchedulerAdapter({platform: 'linux', env: {PATH: root}, home: root, writeFile: vi.fn(), removeFile: vi.fn(), run: vi.fn()}); + expect(adapter.capabilities().supported).toBe(true); + } finally { vi.unstubAllEnvs(); rmSync(root, {recursive: true, force: true}); } + }); + + it('does not select Linux scheduling for a Termux environment', async () => { + const writeFile = vi.fn(); const run = vi.fn(); + const adapter = createSchedulerAdapter({platform: 'linux', env: {TERMUX_VERSION: 'test'}, commandExists: () => true, writeFile, removeFile: vi.fn(), run}); + expect(adapter.capabilities()).toMatchObject({supported: false, catchUpAfterWake: false}); + await expect(adapter.install(alarm, new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/termux/i); + expect(writeFile).not.toHaveBeenCalled(); expect(run).not.toHaveBeenCalled(); + }); + + it('retains an alarm when Guard ownership remains unresolved', async () => { + const adapter: SchedulerAdapter = {capabilities: () => ({supported: true, exactWake: false, catchUpAfterWake: true, message: 'ok'}), install: vi.fn(), remove: vi.fn(), status: vi.fn()}; + const guard = {start: vi.fn(), stop: vi.fn(async () => false), status: vi.fn(async () => ({guards: [], unresolvedGuards: [{alarmId: alarm.id}]}))}; + const service = new SchedulerService(adapter, () => new Date(), {record: vi.fn(), list: () => [], get: () => [], remove: vi.fn()} as never, guard); + await expect(service.remove(alarm.id)).rejects.toThrow(/verified|retained/i); + expect(adapter.remove).not.toHaveBeenCalled(); + }); + + it('does not accept an absence response for another launchd service sharing the target prefix',async()=>{const removeFile=vi.fn();const run=vi.fn(async(_command:string,args:string[])=>({code:113,stdout:'',stderr:args[0]==='print'?`Could not find service "${args[1]!.split('/').at(-1)}.other" in domain for user gui: 501`:'busy'}));const adapter=createSchedulerAdapter({platform:'darwin',home:'/missing-radiocli-test-home',env:{},writeFile:vi.fn(),removeFile,run});await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*absence/i);expect(removeFile).not.toHaveBeenCalled();}); + + it('keeps a registered but inactive systemd timer unhealthy without mutating its registration',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-systemd-inactive-'));try{ + const cliPath=join(root,'cli.js');writeFileSync(cliPath,'');const run=vi.fn(async(_command:string,args:string[])=>({code:args.includes('is-active')?3:0,stdout:args.includes('is-active')?'inactive':'',stderr:''})); + const writeFile=vi.fn((path:string,body:string)=>{mkdirSync(dirname(path),{recursive:true});writeFileSync(path,body);}); + const adapter=createSchedulerAdapter({platform:'linux',home:root,nodePath:process.execPath,cliPath,env:{},commandExists:()=>true,writeFile,run}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z'));writeFile.mockClear();run.mockClear(); + expect(await adapter.status(alarm.id)).toMatchObject({installed:true,healthy:false,message:expect.stringMatching(/not active/i)});expect(writeFile).not.toHaveBeenCalled();expect(run.mock.calls.map(call=>call[1][1])).toEqual(['show','is-enabled','is-active']); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it.each(['darwin','linux','win32'] as const)('retains %s repair artifacts when native commands reject instead of returning status',async platform=>{const removeFile=vi.fn();const adapter=createSchedulerAdapter({platform,env:{},home:'/missing-radiocli-test-home',writeFile:vi.fn(),removeFile,commandExists:()=>true,run:async()=>{throw new Error('native transport failed');}});await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*removal/i);expect(removeFile).not.toHaveBeenCalled();}); + + it('retains Windows repair metadata when a localized absence message cannot be verified',async()=>{const removeFile=vi.fn();const adapter=createSchedulerAdapter({platform:'win32',env:{},home:'/missing-radiocli-test-home',writeFile:vi.fn(),removeFile,run:async()=>({code:1,stdout:'',stderr:'ERROR: El sistema no puede encontrar el archivo especificado.'})});await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*absence/i);expect(removeFile).not.toHaveBeenCalled();}); + + it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('does not create or claim native alarm registration on %s',async platform=>{const writeFile=vi.fn();const run=vi.fn();const commandExists=vi.fn(()=>true);const adapter=createSchedulerAdapter({platform,env:{},writeFile,run,commandExists});expect(adapter.capabilities()).toMatchObject({supported:false,exactWake:false,catchUpAfterWake:false});await expect(adapter.install(alarm,new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/not supported/i);expect(writeFile).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();expect(commandExists).not.toHaveBeenCalled();}); }); diff --git a/src/alarms/scheduler.ts b/src/alarms/scheduler.ts index f543cdb..21c30c0 100644 --- a/src/alarms/scheduler.ts +++ b/src/alarms/scheduler.ts @@ -10,8 +10,11 @@ import {AlarmRuntimeHealthStore} from './runtime-health.js'; import {AlarmGuardService} from './guard.js'; import {AlarmPowerGuardStore} from './power-guard-store.js'; import {detectAlarmTerminal} from './terminal-launcher.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; +import {resolveCommandDetails} from '../player/command.js'; export type SchedulerCapabilities = { + name?: string; supported: boolean; exactWake: boolean; catchUpAfterWake: boolean; @@ -21,6 +24,7 @@ type CommandResult = {code: number; stdout: string; stderr: string}; type SchedulerStatus = {installed: boolean; healthy: boolean; message: string}; export type SchedulerAdapter = { capabilities(): SchedulerCapabilities; + probeCapabilities?(): Promise; install(alarm: Alarm, occurrence: Date): Promise; remove(alarmId: string): Promise; status(alarmId: string): Promise; @@ -41,7 +45,7 @@ export type SchedulerDeps = { }; export class SchedulerService { - constructor(readonly adapter: SchedulerAdapter, private readonly now: () => Date = () => new Date(),readonly health=new AlarmRuntimeHealthStore(),private readonly guard?:{start(alarm:Alarm):Promise;stop(alarmId?:string):Promise;status?():Promise<{guards:Array<{alarmId:string}>}>|{guards:Array<{alarmId:string}>}}) {} + constructor(readonly adapter: SchedulerAdapter, private readonly now: () => Date = () => new Date(),readonly health=new AlarmRuntimeHealthStore(),private readonly guard?:{start(alarm:Alarm):Promise;stop(alarmId?:string):Promise;status?():Promise|GuardStatus}) {} async sync(alarm: Alarm): Promise { const occurrence = nextOccurrenceForAlarm(alarm, this.now()); @@ -76,10 +80,11 @@ export class SchedulerService { async completeOccurrence(alarmId:string,occurrence:Date):Promise{await this.adapter.completeOccurrence?.(alarmId,occurrence);} async remove(alarmId:string):Promise{await this.ensureGuardAbsent(alarmId);try{await this.adapter.remove(alarmId);}catch(error){this.health.record({alarmId,component:'scheduler',healthy:false,message:`Native job removal failed; the alarm definition was retained: ${errorMessage(error)}`});throw error;}this.health.remove(alarmId);} async statusAll(alarms:readonly Alarm[]){return mapConcurrent(alarms,4,async alarm=>{const occurrence=nextOccurrenceForAlarm(alarm,this.now());return{alarmId:alarm.id,nextOccurrence:occurrence?.toISOString()??null,native:occurrence?await this.adapter.status(alarm.id):{installed:false,healthy:true,message:'No enabled future occurrence; no native job is expected.'},health:this.health.get(alarm.id)};});} - async runtimeStatus(alarms:readonly Alarm[]=[]){return{capabilities:this.doctor(),entries:this.health.list(),alarms:await this.statusAll(alarms)};} + async runtimeStatus(alarms:readonly Alarm[]=[]){const [capabilities,status]=await Promise.all([this.adapter.probeCapabilities?.()??this.doctor(),this.statusAll(alarms)]);return{capabilities,entries:this.health.list(),alarms:status};} private async reconcileGuard(alarm:Alarm){if(!this.guard)return;try{const requested=alarm.enabled&&alarm.reliability.keepAwakeUntilAlarm;if(requested)await this.guard.start(alarm);else await this.ensureGuardAbsent(alarm.id);this.health.record({alarmId:alarm.id,component:'power',healthy:true,active:requested,message:requested?'Alarm Guard reconciled.':'Alarm Guard verified absent.'});}catch(error){const message=`Alarm Guard reconciliation failed: ${errorMessage(error)}`;this.health.record({alarmId:alarm.id,component:'power',healthy:false,active:true,message});throw new GuardReconcileError(message);}} - private async ensureGuardAbsent(alarmId:string){if(!this.guard)return;const stopped=await this.guard.stop(alarmId);if(stopped)return;const status=await this.guard.status?.();if(status&&!status.guards.some(item=>item.alarmId===alarmId))return;const message='Alarm Guard teardown could not be verified; the alarm definition was retained for repair.';this.health.record({alarmId,component:'power',healthy:false,active:true,message});throw new GuardReconcileError(message);} + private async ensureGuardAbsent(alarmId:string){if(!this.guard)return;const stopped=await this.guard.stop(alarmId);if(stopped)return;const status=await this.guard.status?.();if(status&&!status.guards.some(item=>item.alarmId===alarmId)&&!status.unresolvedGuards?.some(item=>!item.alarmId||item.alarmId===alarmId))return;const message='Alarm Guard teardown could not be verified; the alarm definition was retained for repair.';this.health.record({alarmId,component:'power',healthy:false,active:true,message});throw new GuardReconcileError(message);} } +type GuardStatus={guards:Array<{alarmId:string}>;unresolvedGuards?:Array<{alarmId?:string}>}; class GuardReconcileError extends Error{} async function settledOperation(work:()=>Promise):Promise<{error?:string}>{try{await work();return{};}catch(error){return{error:errorMessage(error)};}} @@ -93,17 +98,20 @@ export function createSchedulerAdapter(deps: SchedulerDeps = {}): SchedulerAdapt const nodePath = deps.nodePath ?? process.execPath; const cliPath = deps.cliPath ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js'); const env = deps.env ?? process.env; + const host = identifyPlatform({platform, env}); + const policy = nativeAdapters(host); const write = deps.writeFile ?? ((path, contents) => { mkdirSync(dirname(path), {recursive: true}); writeFileSync(path, contents, {encoding: 'utf8', mode: 0o600}); }); const removeFile = deps.removeFile ?? (path => rmSync(path, {force: true})); - const run = deps.run ?? runCommand; - const commandExists = deps.commandExists ?? (command => existsSync(command) || Boolean(process.env.PATH?.split(platform === 'win32' ? ';' : ':').some(path => existsSync(join(path, command))))); + const resolve = (command:string) => resolveCommandDetails(command,{platform,env,home}).path; + const run = deps.run ?? ((command,args) => runCommand(resolve(command)??command,args)); + const commandExists = deps.commandExists ?? (command => resolve(command)!==null); const common = {home, nodePath, cliPath, env, terminal: detectAlarmTerminal(platform, env), write, removeFile, run}; - if (platform === 'darwin') return launchdAdapter(common); - if (platform === 'win32') return windowsAdapter(common); - if (platform === 'linux' && commandExists('systemctl')) return systemdAdapter(common); - return unsupportedAdapter(platform === 'linux' + if (policy.scheduler === 'launchd') return launchdAdapter(common); + if (policy.scheduler === 'task-scheduler') return windowsAdapter(common); + if (policy.scheduler === 'systemd' && commandExists('systemctl')) return systemdAdapter(common); + return unsupportedAdapter(policy.scheduler === 'systemd' ? 'User scheduling requires systemd. This Linux session is unsupported; RadioCLI will not claim the alarm is registered.' - : `Alarm scheduling is not supported on ${platform}.`); + : `Alarm scheduling is not supported on ${host.id==='unknown'?host.platform:host.id}.`); } type Common = {home: string; nodePath: string; cliPath: string; env: NodeJS.ProcessEnv; terminal:string; write: (p:string,c:string)=>void; removeFile:(p:string)=>void; run:(c:string,a:string[])=>Promise}; @@ -123,39 +131,76 @@ function launchdAdapter(common: Common): SchedulerAdapter { const occurrencePath=(id:string,occurrence:Date)=>join(directory,`${occurrenceLabel(id,occurrence)}.plist`); const pathsFor=(id:string)=>{const prefix=`${jobName(id)}.`;let extras:string[]=[];try{extras=readdirSync(directory).filter(name=>name.startsWith(prefix)&&name.endsWith('.plist')).map(name=>join(directory,name));}catch{}return[pathFor(id),...extras];}; const installJob=async(alarm:Alarm,occurrence:Date,label:string,path:string,replace:boolean)=>{const local=localParts(occurrence);const entries=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`${xml(key)}${xml(value)}`).join('');const envArgs=`EnvironmentVariables${entries}`;const args=[common.nodePath,...invocationArgs(common,alarm.id,occurrence,'internal-launchd')].map(value=>`${xml(value)}`).join('');common.write(path,`\n\nLabel${label}ProgramArguments${args}${envArgs}StartCalendarIntervalMonth${local.month}Day${local.day}Hour${local.hour}Minute${local.minute}ProcessTypeInteractiveStandardOutPath/dev/nullStandardErrorPath/dev/null\n`);if(replace)await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);ensureSuccess(await common.run('launchctl',['bootstrap',`gui/${process.getuid?.()??''}`,path]),'launchctl bootstrap');}; + const removeJob=async(path:string)=>{ + const label=path.split('/').at(-1)!.slice(0,-6); + let failure:unknown; + try{ensureSuccess(await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]),'launchctl bootout');}catch(error){failure=error;} + if(failure){ + let status:CommandResult; + try{status=await common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${label}`]);}catch(error){throw new Error(`Unable to verify launchd job removal: ${errorMessage(error)}`);} + if(status.code===0)throw new Error(`launchd job is still loaded: ${errorMessage(failure)}`); + if(!isLaunchdServiceNotFound(status,label))throw new Error(`Unable to verify launchd job absence: ${commandMessage(status)}`); + } + common.removeFile(path); + }; return { - capabilities: () => ({supported: true, exactWake: false, catchUpAfterWake: true, message: 'launchd runs after wake when possible; exact wake is unavailable. Resync after changing the host timezone.'}), + capabilities: () => ({name:'launchd', supported: true, exactWake: false, catchUpAfterWake: true, message: 'launchd runs after wake when possible; exact wake is unavailable. Resync after changing the host timezone.'}), async install(alarm,occurrence){await installJob(alarm,occurrence,jobName(alarm.id),pathFor(alarm.id),true);}, async installFromRunner(alarm,occurrence){const label=occurrenceLabel(alarm.id,occurrence);const existing=await common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${label}`]);if(existing.code===0)return;await installJob(alarm,occurrence,label,occurrencePath(alarm.id,occurrence),false);}, async removeFromRunner(){}, - async completeOccurrence(id,occurrence){const specific=occurrencePath(id,occurrence);const path=existsSync(specific)?specific:pathFor(id);const label=path.split('/').at(-1)!.slice(0,-6);let failure:unknown;try{const result=await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]);if(result.code!==0)failure=new Error(`launchctl bootout failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);}catch(error){failure=error;}if(failure){let status:CommandResult;try{status=await common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${label}`]);}catch(error){throw new Error(`Unable to verify completed launchd job removal: ${errorMessage(error)}`);}if(status.code===0)throw new Error(`Completed launchd job is still loaded: ${errorMessage(failure)}`);if(!isLaunchdServiceNotFound(status,label))throw new Error(`Unable to verify completed launchd job absence: ${(status.stderr||status.stdout||`exit ${status.code}`).trim()}`);}common.removeFile(path);}, - async remove(id){for(const path of pathsFor(id)){await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);common.removeFile(path);}}, + async completeOccurrence(id,occurrence){const specific=occurrencePath(id,occurrence);await removeJob(existsSync(specific)?specific:pathFor(id));}, + async remove(id){for(const path of pathsFor(id))await removeJob(path);}, async status(id){const paths=pathsFor(id).filter(existsSync);const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);if(!paths.length)return{installed:false,healthy:false,message:'LaunchAgent artifact missing'};const results=await Promise.all(paths.map(path=>common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${path.split('/').at(-1)!.slice(0,-6)}`])));const registered=results.some(result=>result.code===0);return{installed:registered,healthy:registered&&executable,message:!registered?'launchd registration missing':!executable?'RadioCLI executable missing':'registered and executable'};} }; } function systemdAdapter(common: Common): SchedulerAdapter { const base = join(common.home,'.config','systemd','user'); const names=(id:string)=>({service:`${jobName(id)}.service`,timer:`${jobName(id)}.timer`}); + const capabilities=():SchedulerCapabilities=>({name:'systemd',supported:true,exactWake:false,catchUpAfterWake:true,message:'systemd tooling detected; user-manager readiness is checked during registration and diagnostics. Persistent user timers can catch up after login/wake; WakeSystem is not claimed without system privileges.'}); + const probeCapabilities=async():Promise=>{ + try{ + const result=await boundedProbe(common.run('systemctl',['--user','show','--property=Version','--value'])); + ensureSuccess(result,'systemd user manager probe'); + return{...capabilities(),message:'The systemd user manager is reachable. Persistent timers can catch up after login/wake; WakeSystem is not claimed without system privileges.'}; + }catch(error){return{...capabilities(),supported:false,catchUpAfterWake:false,message:`The systemd user manager is unavailable: ${errorMessage(error)}`};} + }; return { - capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'systemd user timers catch up after login/wake; WakeSystem is not claimed without system privileges.'}), - async install(alarm, occurrence) { const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=[common.nodePath,...invocationArgs(common,alarm.id,occurrence)].map(systemdQuote).join(' '); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, - async remove(id){const n=names(id); await common.run('systemctl',['--user','disable','--now',n.timer]).catch(()=>undefined); common.removeFile(join(base,n.timer));common.removeFile(join(base,n.service));await common.run('systemctl',['--user','daemon-reload']).catch(()=>undefined);}, - async status(id){const n=names(id);const result=await common.run('systemctl',['--user','is-enabled',n.timer]);const artifact=existsSync(join(base,n.timer))&&existsSync(join(base,n.service));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:result.code===0&&artifact,healthy:result.code===0&&artifact&&executable,message:result.code!==0?'user timer not enabled':!artifact?'systemd unit artifact missing':!executable?'RadioCLI executable missing':'enabled and executable'};} + capabilities, + probeCapabilities, + async install(alarm, occurrence) { const readiness=await probeCapabilities();if(!readiness.supported)throw new Error(readiness.message);const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=[common.nodePath,...invocationArgs(common,alarm.id,occurrence)].map(systemdQuote).join(' '); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, + async remove(id){ + const n=names(id); + await removeWithVerification(()=>common.run('systemctl',['--user','disable','--now',n.timer]),()=>common.run('systemctl',['--user','show',n.timer,'--property=LoadState','--property=ActiveState']),result=>/^LoadState=not-found\r?$/m.test(result.stdout)&&/^ActiveState=inactive\r?$/m.test(result.stdout),'systemd timer'); + common.removeFile(join(base,n.timer));common.removeFile(join(base,n.service)); + ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload after removal'); + }, + async status(id){const readiness=await probeCapabilities();if(!readiness.supported)return{installed:false,healthy:false,message:readiness.message};const n=names(id);const [enabled,active]=await Promise.all([common.run('systemctl',['--user','is-enabled',n.timer]),common.run('systemctl',['--user','is-active',n.timer])]);const artifact=existsSync(join(base,n.timer))&&existsSync(join(base,n.service));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:enabled.code===0&&artifact,healthy:enabled.code===0&&active.code===0&&artifact&&executable,message:enabled.code!==0?'user timer not enabled':active.code!==0?'user timer is not active':!artifact?'systemd unit artifact missing':!executable?'RadioCLI executable missing':'enabled, active, and executable'};} }; } function windowsAdapter(common: Common): SchedulerAdapter { const xmlPath=(id:string)=>join(common.env.LOCALAPPDATA??join(common.home,'AppData','Local'),'RadioCLI','scheduler',`${jobName(id)}.xml`); return { - capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.'}), + capabilities:()=>({name:'Task Scheduler',supported:true,exactWake:false,catchUpAfterWake:true,message:'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.'}), async install(alarm,occurrence){const name=`\\RadioCLI\\${jobName(alarm.id)}`;const command=windowsCommandLine(common.nodePath,invocationArgs(common,alarm.id,occurrence));const environment=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`set "${key}=${cmdEscape(value)}"`).join(' && ');const raw=`${environment} && ${command}`;const body=`${xml(alarm.label)}${windowsLocalBoundary(occurrence)}trueInteractiveTokenLeastPrivilegetrue${alarm.reliability.wakeIfSupported}falsefalsePT0SIgnoreNewcmd.exe/d /s /c "${xml(raw)}"`;const path=xmlPath(alarm.id);common.write(path,body);ensureSuccess(await common.run('schtasks.exe',['/Create','/TN',name,'/XML',path,'/F']),'schtasks create');}, - async remove(id){await common.run('schtasks.exe',['/Delete','/TN',`\\RadioCLI\\${jobName(id)}`,'/F']).catch(()=>undefined);common.removeFile(xmlPath(id));}, + async remove(id){const name=`\\RadioCLI\\${jobName(id)}`;await removeWithVerification(()=>common.run('schtasks.exe',['/Delete','/TN',name,'/F']),()=>common.run('schtasks.exe',['/Query','/TN',name]),result=>result.code===1&&/^ERROR: The system cannot find the (?:file|path) specified\.$/.test(`${result.stderr}\n${result.stdout}`.trim()),'Task Scheduler job');common.removeFile(xmlPath(id));}, async status(id){const result=await common.run('schtasks.exe',['/Query','/TN',`\\RadioCLI\\${jobName(id)}`]);const artifact=existsSync(xmlPath(id));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:result.code===0&&artifact,healthy:result.code===0&&artifact&&executable,message:result.code!==0?'task not registered':!artifact?'task XML artifact missing':!executable?'RadioCLI executable missing':'registered and executable'};} }; } function unsupportedAdapter(message:string):SchedulerAdapter{return{capabilities:()=>({supported:false,exactWake:false,catchUpAfterWake:false,message}),install:async()=>{throw new Error(message);},remove:async()=>{},status:async()=>({installed:false,healthy:false,message})};} -function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',v=>stdout+=String(v));child.stderr.on('data',v=>stderr+=String(v));child.on('error',e=>resolve({code:127,stdout,stderr:e.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} +function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true,timeout:10_000});let stdout='';let stderr='';child.stdout.on('data',v=>stdout+=String(v));child.stderr.on('data',v=>stderr+=String(v));child.on('error',e=>resolve({code:127,stdout,stderr:e.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} +async function boundedProbe(probe:Promise):Promise{let timer:NodeJS.Timeout|undefined;try{return await Promise.race([probe,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('User-manager probe timed out.')),3_000);})]);}finally{if(timer)clearTimeout(timer);}} +async function removeWithVerification(remove:()=>Promise,query:()=>Promise,isAbsent:(result:CommandResult)=>boolean,label:string):Promise{ + let failure:unknown; + try{ensureSuccess(await remove(),`${label} removal`);}catch(error){failure=error;} + if(!failure)return; + let result:CommandResult; + try{result=await query();}catch(error){throw new Error(`Unable to verify ${label} removal: ${errorMessage(error)}`);} + if(isAbsent(result))return; + throw new Error(`Unable to verify ${label} absence; repair artifacts were retained. ${errorMessage(failure)} ${commandMessage(result)}`); +} +function commandMessage(result:CommandResult){return(result.stderr||result.stdout||`exit ${result.code}`).trim();} function ensureSuccess(result:CommandResult,label:string){if(result.code!==0)throw new Error(`${label} failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);} function xml(value:string){return value.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');} function systemdQuote(value:string){if(/[\r\n\0]/.test(value))throw new Error('Scheduler arguments cannot contain control characters.');return `"${value.replaceAll('%','%%').replaceAll('\\','\\\\').replaceAll('"','\\"')}"`;} @@ -167,5 +212,5 @@ function systemdCalendar(date:Date){return `${date.getUTCFullYear()}-${String(da function windowsLocalBoundary(date:Date){const p=localParts(date);return `${p.year}-${String(p.month).padStart(2,'0')}-${String(p.day).padStart(2,'0')}T${String(p.hour).padStart(2,'0')}:${String(p.minute).padStart(2,'0')}:${String(p.second).padStart(2,'0')}`;} function unitText(value:string){return value.replace(/[\r\n\0]/g,' ').replaceAll('%','%%').slice(0,200);} function errorMessage(error:unknown){return error instanceof Error?error.message:String(error);} -function isLaunchdServiceNotFound(result:CommandResult,label:string){const output=`${result.stderr}\n${result.stdout}`;return result.code!==0&&output.includes(label)&&/could not find service\b/i.test(output)&&/\bin domain for\b/i.test(output);} +function isLaunchdServiceNotFound(result:CommandResult,label:string){const output=`${result.stderr}\n${result.stdout}`;const match=/could not find service "([^"]+)" in domain for\b/i.exec(output);return result.code!==0&&match?.[1]===label;} async function mapConcurrent(items:readonly T[],limit:number,work:(item:T)=>Promise):Promise{const results=new Array(items.length);let next=0;await Promise.all(Array.from({length:Math.min(limit,items.length)},async()=>{while(true){const index=next;next+=1;if(index>=items.length)return;results[index]=await work(items[index]!);}}));return results;} diff --git a/src/alarms/setup-verification.test.ts b/src/alarms/setup-verification.test.ts index 0af70e9..6f6ff2e 100644 --- a/src/alarms/setup-verification.test.ts +++ b/src/alarms/setup-verification.test.ts @@ -18,4 +18,5 @@ describe('alarm setup verification',()=>{ it('does not claim success when an audible sample cannot be stopped cleanly',async()=>{const {alarm,settings}=fixture();const d=dependencies();d.deps.player=vi.fn(()=>({refreshDetectedBackends:vi.fn(),play:vi.fn(async()=>{}),stop:vi.fn(async()=>new Promise(()=>{}))}));const report=await verifyAlarmSetup(scheduler() as never,alarm,settings,()=>{}, {...d.deps,wait:vi.fn(async()=>{})});expect(report.state).toBe('failed');expect(report.steps.find(step=>step.id==='playback')).toMatchObject({state:'failed'});},5_000); it('attempts cleanup even when native registration reports a hard failure',async()=>{const {alarm,settings}=fixture();const d=dependencies();const s=scheduler({sync:vi.fn(async()=>{throw new Error('registration denied');})});const report=await verifyAlarmSetup(s as never,alarm,settings,()=>{},d.deps);expect(report.state).toBe('failed');expect(s.remove).toHaveBeenCalledOnce();expect(report.steps.find(step=>step.id==='scheduler')).toMatchObject({state:'failed'});expect(report.steps.find(step=>step.id==='cleanup')).toMatchObject({state:'passed'});}); it('requires a configured alarm for the audible station rehearsal while still cleaning up',async()=>{const {settings}=fixture();const d=dependencies();const s=scheduler();const report=await verifyAlarmSetup(s as never,undefined,settings,()=>{},d.deps);expect(report.state).toBe('failed');expect(report.steps.find(step=>step.id==='playback')?.detail).toMatch(/create at least one alarm/i);expect(s.remove).toHaveBeenCalledOnce();}); + it('names the adapter that performed registration instead of inferring one from the host',async()=>{const {alarm,settings}=fixture();const d=dependencies();const s=scheduler({doctor:()=>({supported:true,exactWake:false,catchUpAfterWake:true,name:'Selected test scheduler',message:'ready'})});const report=await verifyAlarmSetup(s as never,alarm,settings,()=>{},d.deps);expect(report.steps.find(step=>step.id==='scheduler')).toMatchObject({state:'passed',detail:'Registered and queried a disposable Selected test scheduler job.'});}); }); diff --git a/src/alarms/setup-verification.ts b/src/alarms/setup-verification.ts index fe58def..51f1415 100644 --- a/src/alarms/setup-verification.ts +++ b/src/alarms/setup-verification.ts @@ -58,7 +58,7 @@ export async function verifyAlarmSetup(scheduler:SchedulerService,alarm:Alarm|un temporary=temporaryAlarm(alarm,now(),deps.id?.()??randomUUID()); const occurrence=await scheduler.sync(temporary);if(!occurrence)throw new Error('The disposable alarm did not receive a future occurrence.');schedulerInstalled=true; const [status]=await scheduler.statusAll([temporary]);if(!status?.native.installed||!status.native.healthy)throw new Error(status?.native.message??'The native job could not be verified.'); - set('scheduler','passed',`Registered and queried a disposable ${platformSchedulerName()} job.`); + set('scheduler','passed',`Registered and queried a disposable ${capability.name??'native scheduler'} job.`); if(alarm?.reliability.wakeIfSupported)set('wake',capability.exactWake?'passed':'warning',capability.exactWake?'This scheduler reports exact wake support.':`The OS accepted the job, but wake remains hardware and policy dependent. ${capability.message}`); else set('wake','passed',capability.catchUpAfterWake?'Wake was not requested; the native scheduler supports catch-up after the machine wakes.':'Wake was not requested for this alarm.'); }catch(error){set('scheduler','failed',messageOf(error));set('wake','warning','Wake behavior could not be assessed because native scheduler verification failed.');} @@ -98,7 +98,6 @@ export async function verifyAlarmSetup(scheduler:SchedulerService,alarm:Alarm|un function temporaryAlarm(source:Alarm|undefined,now:Date,id:string):Alarm{const at=new Date(Math.ceil((now.getTime()+10*60_000)/60_000)*60_000);return{id:`setup-verification-${id}`,label:'RadioCLI setup verification',enabled:true,station:source?.station??{id:'verification',provider:'playlist',name:'RadioCLI verification',tags:[],streamUrl:'https://127.0.0.1/'},schedule:{type:'once',at:at.toISOString()},playback:{volume:source?.playback.volume??40,fadeSeconds:0,stopAfterMinutes:1},reliability:{missedRunGraceMinutes:1,wakeIfSupported:false},createdAt:now.toISOString(),updatedAt:now.toISOString()};} async function probeControlChannel():Promise{const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-verify-'));const file=join(root,'probe.json');const server=await startActiveAlarmSession({alarmId:'setup-verification',scheduledAt:new Date().toISOString(),stationName:'RadioCLI verification',startedAt:new Date().toISOString()},{filePath:file,onDismiss:()=>{},onSnooze:()=>{},onKeepPlaying:()=>{}});try{const client=await connectActiveAlarm(file);if(!client)throw new Error('The ringing control channel was not discoverable.');const status=await client.status();if(status.alarmId!=='setup-verification')throw new Error('The ringing control channel returned the wrong alarm identity.');}finally{await server.close();rmSync(root,{recursive:true,force:true});}} -function platformSchedulerName(){return process.platform==='darwin'?'launchd':process.platform==='win32'?'Task Scheduler':'systemd';} function friendlyTerminal(value:string){return value.replace(/^darwin:/,'').replace(/^win32:/,'').replace(/^linux:/,'');} function messageOf(error:unknown){return error instanceof Error?error.message:String(error);} function wait(milliseconds:number){return new Promise(resolve=>setTimeout(resolve,milliseconds));} diff --git a/src/alarms/system-volume.test.ts b/src/alarms/system-volume.test.ts index c09e369..435f8ea 100644 --- a/src/alarms/system-volume.test.ts +++ b/src/alarms/system-volume.test.ts @@ -1,6 +1,8 @@ -import {describe,expect,it,vi} from 'vitest'; +import {afterEach,describe,expect,it,vi} from 'vitest'; import {createSystemVolumeController} from './system-volume.js'; +afterEach(()=>vi.unstubAllEnvs()); + describe('alarm system output volume',()=>{ it('unmutes and raises macOS output for an alarm, then restores it',async()=>{const calls:Array<{command:string;args:string[]}>=[];const run=vi.fn(async(command:string,args:string[])=>{calls.push({command,args});return{code:0,stdout:args.includes('set s to get volume settings')?'20,true\n':'',stderr:''};});const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70);expect(calls[1]?.args.join(' ')).toContain('set volume output volume 70 without output muted');await lease.release();expect(calls[2]?.args.join(' ')).toContain('set volume output volume 20 with output muted');}); @@ -11,4 +13,26 @@ describe('alarm system output volume',()=>{ it('uses Windows Core Audio through built-in PowerShell',async()=>{const run=vi.fn(async(_command:string,args:string[])=>({code:0,stdout:args.at(-1)?.includes('::Get')?'10,true':'',stderr:''}));const controller=createSystemVolumeController('win32',run,command=>command==='powershell.exe'?'powershell.exe':null);const lease=await controller.acquireMinimum(55);const setScript=(run.mock.calls as unknown as Array<[string,string[]]>)[1]?.[1].at(-1)??'';expect(setScript).toContain('[RadioCliVolume]::Set(55, $false)');await lease.release();}); it('fails truthfully when no platform volume backend exists',async()=>{await expect(createSystemVolumeController('linux',vi.fn(),()=>null).acquireMinimum(50)).rejects.toThrow(/unavailable/i);}); + + it('restores the previous volume and mute state when an adjustment only partly succeeds',async()=>{ + const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='set-mute'&&args[2]==='0'?1:0,stdout:args[0]==='get-volume'?'Volume: 0.15 [MUTED]':'',stderr:args[0]==='set-mute'&&args[2]==='0'?'mute denied':''})); + await expect(createSystemVolumeController('linux',run,()=>'/test/wpctl').acquireMinimum(60)).rejects.toThrow(/mute denied/); + expect(run.mock.calls.map(call=>call[1])).toEqual([['get-volume','@DEFAULT_AUDIO_SINK@'],['set-volume','@DEFAULT_AUDIO_SINK@','60%'],['set-mute','@DEFAULT_AUDIO_SINK@','0'],['set-volume','@DEFAULT_AUDIO_SINK@','15%'],['set-mute','@DEFAULT_AUDIO_SINK@','1']]); + }); + + it('reports restoration failure after a partially applied adjustment',async()=>{ + const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='set-mute'||args[2]==='15%'?1:0,stdout:args[0]==='get-volume'?'Volume: 0.15 [MUTED]':'',stderr:args[2]==='15%'?'restore denied':'mute denied'})); + await expect(createSystemVolumeController('linux',run,()=>'/test/wpctl').acquireMinimum(60)).rejects.toThrow(/mute denied.*restor.*restore denied/i); + }); + + it('allows a failed restoration to be retried when the lease is released again',async()=>{ + let restorationAttempts=0; + const run=vi.fn(async(_command:string,args:string[])=>{const restoring=args.some(arg=>arg.includes('set volume output volume 20 '));if(restoring)restorationAttempts+=1;return{code:restoring&&restorationAttempts===1?1:0,stdout:args.includes('set s to get volume settings')?'20,true':'',stderr:'temporary mixer failure'};}); + const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70); + await expect(lease.release()).rejects.toThrow(/temporary mixer failure/);await expect(lease.release()).resolves.toBeUndefined();await lease.release();expect(restorationAttempts).toBe(2); + }); + + it('does not control a Termux mixer through the Linux desktop adapter',async()=>{vi.stubEnv('TERMUX_VERSION','0.118.3');const run=vi.fn();const resolve=vi.fn(()=>'/test/wpctl');await expect(createSystemVolumeController('linux',run,resolve).acquireMinimum(50)).rejects.toThrow(/unavailable.*termux/i);expect(resolve).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();}); + + it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('reports unavailable output-volume control on %s without commands',async platform=>{const run=vi.fn();const resolve=vi.fn(()=>'/test/wpctl');await expect(createSystemVolumeController(platform,run,resolve).acquireMinimum(50)).rejects.toThrow(/unavailable/i);expect(resolve).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();}); }); diff --git a/src/alarms/system-volume.ts b/src/alarms/system-volume.ts index 7f2c340..14e63d5 100644 --- a/src/alarms/system-volume.ts +++ b/src/alarms/system-volume.ts @@ -1,5 +1,6 @@ import {spawn} from 'node:child_process'; import {resolveCommand} from '../player/command.js'; +import {identifyPlatform,nativeAdapters} from '../platform/runtime.js'; type Snapshot={volume:number;muted:boolean}; type Result={code:number;stdout:string;stderr:string}; @@ -8,8 +9,25 @@ export type SystemVolumeLease={release():Promise;message:string}; export type SystemVolumeController={acquireMinimum(volume:number):Promise}; export function createSystemVolumeController(platform:NodeJS.Platform=process.platform,run:Run=runCommand,resolve:(command:string)=>string|null=resolveCommand):SystemVolumeController{ - const backend=platform==='darwin'?macBackend(run):platform==='linux'?linuxBackend(run,resolve):platform==='win32'?windowsBackend(run,resolve):undefined; - return{async acquireMinimum(volume){if(!backend)throw new Error(`System output volume control is unavailable on ${platform}.`);const target=clamp(volume);const before=await backend.read();const applied=Math.max(before.volume,target);if(before.muted||before.volume;write(value:Snapshot):Promise}; @@ -34,5 +52,6 @@ public enum EDataFlow { eRender, eCapture, eAll } public enum ERole { eConsole, public static class RadioCliVolume { static IAudioEndpointVolume Endpoint(){IMMDevice d;((IMMDeviceEnumerator)new MMDeviceEnumerator()).GetDefaultAudioEndpoint(EDataFlow.eRender,ERole.eMultimedia,out d);object o;Guid id=typeof(IAudioEndpointVolume).GUID;d.Activate(ref id,23,IntPtr.Zero,out o);return(IAudioEndpointVolume)o;} public static string Get(){float v;bool m;var e=Endpoint();e.GetMasterVolumeLevelScalar(out v);e.GetMute(out m);return Math.Round(v*100)+","+m.ToString().ToLower();} public static void Set(int v,bool m){var e=Endpoint();e.SetMasterVolumeLevelScalar(Math.Max(0,Math.Min(100,v))/100f,Guid.Empty);e.SetMute(m,Guid.Empty);} } '@`; function clamp(value:number){return Math.max(0,Math.min(100,Math.round(Number.isFinite(value)?value:0)));} +function messageOf(error:unknown){return error instanceof Error?error.message:String(error);} async function checked(promise:Promise,label:string){const result=await promise;if(result.code!==0)throw new Error(`${label} failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);return result;} function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',value=>stdout+=String(value));child.stderr.on('data',value=>stderr+=String(value));child.on('error',error=>resolve({code:127,stdout,stderr:error.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} diff --git a/src/alarms/terminal-launcher.test.ts b/src/alarms/terminal-launcher.test.ts index 02e6a26..36ea87f 100644 --- a/src/alarms/terminal-launcher.test.ts +++ b/src/alarms/terminal-launcher.test.ts @@ -1,5 +1,6 @@ import {EventEmitter} from 'node:events'; import {spawn as nodeSpawn,type ChildProcess} from 'node:child_process'; +import {connect, type Socket} from 'node:net'; import {describe,expect,it,vi} from 'vitest'; import {detectAlarmTerminal,openAlarmControls,prepareAlarmTerminalAccess,verifyAlarmTerminalLaunch} from './terminal-launcher.js'; @@ -46,4 +47,21 @@ describe('alarm terminal launcher',()=>{ it('rejects setup verification when no graphical terminal can expose ringing controls',async()=>{await expect(verifyAlarmTerminalLaunch({platform:'freebsd',timeoutMs:10})).rejects.toThrow(/no supported graphical terminal/i);}); it('requires the launched terminal process to complete an authenticated callback',async()=>{const launch=vi.fn((_command:string,args:readonly string[])=>{const index=args.indexOf(process.execPath);expect(index).toBeGreaterThanOrEqual(0);return nodeSpawn(process.execPath,args.slice(index+1),{stdio:'ignore'});});await expect(verifyAlarmTerminalLaunch({platform:'win32',env:{WT_SESSION:'test'},nodePath:process.execPath,spawn:launch,timeoutMs:2_000})).resolves.toBe('win32:windows-terminal');expect(launch).toHaveBeenCalledOnce();}); + + it('bounds and closes a callback connection that never sends its token', async () => { + let socket: Socket | undefined; + const launch = vi.fn((_command: string, args: readonly string[]) => { + const child = new EventEmitter() as ChildProcess; + child.unref = vi.fn(); + const port = Number(args.at(-3)); + const host = args.at(-1)!; + socket = connect(port, host); + socket.on('error', () => undefined); + queueMicrotask(() => child.emit('spawn')); + return child; + }); + try { + await expect(verifyAlarmTerminalLaunch({platform: 'win32', env: {WT_SESSION: 'test'}, spawn: launch, timeoutMs: 30})).rejects.toThrow(/did not connect back/i); + } finally { socket?.destroy(); } + }); }); diff --git a/src/alarms/terminal-launcher.ts b/src/alarms/terminal-launcher.ts index c123004..c21773f 100644 --- a/src/alarms/terminal-launcher.ts +++ b/src/alarms/terminal-launcher.ts @@ -1,8 +1,9 @@ import {spawn, type ChildProcess} from 'node:child_process'; import {existsSync} from 'node:fs'; -import {createServer} from 'node:net'; +import {createServer, type Socket} from 'node:net'; import {randomBytes} from 'node:crypto'; import {basename, delimiter, join} from 'node:path'; +import {listenLoopback} from '../platform/loopback.js'; export type AlarmTerminalLaunchResult = {opened: boolean; terminal: string; message: string}; type Spawn = (command: string, args: readonly string[]) => ChildProcess; @@ -83,13 +84,16 @@ export async function verifyAlarmTerminalLaunch(options:ProbeOptions={}):Promise await prepareAlarmTerminalAccess(options); const terminal=detectAlarmTerminal(platform,env);if(terminal.endsWith(':unsupported'))throw new Error('No supported graphical terminal was found for automatic alarm controls.'); const token=randomBytes(24).toString('base64url');const server=createServer(); - await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>resolve());}); - const address=server.address();if(!address||typeof address==='string'){server.close();throw new Error('Unable to create the terminal verification channel.');} - const script="const n=require('node:net');const s=n.connect(Number(process.argv[1]),'127.0.0.1',()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));"; - const direct=[options.nodePath??process.execPath,'-e',script,String(address.port),token]; + const address=await listenLoopback(server); + const script="const n=require('node:net');const s=n.connect(Number(process.argv[1]),process.argv[3],()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));"; + const direct=[options.nodePath??process.execPath,'-e',script,String(address.port),token,address.host]; const command=`${direct.map(shellQuote).join(' ')}; exit`; const launch=options.spawn??spawnDetached; - const received=new Promise((resolve,reject)=>{server.once('connection',socket=>{let value='';socket.setEncoding('utf8');socket.on('data',chunk=>value+=chunk);socket.on('end',()=>value===token?resolve():reject(new Error('The terminal verification response was not authentic.')));socket.on('error',reject);});}); + const sockets=new Set(); + const received=new Promise((resolve,reject)=>{server.on('connection',socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));let value='';socket.setEncoding('utf8');socket.on('data',chunk=>{value+=chunk;if(value.length>token.length){socket.destroy();reject(new Error('The terminal verification response was not authentic.'));}});socket.on('end',()=>value===token?resolve():reject(new Error('The terminal verification response was not authentic.')));socket.on('error',reject);});}); + // A callback can fail while the launcher is still reporting its own result. + // Keep the rejection observed until it is awaited below. + void received.catch(()=>undefined); try{ if(terminal==='darwin:apple-terminal')await launched(launch('/usr/bin/osascript',appleTerminalScript(command))); else if(terminal==='darwin:iterm')await launched(launch('/usr/bin/osascript',iTermScript(command))); @@ -102,7 +106,7 @@ export async function verifyAlarmTerminalLaunch(options:ProbeOptions={}):Promise else throw new Error('The saved terminal is not supported for alarm controls.'); await withTimeout(received,options.timeoutMs??8_000,'The terminal opened but did not connect back to RadioCLI.'); return terminal; - }finally{await new Promise(resolve=>server.close(()=>resolve()));} + }finally{for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(()=>resolve()));} } function validDescriptor(value:string,platform:NodeJS.Platform):boolean { diff --git a/src/alarms/tui-presence.ts b/src/alarms/tui-presence.ts index 5166550..bad05d2 100644 --- a/src/alarms/tui-presence.ts +++ b/src/alarms/tui-presence.ts @@ -1,5 +1,5 @@ import {existsSync,mkdirSync,readFileSync,readdirSync,rmSync,writeFileSync} from 'node:fs'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {join} from 'node:path'; export function registerTuiPresence(root=runtimeDirectory(),pid=process.pid):()=>void { @@ -11,5 +11,5 @@ export function hasLiveTui(root=runtimeDirectory(),alive=processAlive):boolean { for(const name of readdirSync(directory)){const path=join(directory,name);try{const value=JSON.parse(readFileSync(path,'utf8')) as {pid?:unknown};if(typeof value.pid==='number'&&Number.isInteger(value.pid)&&alive(value.pid))return true;rmSync(path,{force:true});}catch{rmSync(path,{force:true});}} return false; } -function runtimeDirectory():string{if(process.env.RADIOCLI_HOME)return join(process.env.RADIOCLI_HOME,'runtime');if(process.platform==='win32')return join(process.env.LOCALAPPDATA??join(homedir(),'AppData','Local'),'RadioCLI','runtime');return join(process.env.XDG_RUNTIME_DIR??join(homedir(),'.local','state'),'radiocli');} +function runtimeDirectory():string{return platformPaths().runtime;} function processAlive(pid:number):boolean{try{process.kill(pid,0);return true;}catch{return false;}} diff --git a/src/platform/loopback.test.ts b/src/platform/loopback.test.ts new file mode 100644 index 0000000..de9583a --- /dev/null +++ b/src/platform/loopback.test.ts @@ -0,0 +1,70 @@ +import {createServer, type Server} from 'node:net'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {isLoopbackHost, listenLoopback} from './loopback.js'; + +const servers: Server[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(() => resolve())))); +}); + +describe('private loopback listeners', () => { + it('accepts only the two literal loopback addresses', () => { + expect(isLoopbackHost('127.0.0.1')).toBe(true); + expect(isLoopbackHost('::1')).toBe(true); + for (const host of ['localhost', '0.0.0.0', '::', '127.0.0.2', '[::1]', '::ffff:127.0.0.1', 'example.com', undefined]) { + expect(isLoopbackHost(host), String(host)).toBe(false); + } + }); + + it('binds an ephemeral IPv4 loopback port without exposing a wildcard listener', async () => { + const server = createServer(); + servers.push(server); + const address = await listenLoopback(server); + expect(address.host).toBe('127.0.0.1'); + expect(address.port).toBeGreaterThan(0); + expect(server.address()).toMatchObject({address: '127.0.0.1', port: address.port}); + expect(server.listenerCount('error')).toBe(0); + expect(server.listenerCount('listening')).toBe(0); + }); + + for (const code of ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL']) { + it(`falls back to a real IPv6 loopback listener after ${code} on IPv4`, async context => { + const server = createServer(); + servers.push(server); + const listen = vi.spyOn(server, 'listen').mockImplementationOnce(() => { + queueMicrotask(() => server.emit('error', Object.assign(new Error('IPv4 unavailable'), {code}))); + return server; + }); + let address; + try { + address = await listenLoopback(server); + } catch (error) { + expect(listen).toHaveBeenCalledTimes(2); + if (['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip('The host does not provide IPv6 loopback.'); + } + throw error; + } + expect(address).toMatchObject({host: '::1'}); + expect(server.address()).toMatchObject({address: '::1', port: address.port}); + expect(listen.mock.calls.map(call => call[1])).toEqual(['127.0.0.1', '::1']); + expect(server.listenerCount('error')).toBe(0); + }); + } + + it('does not hide permission errors by retrying another address family', async () => { + const server = createServer(); + servers.push(server); + const denied = Object.assign(new Error('permission denied'), {code: 'EACCES'}); + const listen = vi.spyOn(server, 'listen').mockImplementationOnce(() => { + queueMicrotask(() => server.emit('error', denied)); + return server; + }); + await expect(listenLoopback(server)).rejects.toBe(denied); + expect(listen).toHaveBeenCalledOnce(); + expect(server.listenerCount('error')).toBe(0); + expect(server.listenerCount('listening')).toBe(0); + }); +}); diff --git a/src/platform/loopback.ts b/src/platform/loopback.ts new file mode 100644 index 0000000..8c1b51d --- /dev/null +++ b/src/platform/loopback.ts @@ -0,0 +1,47 @@ +import type {Server} from 'node:net'; + +export type LoopbackHost = '127.0.0.1' | '::1'; + +export function isLoopbackHost(value: unknown): value is LoopbackHost { + return value === '127.0.0.1' || value === '::1'; +} + +/** Bind only literal loopback addresses, including hosts without an IPv4 stack. */ +export async function listenLoopback(server: Server): Promise<{host: LoopbackHost; port: number}> { + try { + return await listen(server, '127.0.0.1'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EAFNOSUPPORT' && code !== 'EPROTONOSUPPORT' && code !== 'EADDRNOTAVAIL') throw error; + return listen(server, '::1'); + } +} + +function listen(server: Server, host: LoopbackHost): Promise<{host: LoopbackHost; port: number}> { + return new Promise((resolve, reject) => { + const cleanup = () => { + server.removeListener('error', failed); + server.removeListener('listening', ready); + }; + const failed = (error: Error) => { + cleanup(); + reject(error); + }; + const ready = () => { + cleanup(); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Unable to create a local control endpoint.')); + return; + } + resolve({host, port: address.port}); + }; + server.once('error', failed); + server.once('listening', ready); + try { + server.listen(0, host); + } catch (error) { + failed(error as Error); + } + }); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4103586..c2a6009 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1208,16 +1208,16 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } }, []); - const openStationHomepage = useCallback((station: Station | null) => { + const openStationHomepage = useCallback(async (station: Station | null) => { if (!station?.homepage) { setMessage('This station has no homepage.'); return; } setMessage( - openExternal(station.homepage) + await openExternal(station.homepage) ? `Opening homepage: ${station.name}` - : 'That station homepage is not a valid HTTP(S) URL.' + : `Could not open a browser in this session. Homepage: ${station.homepage}` ); }, []); @@ -1238,7 +1238,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return; } - const copied = copyToClipboard(url); + const copied = await copyToClipboard(url); setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${url}`); }, [providers] @@ -1619,7 +1619,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } const command = updateCommandForInstall(); - const copied = copyToClipboard(command.command); + const copied = await copyToClipboard(command.command); const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion; const prefix = latestVersion ? `Latest v${latestVersion}. ` : ''; const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method'; diff --git a/src/ui/system-actions.test.ts b/src/ui/system-actions.test.ts index 66a013f..4b9263b 100644 --- a/src/ui/system-actions.test.ts +++ b/src/ui/system-actions.test.ts @@ -1,5 +1,10 @@ -import {describe, expect, it} from 'vitest'; -import {clipboardCommands, openExternalCommand} from './system-actions.js'; +import {EventEmitter} from 'node:events'; +import {PassThrough} from 'node:stream'; +import type {ChildProcess} from 'node:child_process'; +import {describe, expect, it, vi} from 'vitest'; +import {clipboardCommands, copyToClipboard, openExternal, openExternalCommand} from './system-actions.js'; + +vi.mock('node:child_process', () => ({spawn: vi.fn(() => { throw new Error('Desktop tests must inject their launcher.'); })})); describe('system actions', () => { it('opens URLs with the platform default handler', () => { @@ -13,4 +18,63 @@ describe('system actions', () => { expect(clipboardCommands('win32').map(entry => entry.command)).toEqual(['clip']); expect(clipboardCommands('linux').map(entry => entry.command)).toEqual(['wl-copy', 'xclip', 'xsel']); }); + + it('does not report an asynchronous browser launch failure as success', async () => { + const launch = fakeLauncher('error'); + await expect(openExternal('https://example.test/?x=1&y=2', 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); + }); + + it('passes a validated URL as one literal argument and waits for completion', async () => { + const launch = fakeLauncher(0); + const url = 'https://example.test/?x=1&y=%22'; + await expect(openExternal(url, 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(true); + expect(launch.mock.calls[0]?.slice(0, 2)).toEqual(['explorer', [url]]); + await expect(openExternal('file:///etc/passwd', 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); + expect(launch).toHaveBeenCalledOnce(); + }); + + it('falls through to another clipboard tool after a nonzero exit', async () => { + const launch = fakeLauncher(1, 0); + const text = 'Zoë 日本 & $(literal)'; + await expect(copyToClipboard(text, 'linux', {spawn: launch, env: {DISPLAY: ':0'}, resolve: name => name})).resolves.toBe(true); + expect(launch.mock.calls.map(call => call[0])).toEqual(['wl-copy', 'xclip']); + expect(launch.inputs).toEqual([text, text]); + }); + + it('reports missing tools and headless sessions without launching', async () => { + const launch = fakeLauncher(0); + await expect(copyToClipboard('text', 'linux', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); + await expect(openExternal('https://example.test', 'darwin', {spawn: launch, env: {SSH_CONNECTION: 'test'}, resolve: name => name})).resolves.toBe(false); + await expect(copyToClipboard('text', 'darwin', {spawn: launch, env: {}, resolve: () => null})).resolves.toBe(false); + expect(launch).not.toHaveBeenCalled(); + }); + + it('handles clipboard stdin failure and bounds a hung helper', async () => { + const broken = fakeLauncher('pipe-error'); + await expect(copyToClipboard('text', 'darwin', {spawn: broken, env: {}, resolve: name => name})).resolves.toBe(false); + const hung = fakeLauncher('hang'); + await expect(copyToClipboard('text', 'darwin', {spawn: hung, env: {}, resolve: name => name, timeoutMs: 10})).resolves.toBe(false); + expect(hung.kills).toHaveBeenCalledOnce(); + }); }); + +function fakeLauncher(...results: Array) { + const inputs: string[] = []; + const kills = vi.fn(); + const launch = vi.fn((_command: string, _args: readonly string[], _options?: unknown) => { + const child = new EventEmitter() as ChildProcess; + child.stdin = new PassThrough(); + child.kill = kills; + child.unref = vi.fn(); + const index = inputs.push('') - 1; + child.stdin.on('data', chunk => { inputs[index] += String(chunk); }); + const result = results.shift() ?? 1; + queueMicrotask(() => { + if (result === 'error') child.emit('error', new Error('Command unavailable')); + else if (result === 'pipe-error') child.stdin?.emit('error', new Error('EPIPE')); + else if (result !== 'hang') child.emit('close', result); + }); + return child; + }); + return Object.assign(launch, {inputs, kills}); +} diff --git a/src/ui/system-actions.ts b/src/ui/system-actions.ts index 0229242..f74a343 100644 --- a/src/ui/system-actions.ts +++ b/src/ui/system-actions.ts @@ -1,56 +1,69 @@ -import {spawn} from 'node:child_process'; +import {spawn, type ChildProcess, type SpawnOptions} from 'node:child_process'; import {resolveCommand} from '../player/command.js'; import {safeExternalHttpUrl} from '../safety.js'; -import {browserCommands, clipboardCandidates, type SystemCommand} from '../platform/desktop.js'; +import {browserCommands, clipboardCandidates, hasGraphicalSession, type SystemCommand} from '../platform/desktop.js'; import {identifyPlatform} from '../platform/runtime.js'; // The command used to open a URL in the platform's default handler. -export function openExternalCommand(platform: NodeJS.Platform = process.platform): SystemCommand { - return browserCommands(identifyPlatform({platform}))[0] ?? {command: 'xdg-open', args: []}; +export function openExternalCommand(platform: NodeJS.Platform = process.platform): SystemCommand | undefined { + return browserCommands(identifyPlatform({platform}))[0]; } // Candidate clipboard tools per platform, in priority order. pbcopy/clip ship // with macOS/Windows; Linux/BSD rely on whichever of these is installed. export function clipboardCommands(platform: NodeJS.Platform = process.platform): SystemCommand[] { - const commands = clipboardCandidates(identifyPlatform({platform})); - return commands.length ? commands : clipboardCandidates(identifyPlatform({platform: 'linux', env: {}})); + return clipboardCandidates(identifyPlatform({platform})); } -export function openExternal(url: string, platform: NodeJS.Platform = process.platform): boolean { +type ActionDeps = { + env?: NodeJS.ProcessEnv; + resolve?: (command: string) => string | null; + spawn?: (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; + timeoutMs?: number; +}; + +export async function openExternal(url: string, platform: NodeJS.Platform = process.platform, deps: ActionDeps = {}): Promise { const safeUrl = safeExternalHttpUrl(url); - if (!safeUrl) { - return false; + const env = deps.env ?? process.env; + const host = identifyPlatform({platform, env}); + if (!safeUrl || !hasGraphicalSession(host, env)) return false; + for (const {command, args} of browserCommands(host)) { + const executable = (deps.resolve ?? resolveCommand)(command); + if (executable && await runHelper(executable, [...args, safeUrl], undefined, deps)) return true; } + return false; +} - const {command, args} = openExternalCommand(platform); - try { - const child = spawn(resolveCommand(command) ?? command, [...args, safeUrl], {stdio: 'ignore', detached: true}); - child.on('error', () => undefined); - child.unref(); - return true; - } catch { - // Opening a browser is best-effort; never crash the TUI over it. - return false; +export async function copyToClipboard(text: string, platform: NodeJS.Platform = process.platform, deps: ActionDeps = {}): Promise { + const env = deps.env ?? process.env; + const host = identifyPlatform({platform, env}); + if (!hasGraphicalSession(host, env)) return false; + for (const {command, args} of clipboardCandidates(host)) { + const executable = (deps.resolve ?? resolveCommand)(command); + if (executable && await runHelper(executable, args, text, deps)) return true; } + return false; } -export function copyToClipboard(text: string, platform: NodeJS.Platform = process.platform): boolean { - for (const {command, args} of clipboardCommands(platform)) { - const resolved = resolveCommand(command); - if (!resolved) { - continue; - } - +function runHelper(command: string, args: string[], input: string | undefined, deps: ActionDeps): Promise { + return new Promise(resolve => { + let child: ChildProcess | undefined; + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(ok); + }; + const timer = setTimeout(() => { child?.kill(); finish(false); }, deps.timeoutMs ?? 5000); try { - const child = spawn(resolved, args, {stdio: ['pipe', 'ignore', 'ignore']}); - child.on('error', () => undefined); - child.stdin.write(text); - child.stdin.end(); - return true; + child = (deps.spawn ?? spawn)(command, args, {stdio: input === undefined ? 'ignore' : ['pipe', 'ignore', 'ignore'], env: deps.env ?? process.env, windowsHide: true}); + child.once('error', () => finish(false)); + child.once('close', code => finish(code === 0)); + child.stdin?.on('error', () => { child?.kill(); finish(false); }); + if (input !== undefined) child.stdin?.end(input); } catch { - continue; + finish(false); } - } - - return false; + }); } From a64d5f7e03bb28662c158942814f35e4d3c164b0 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:47:02 -0500 Subject: [PATCH 04/25] fix: harden player discovery and portable mpv IPC --- src/platform/executables.test.ts | 52 +++++++++++++++++++++++++++++++ src/platform/executables.ts | 33 +++++++++++++++----- src/platform/ipc.test.ts | 17 ++++++++++ src/platform/ipc.ts | 16 ++++++++++ src/player/command-diagnostics.ts | 2 +- src/player/mpv-ipc-client.test.ts | 20 ++++++++++++ src/player/mpv-ipc-client.ts | 3 +- src/player/player-controller.ts | 8 ++--- 8 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 src/platform/executables.test.ts create mode 100644 src/platform/ipc.test.ts create mode 100644 src/platform/ipc.ts diff --git a/src/platform/executables.test.ts b/src/platform/executables.test.ts new file mode 100644 index 0000000..4ec66f9 --- /dev/null +++ b/src/platform/executables.test.ts @@ -0,0 +1,52 @@ +import {chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {clearCommandCache, resolveCommandDetails} from './executables.js'; + +const roots: string[] = []; +afterEach(() => { + vi.unstubAllEnvs(); + clearCommandCache(); + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); + +describe('safe executable configuration and permissions', () => { + it('treats a configured path with spaces and Unicode as a single executable', () => { + const path = '/opt/Radio 日本/player mpv'; + expect(resolveCommandDetails('mpv', {platform: 'linux', env: {PATH: '/usr/bin', RADIOCLI_MPV_PATH: path}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'configured-path'}); + }); + + it('never falls back silently from a broken explicit player configuration', () => { + const result = resolveCommandDetails('mpv', {platform: 'linux', env: {RADIOCLI_MPV_PATH: '/missing/mpv'}, isRunnable: path => path === '/usr/bin/mpv'}); + expect(result).toMatchObject({path: null, discovery: 'configured-path', error: expect.stringContaining('RADIOCLI_MPV_PATH')}); + expect(resolveCommandDetails('mpv', {platform: 'linux', env: {RADIOCLI_MPV_PATH: 'mpv --script=untrusted'}, isRunnable: () => true}).path).toBeNull(); + }); + + it('rejects shell scripts as configured Windows player commands', () => { + expect(resolveCommandDetails('mpv', {platform: 'win32', env: {RADIOCLI_MPV_PATH: 'C:\\Tools\\mpv.cmd'}, isRunnable: () => true}).path).toBeNull(); + expect(resolveCommandDetails('mpv', {platform: 'win32', env: {RADIOCLI_MPV_PATH: 'D:\\Tools 日本\\mpv.exe'}, isRunnable: () => true})).toMatchObject({path: 'D:\\Tools 日本\\mpv.exe', discovery: 'configured-path'}); + }); + + it('does not call an executable directory a runnable file', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); + roots.push(root); + const directory = join(root, 'mpv'); + mkdirSync(directory); + expect(resolveCommandDetails(directory).path).toBeNull(); + }); + + it.skipIf(process.platform === 'win32')('honors POSIX executable permission and environment changes without a stale cache', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); + roots.push(root); + const path = join(root, 'player 日本'); + writeFileSync(path, '#!/bin/sh\nexit 0\n', {mode: 0o600}); + vi.stubEnv('RADIOCLI_MPV_PATH', path); + expect(resolveCommandDetails('mpv').path).toBeNull(); + chmodSync(path, 0o700); + clearCommandCache(); + expect(resolveCommandDetails('mpv').path).toBe(path); + vi.stubEnv('RADIOCLI_MPV_PATH', `${path}-missing`); + expect(resolveCommandDetails('mpv').path).toBeNull(); + }); +}); diff --git a/src/platform/executables.ts b/src/platform/executables.ts index 4843b17..636d176 100644 --- a/src/platform/executables.ts +++ b/src/platform/executables.ts @@ -1,13 +1,14 @@ import {spawnSync} from 'node:child_process'; -import {accessSync, constants, existsSync} from 'node:fs'; +import {accessSync, constants, statSync} from 'node:fs'; import {homedir} from 'node:os'; import {posix, win32} from 'node:path'; -export type CommandDiscovery = 'path' | 'package-manager-shim' | 'application-directory' | 'windows-registry' | 'missing'; +export type CommandDiscovery = 'path' | 'configured-path' | 'package-manager-shim' | 'application-directory' | 'windows-registry' | 'missing'; export type CommandResolution = { path: string | null; discovery: CommandDiscovery; + error?: string; }; type CommandResolutionOptions = { @@ -33,8 +34,10 @@ export function resolveCommandDetails( command: string, overrides?: Partial ): CommandResolution { + const variable = overrideVariable(command); + const cacheKey = [command, process.env.PATH, process.env.PATHEXT, variable ? process.env[variable] : undefined].join('\0'); if (!overrides) { - const cached = commandCache.get(command); + const cached = commandCache.get(cacheKey); if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs) return cached.resolution; } @@ -48,11 +51,24 @@ export function resolveCommandDetails( }; const resolution = resolveUncached(command, options); - if (!overrides) commandCache.set(command, {resolution, checkedAt: Date.now()}); + if (!overrides) { + if (commandCache.size >= 256) commandCache.clear(); + commandCache.set(cacheKey, {resolution, checkedAt: Date.now()}); + } return resolution; } function resolveUncached(command: string, options: CommandResolutionOptions): CommandResolution { + const variable = overrideVariable(command); + const configured = variable ? options.env[variable] : undefined; + if (configured !== undefined) { + const pathApi = options.platform === 'win32' ? win32 : posix; + if (!configured || /[\0\r\n]/.test(configured) || !pathApi.isAbsolute(configured) || (options.platform === 'win32' && !/\.(exe|com)$/i.test(configured))) { + return {path: null, discovery: 'configured-path', error: `${variable} must be an absolute native executable path, without shell arguments.`}; + } + return options.isRunnable(configured) ? {path: configured, discovery: 'configured-path'} + : {path: null, discovery: 'configured-path', error: `${variable} does not name a runnable file.`}; + } const pathMatch = lookupOnPath(command, options); if (pathMatch) return {path: pathMatch, discovery: 'path'}; @@ -70,6 +86,10 @@ function resolveUncached(command: string, options: CommandResolutionOptions): Co return {path: null, discovery: 'missing'}; } +function overrideVariable(command: string): string | undefined { + return ({mpv: 'RADIOCLI_MPV_PATH', ffplay: 'RADIOCLI_FFPLAY_PATH', vlc: 'RADIOCLI_VLC_PATH', cvlc: 'RADIOCLI_VLC_PATH', ffmpeg: 'RADIOCLI_FFMPEG_PATH'} as Record)[command]; +} + function lookupOnPath(command: string, options: CommandResolutionOptions): string | null { if (command.includes('/') || command.includes('\\')) return options.isRunnable(command) ? command : null; @@ -112,10 +132,9 @@ export function clearCommandCache(): void { } function isRunnable(path: string, platform: NodeJS.Platform): boolean { - if (!existsSync(path)) return false; - if (platform === 'win32') return true; try { - accessSync(path, constants.X_OK); + if (!statSync(path).isFile()) return false; + if (platform !== 'win32') accessSync(path, constants.X_OK); return true; } catch { return false; diff --git a/src/platform/ipc.test.ts b/src/platform/ipc.test.ts new file mode 100644 index 0000000..3ec797c --- /dev/null +++ b/src/platform/ipc.test.ts @@ -0,0 +1,17 @@ +import {describe, expect, it} from 'vitest'; +import {mpvIpcPath} from './ipc.js'; + +describe('native mpv IPC endpoints', () => { + it('uses the Windows pipe namespace and does not interpret the temp directory', () => { + expect(mpvIpcPath({platform: 'win32', pid: 42, timestamp: 123, directory: 'D:\\Long temp 日本'})).toBe('\\\\.\\pipe\\radiocli-42-123'); + }); + it.each(['linux', 'darwin', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'])( + 'keeps %s on a Unix socket with spaces and non-ASCII preserved', platform => { + expect(mpvIpcPath({platform, pid: 42, timestamp: 123, directory: '/tmp/日本 space'})).toBe('/tmp/日本 space/radiocli-42-123.sock'); + } + ); + it('checks the portable socket limit in bytes, before a player is launched', () => { + expect(() => mpvIpcPath({platform: 'freebsd', directory: `/tmp/${'é'.repeat(42)}`, pid: 42, timestamp: 123})).toThrow(/socket.*shorter.*TMPDIR/i); + expect(() => mpvIpcPath({platform: 'linux', directory: `/tmp/${'a'.repeat(150)}`})).toThrow(/socket/i); + }); +}); diff --git a/src/platform/ipc.ts b/src/platform/ipc.ts new file mode 100644 index 0000000..56c62e4 --- /dev/null +++ b/src/platform/ipc.ts @@ -0,0 +1,16 @@ +import {tmpdir} from 'node:os'; +import {posix} from 'node:path'; +import {identifyPlatform, nativeAdapters} from './runtime.js'; + +type IpcOptions = {platform?: string; directory?: string; pid?: number; timestamp?: number}; + +export function mpvIpcPath({platform = process.platform, directory = tmpdir(), pid = process.pid, timestamp = Date.now()}: IpcOptions = {}): string { + if (nativeAdapters(identifyPlatform({platform})).ipc === 'named-pipe') return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`; + const path = posix.join(directory, `radiocli-${pid}-${timestamp}.sock`); + // BSD/macOS sockaddr_un is smaller than Linux's. Count encoded bytes and + // leave room for the terminator; JavaScript string length is not sufficient. + if (Buffer.byteLength(path) > 100) { + throw new Error('The mpv socket path is too long. Select a shorter private TMPDIR and restart RadioCLI.'); + } + return path; +} diff --git a/src/player/command-diagnostics.ts b/src/player/command-diagnostics.ts index 4e0ff10..676f227 100644 --- a/src/player/command-diagnostics.ts +++ b/src/player/command-diagnostics.ts @@ -23,7 +23,7 @@ type CommandExecutor = ( export function diagnoseCommand(command: string, overrides: Partial = {}): CommandDiagnostic { const resolution = (overrides.resolve ?? resolveCommandDetails)(command); if (!resolution.path) { - return {...resolution, launchable: false, version: null, error: 'not found'}; + return {...resolution, launchable: false, version: null, error: resolution.error ?? 'not found'}; } const execute: CommandExecutor = overrides.execute ?? ((path, args, options) => spawnSync(path, args, options)); diff --git a/src/player/mpv-ipc-client.test.ts b/src/player/mpv-ipc-client.test.ts index 56c5ce0..7fa6240 100644 --- a/src/player/mpv-ipc-client.test.ts +++ b/src/player/mpv-ipc-client.test.ts @@ -12,6 +12,26 @@ afterEach(async () => { }); describe('MpvIpcClient', () => { + it('preserves UTF-8 metadata across native socket packets', async () => { + const path = testIpcPath(); + const sockets = new Set(); + const server = createServer(socket => { + sockets.add(socket); + socket.on('error', () => undefined); + socket.once('data', request => { + const {request_id} = JSON.parse(String(request)) as {request_id: number}; + const response = Buffer.from(`${JSON.stringify({request_id, error: 'success', data: '日本 Café'})}\n`); + const cut = response.indexOf(Buffer.from('日')) + 1; + socket.write(response.subarray(0, cut)); + setTimeout(() => { if (!socket.destroyed) socket.write(response.subarray(cut)); }, 20); + }); + }); + await listen(server, path); + const client = new MpvIpcClient(path, 500); + cleanupTasks.push(() => closeTestIpc(client, server, sockets, path)); + await expect(client.query({command: ['get_property', 'media-title']})).resolves.toBe('日本 Café'); + }); + it('multiplexes concurrent requests over one socket and reconnects after a drop', async () => { const path = testIpcPath(); const sockets = new Set(); diff --git a/src/player/mpv-ipc-client.ts b/src/player/mpv-ipc-client.ts index f007331..dee76d0 100644 --- a/src/player/mpv-ipc-client.ts +++ b/src/player/mpv-ipc-client.ts @@ -122,7 +122,8 @@ export class MpvIpcClient { this.socket = socket; this.connectingSocket = null; this.buffer = ''; - socket.on('data', chunk => this.consume(chunk.toString('utf8'))); + socket.setEncoding('utf8'); + socket.on('data', chunk => this.consume(String(chunk))); socket.on('error', error => this.dropSocket(socket, error)); socket.on('close', () => this.dropSocket(socket, new Error('mpv IPC connection closed.'))); resolve(socket); diff --git a/src/player/player-controller.ts b/src/player/player-controller.ts index 24600e9..d759197 100644 --- a/src/player/player-controller.ts +++ b/src/player/player-controller.ts @@ -1,6 +1,5 @@ import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; import {existsSync, unlinkSync} from 'node:fs'; -import {tmpdir} from 'node:os'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {AirPlayDevice, AppSettings, IcyNowPlaying, PlaybackDiagnostics, PlaybackState, Station} from '../types.js'; @@ -11,6 +10,7 @@ import {airPlaySenderHealth} from './airplay-sender-health.js'; import {encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage, type AirPlayWorkerCommand, type AirPlayWorkerEvent} from './airplay-worker-protocol.js'; import {safeMediaTarget, sanitizeTerminalText} from '../safety.js'; import {MpvIpcClient} from './mpv-ipc-client.js'; +import {mpvIpcPath} from '../platform/ipc.js'; export type PlayerEvent = (state: PlaybackState) => void; export type MetadataEvent = (metadata: IcyNowPlaying) => void; @@ -1045,11 +1045,7 @@ export function createMpvIpcPath( pid = process.pid, timestamp = Date.now() ): string { - if (platform === 'win32') { - return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`; - } - - return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`); + return mpvIpcPath({platform, pid, timestamp}); } function airPlayWorkerPath(): string { From 55c15ccd4505ff241260d4244fd4f116ef07589e Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:52:11 -0500 Subject: [PATCH 05/25] fix: preserve library data across permission and write failures --- src/cli.test.ts | 15 ++- src/cli.tsx | 5 +- src/platform/storage.test.ts | 92 +++++++++++++ src/platform/storage.ts | 46 +++++++ src/providers/cache.ts | 2 +- src/storage/store.test.ts | 243 ++++++++++++++++++++++++++++++++++- src/storage/store.ts | 98 +++++++++++--- 7 files changed, 474 insertions(+), 27 deletions(-) create mode 100644 src/platform/storage.test.ts create mode 100644 src/platform/storage.ts diff --git a/src/cli.test.ts b/src/cli.test.ts index f85503c..fb2b20a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -104,7 +104,7 @@ describe('CLI command dispatch', () => { expect(report.guidance).toContain('playback=missing'); expect(report.host).toMatchObject({id: expect.any(String), arch: process.arch}); expect(report.capabilities.playback?.status).toBe('unavailable'); - expect(report.capabilities.storage?.status).toBe('unverified'); + expect(report.capabilities.storage?.status).toBe('available'); expect(report.capabilities).toHaveProperty('backgroundScheduling'); expect(report.capabilities).toHaveProperty('terminalReopening'); expect(report.capabilities).toHaveProperty('screenReader'); @@ -112,6 +112,19 @@ describe('CLI command dispatch', () => { expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); }); + it('diagnoses an inaccessible data destination without creating or replacing it', async () => { + const blockedHome = join(radioCliHome, 'not-a-directory'); + writeFileSync(blockedHome, 'preserve me'); + process.env.RADIOCLI_HOME = blockedHome; + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.storage.status).toBe('unavailable'); + expect(report.capabilities.atomicWrites.status).toBe('unavailable'); + expect(report.capabilities.storage.message).toContain('RADIOCLI_HOME'); + expect(JSON.stringify(report)).not.toContain(blockedHome); + expect(readFileSync(blockedHome, 'utf8')).toBe('preserve me'); + }); + it('rejects unknown commands with the help hint', async () => { await expect(runCommand(['wat'])).rejects.toThrow('Unknown command: wat\nRun radiocli help.'); }); diff --git a/src/cli.tsx b/src/cli.tsx index 4d56fef..d875eba 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -23,6 +23,8 @@ import {identifyPlatform} from './platform/runtime.js'; import {platformCapabilities} from './platform/capabilities.js'; import {hasGraphicalSession} from './platform/desktop.js'; import {detectPackageManager} from './platform/packages.js'; +import {platformPaths} from './platform/paths.js'; +import {storageReadiness} from './platform/storage.js'; const runtime = {nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url)}; @@ -300,7 +302,8 @@ function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic) { backends, commands: integrationCommands, graphicalSession: hasGraphicalSession(host), - packageManager: detectPackageManager(process.platform, host.osRelease) + packageManager: detectPackageManager(process.platform, host.osRelease), + storageWritable: storageReadiness(platformPaths().library).status === 'available' }); return { radioCliVersion: appVersion(), diff --git a/src/platform/storage.test.ts b/src/platform/storage.test.ts new file mode 100644 index 0000000..8bb342d --- /dev/null +++ b/src/platform/storage.test.ts @@ -0,0 +1,92 @@ +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it} from 'vitest'; +import {storageReadiness} from './storage.js'; + +const roots: string[] = []; +// Windows ACLs and a privileged POSIX user do not enforce these chmod fixtures. +const nativePermissions = process.platform !== 'win32' && process.getuid?.() !== 0; + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'radiocli storage café ')); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); + +describe('storageReadiness', () => { + it('checks the nearest existing parent without creating directories or files', () => { + const root = fixture(); + const nested = join(root, 'new data', '音楽'); + + expect(storageReadiness(join(nested, 'library.json')).status).toBe('available'); + expect(existsSync(nested)).toBe(false); + expect(readdirSync(root)).toEqual([]); + }); + + it('reports a file in the parent chain without exposing its path', () => { + const root = fixture(); + const obstacle = join(root, 'private account'); + writeFileSync(obstacle, 'not a directory'); + + const result = storageReadiness(join(obstacle, 'new', 'library.json')); + expect(result.status).toBe('unavailable'); + expect(result.message).toMatch(/RADIOCLI_HOME.*writable/i); + expect(result.message).not.toContain(root); + expect(readdirSync(root)).toEqual(['private account']); + }); + + it('does not consider an existing directory to be a writable library file', () => { + const root = fixture(); + const file = join(root, 'library.json'); + mkdirSync(file); + + expect(storageReadiness(file).status).toBe('unavailable'); + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it.skipIf(!nativePermissions)('reports a read-only nearest parent without creating a write probe', () => { + const root = fixture(); + chmodSync(root, 0o500); + try { + const result = storageReadiness(join(root, 'new data', 'library.json')); + expect(result.status).toBe('unavailable'); + expect(result.message).toMatch(/RADIOCLI_HOME.*writable/i); + expect(result.message).not.toContain(root); + expect(readdirSync(root)).toEqual([]); + } finally { + chmodSync(root, 0o700); + } + }); + + it.skipIf(!nativePermissions)('reports a read-only file even when its parent is writable', () => { + const root = fixture(); + const file = join(root, 'library.json'); + writeFileSync(file, '{}'); + chmodSync(file, 0o400); + try { + expect(storageReadiness(file).status).toBe('unavailable'); + expect(readdirSync(root)).toEqual(['library.json']); + } finally { + chmodSync(file, 0o600); + } + }); + + it.skipIf(!nativePermissions)('reports inaccessible ancestors without treating the destination as missing', () => { + const root = fixture(); + const parent = join(root, 'private'); + mkdirSync(parent); + chmodSync(parent, 0); + try { + const result = storageReadiness(join(parent, 'new', 'library.json')); + expect(result.status).toBe('unavailable'); + expect(result.message).not.toContain(root); + } finally { + chmodSync(parent, 0o700); + } + }); +}); diff --git a/src/platform/storage.ts b/src/platform/storage.ts new file mode 100644 index 0000000..245a0b8 --- /dev/null +++ b/src/platform/storage.ts @@ -0,0 +1,46 @@ +import {accessSync, constants, statSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; + +/** Access checks are advisory: ACLs, mounts, and permissions can change before a write. */ +export function storageReadiness(filePath: string): {status: 'available' | 'unavailable'; message: string} { + try { + assertStorageWritable(filePath); + return { + status: 'available', + message: 'Filesystem access checks passed for the library location; each operation still verifies its write.' + }; + } catch { + return { + status: 'unavailable', + message: 'The library location is not accessible for writes. Check permissions or set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.' + }; + } +} + +/** Does not create a probe file or missing directories. The actual write remains authoritative. */ +export function assertStorageWritable(filePath: string): void { + try { + if (!statSync(filePath).isFile()) { + throw Object.assign(new Error('The library destination is not a regular file.'), {code: 'EINVAL'}); + } + // An atomic rename could otherwise replace a deliberately read-only file on POSIX. + accessSync(filePath, constants.R_OK | constants.W_OK); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + let parent = dirname(resolve(filePath)); + while (true) { + try { + if (!statSync(parent).isDirectory()) { + throw Object.assign(new Error('The library parent is not a directory.'), {code: 'ENOTDIR'}); + } + accessSync(parent, constants.W_OK | constants.X_OK); + return; + } catch (error) { + const ancestor = dirname(parent); + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || ancestor === parent) throw error; + parent = ancestor; + } + } +} diff --git a/src/providers/cache.ts b/src/providers/cache.ts index 17b0b66..e2788aa 100644 --- a/src/providers/cache.ts +++ b/src/providers/cache.ts @@ -134,7 +134,7 @@ function writeJsonAtomically(filePath: string, value: unknown): void { } } -export function backupBadFile(filePath: string): void { +function backupBadFile(filePath: string): void { if (!existsSync(filePath)) { return; } diff --git a/src/storage/store.test.ts b/src/storage/store.test.ts index 2b940c0..15aeb17 100644 --- a/src/storage/store.test.ts +++ b/src/storage/store.test.ts @@ -1,17 +1,25 @@ -import {existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import * as fs from 'node:fs'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; -import {afterEach, describe, expect, it} from 'vitest'; +import {afterEach, describe, expect, it, vi} from 'vitest'; import {JsonLibraryStore} from './store.js'; +import * as paths from '../platform/paths.js'; import {AlarmPowerGuardStore} from '../alarms/power-guard-store.js'; import {defaultReceiverStyle, receiverStyleNames, themeNames, type AlarmCreateInput, type Station} from '../types.js'; const roots: string[] = []; const originalRadioCliHome = process.env.RADIOCLI_HOME; const originalRadioAtlasHome = process.env.RADIO_ATLAS_HOME; +// Windows ACLs and a privileged POSIX user do not enforce these chmod fixtures. +const nativePermissions = process.platform !== 'win32' && process.getuid?.() !== 0; + +vi.mock('node:fs', async importOriginal => ({...await importOriginal()})); +vi.mock('../platform/paths.js', async importOriginal => ({...await importOriginal()})); describe('JsonLibraryStore', () => { afterEach(() => { + vi.restoreAllMocks(); for (const root of roots.splice(0)) { rmSync(root, {recursive: true, force: true}); } @@ -522,7 +530,236 @@ describe('JsonLibraryStore', () => { expect(store.snapshot().settings.theme).toBe('green'); expect(store.snapshot().settings.receiverStyle).toBe(defaultReceiverStyle); expect(store.snapshot().activity.sessions).toEqual([]); - expect(readdirSync(root).some(name => name.startsWith('library.json.bad-'))).toBe(true); + const backup = readdirSync(root).find(name => name.startsWith('library.json.bad-')); + expect(backup).toBeDefined(); + expect(readFileSync(join(root, backup!), 'utf8')).toBe('{not json'); + }); + + it.skipIf(!nativePermissions)('keeps a readable library usable in a read-only directory and preserves it after a failed commit', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli library café ')); + roots.push(root); + const file = join(root, '音楽 library.json'); + const original = JSON.stringify({settings: {theme: 'amber'}}); + writeFileSync(file, original); + chmodSync(root, 0o500); + try { + const store = new JsonLibraryStore(file); + const before = store.snapshot(); + expect(before.settings.theme).toBe('amber'); + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(/RADIOCLI_HOME.*writable/i); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + expect(readdirSync(root)).toEqual(['音楽 library.json']); + } finally { + chmodSync(root, 0o700); + } + }); + + it.skipIf(!nativePermissions)('does not replace a read-only library through an otherwise writable directory', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli readonly café ')); + roots.push(root); + const file = join(root, 'library.json'); + const original = JSON.stringify({settings: {theme: 'amber'}}); + writeFileSync(file, original); + chmodSync(file, 0o400); + try { + const store = new JsonLibraryStore(file); + const before = store.snapshot(); + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(/RADIOCLI_HOME.*writable/i); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + expect(readdirSync(root)).toEqual(['library.json']); + } finally { + chmodSync(file, 0o600); + } + }); + + it.skipIf(!nativePermissions)('does not rename an unreadable intact library as corrupt or return empty defaults', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli unreadable café ')); + roots.push(root); + const file = join(root, 'private library.json'); + const original = JSON.stringify({settings: {theme: 'amber'}}); + writeFileSync(file, original); + chmodSync(file, 0); + try { + let failure: unknown; + try { new JsonLibraryStore(file); } catch (error) { failure = error; } + expect(failure).toMatchObject({code: 'EACCES', cause: {code: 'EACCES'}}); + expect((failure as Error).message).toMatch(/read.*RADIOCLI_HOME.*writable/i); + expect((failure as Error).message).not.toContain(root); + expect(readdirSync(root)).toEqual(['private library.json']); + } finally { + chmodSync(file, 0o600); + } + expect(readFileSync(file, 'utf8')).toBe(original); + }); + + it.skipIf(!nativePermissions)('does not interpret an inaccessible ancestor as an absent library', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli inaccessible café ')); + roots.push(root); + const parent = join(root, 'private'); + const file = join(parent, 'library.json'); + mkdirSync(parent); + writeFileSync(file, '{"settings":{"theme":"amber"}}'); + chmodSync(parent, 0); + try { + expect(() => new JsonLibraryStore(file)).toThrow(/read.*RADIOCLI_HOME.*writable/i); + } finally { + chmodSync(parent, 0o700); + } + expect(readFileSync(file, 'utf8')).toBe('{"settings":{"theme":"amber"}}'); + expect(readdirSync(parent)).toEqual(['library.json']); + }); + + it.skipIf(!nativePermissions)('does not switch to an older library when the preferred directory is inaccessible', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli migration café ')); + roots.push(root); + const parent = join(root, 'current data'); + const file = join(parent, 'library.json'); + const legacy = join(root, 'legacy.json'); + mkdirSync(parent); + writeFileSync(file, '{"settings":{"theme":"ruby"}}'); + writeFileSync(legacy, '{"settings":{"theme":"amber"}}'); + vi.spyOn(paths, 'platformPaths').mockReturnValue({...paths.platformPaths(), library: file, legacyLibrary: legacy}); + chmodSync(parent, 0); + try { + expect(() => new JsonLibraryStore()).toThrow(/read.*RADIOCLI_HOME.*writable/i); + } finally { + chmodSync(parent, 0o700); + } + expect(readFileSync(file, 'utf8')).toBe('{"settings":{"theme":"ruby"}}'); + expect(readFileSync(legacy, 'utf8')).toBe('{"settings":{"theme":"amber"}}'); + }); + + it.skipIf(!nativePermissions)('preserves corrupt data when a backup cannot be written', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli corrupt café ')); + roots.push(root); + const file = join(root, 'library.json'); + writeFileSync(file, '{not json'); + chmodSync(root, 0o500); + try { + expect(() => new JsonLibraryStore(file)).toThrow(/corrupt.*RADIOCLI_HOME.*writable/i); + expect(readFileSync(file, 'utf8')).toBe('{not json'); + expect(readdirSync(root)).toEqual(['library.json']); + } finally { + chmodSync(root, 0o700); + } + }); + + it('retains I/O failure details without classifying the library as corrupt', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + writeFileSync(file, '{"settings":{"theme":"amber"}}'); + const failure = Object.assign(new Error(`disk failure at ${file}`), {code: 'EIO'}); + vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { throw failure; }); + + let reported: unknown; + try { new JsonLibraryStore(file); } catch (error) { reported = error; } + expect(reported).toMatchObject({code: 'EIO', cause: failure}); + expect((reported as Error).message).not.toContain(file); + expect(readFileSync(file, 'utf8')).toBe('{"settings":{"theme":"amber"}}'); + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it('preserves committed data and the original write failure when temporary and lock cleanup also fail', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + const failure = Object.assign(new Error(`rename failed at ${file}`), {code: 'ENOSPC'}); + const cleanup = Object.assign(new Error('cleanup failed'), {code: 'EACCES'}); + vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => { throw failure; }); + vi.spyOn(fs, 'rmSync').mockImplementation(() => { throw cleanup; }); + + let reported: unknown; + try { store.updateSettings({theme: 'ruby'}); } catch (error) { reported = error; } + expect(reported).toMatchObject({code: 'ENOSPC', cause: failure}); + expect((reported as Error).message).toMatch(/RADIOCLI_HOME.*writable/i); + expect((reported as Error).message).not.toContain(file); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + }); + + it('reports a busy library without revealing the private path or changing state', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli busy café ')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + mkdirSync(`${file}.lock`); + + let reported: unknown; + try { store.updateSettings({theme: 'ruby'}); } catch (error) { reported = error; } + expect((reported as Error).message).toMatch(/busy/i); + expect((reported as Error).message).not.toContain(root); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + }); + + it('preserves the library if setting private temporary-file permissions fails', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + const failure = Object.assign(new Error('chmod failed'), {code: 'EPERM'}); + const chmod = vi.spyOn(fs, 'chmodSync').mockImplementationOnce(() => { throw failure; }); + + if (process.platform === 'win32') { + store.updateSettings({theme: 'ruby'}); + expect(chmod).not.toHaveBeenCalled(); + expect(new JsonLibraryStore(file).snapshot().settings.theme).toBe('ruby'); + } else { + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(expect.objectContaining({code: 'EPERM', cause: failure})); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + } + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it('keeps the saved state accurate if lock release fails after a successful replacement', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + vi.spyOn(fs, 'rmSync').mockImplementationOnce(() => { throw Object.assign(new Error('release failed'), {code: 'EACCES'}); }); + + expect(store.updateSettings({theme: 'ruby'}).settings.theme).toBe('ruby'); + expect(new JsonLibraryStore(file).snapshot().settings.theme).toBe('ruby'); + }); + + it('reports lock-inspection failures without retrying them as a missing lock', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + const lockPath = `${file}.lock`; + mkdirSync(lockPath); + const failure = Object.assign(new Error(`lock inspection failed at ${lockPath}`), {code: 'EIO'}); + const stat = fs.statSync; + let inspected = false; + vi.spyOn(fs, 'statSync').mockImplementation(((...args: Parameters) => { + if (args[0] === lockPath && !inspected) { + inspected = true; + throw failure; + } + return stat(...args); + }) as typeof fs.statSync); + + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(expect.objectContaining({code: 'EIO', cause: failure})); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); }); it.each(['scope', 'spectrum', 'oscilloscope', 'motion-bars', 'radar', 'dual-ripple', 'perspective-floor', 'bloom-bars', 'running-horse', 'starlink', 'tuning-dial', 'cassette', `${'term'}${'flix'}-plasma`, 'sierpinksi', 'voronoi', 'orbits', 'chromatic', 'ferro-crown', 'origami-tide', 'tv-static', 'sunspot', 'plasma', 'metaballs', 'motion-blob', 'clifford', 'mirror', 'paris', 'kyoto', 'sahara'])( diff --git a/src/storage/store.ts b/src/storage/store.ts index f71cc79..d499e93 100644 --- a/src/storage/store.ts +++ b/src/storage/store.ts @@ -1,4 +1,5 @@ import {platformPaths} from '../platform/paths.js'; +import {assertStorageWritable} from '../platform/storage.js'; import {chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; import {dirname, isAbsolute, join, resolve} from 'node:path'; @@ -20,7 +21,6 @@ import { type UpdateCheckState } from '../types.js'; import {canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone} from '../alarms/schedule.js'; -import {backupBadFile} from '../providers/cache.js'; import {migrateReceiverStyle} from '../ui/visualizers/receiver-style-registry.js'; const stationSchema: z.ZodType = z @@ -624,14 +624,24 @@ export class JsonLibraryStore { } private read(): LibraryState { - if (!existsSync(this.filePath)) { - return defaultState(); + let contents: string; + try { + contents = readFileSync(this.filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultState(); + throw storageError('read the RadioCLI library', error); } try { - return migrateLibraryState(librarySchema.parse(JSON.parse(readFileSync(this.filePath, 'utf8')))); + return migrateLibraryState(librarySchema.parse(JSON.parse(contents))); } catch { - backupBadFile(this.filePath); + try { + assertStorageWritable(this.filePath); + renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`); + } catch (error) { + // Reset only after preserving the original bytes. A failed read never reaches here. + throw storageError('preserve the corrupt RadioCLI library', error); + } return defaultState(); } } @@ -650,8 +660,13 @@ export class JsonLibraryStore { } private write(state: LibraryState): void { - mkdirSync(dirname(this.filePath), {recursive: true, mode: 0o700}); - writeJsonAtomically(this.filePath, libraryStateForDisk(state)); + try { + assertStorageWritable(this.filePath); + mkdirSync(dirname(this.filePath), {recursive: true, mode: 0o700}); + writeJsonAtomically(this.filePath, libraryStateForDisk(state)); + } catch (error) { + throw storageError('write the RadioCLI library', error); + } } } @@ -700,7 +715,18 @@ function recoverActiveListeningSessionInState(state: LibraryState): LibraryState function defaultStorePath(): string { const {library, legacyLibrary} = platformPaths(); - return existsSync(library) || !existsSync(legacyLibrary) ? library : legacyLibrary; + for (const filePath of [library, legacyLibrary]) { + try { + statSync(filePath); + return filePath; + } catch (error) { + // A permission failure must not select an older library or empty defaults. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw storageError('read the RadioCLI library', error); + } + } + } + return library; } function resolveUserPath(requestedPath: string): string { @@ -781,46 +807,76 @@ function libraryStateForDisk(state: LibraryState): LibraryState { } function writeJsonAtomically(filePath: string, value: unknown): void { - const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`; try { - writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); - renameSync(tempPath, filePath); + writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); if (process.platform !== 'win32') { - chmodSync(filePath, 0o600); + chmodSync(tempPath, 0o600); } + // No fallible persistence steps follow the atomic replacement. + renameSync(tempPath, filePath); } catch (error) { - rmSync(tempPath, {force: true}); - throw error; + try { rmSync(tempPath, {force: true}); } catch { /* Preserve the original write failure. */ } + throw storageError('write RadioCLI data', error); } } function acquireStoreLock(filePath: string): () => void { const lockPath = `${filePath}.lock`; - mkdirSync(dirname(filePath), {recursive: true, mode: 0o700}); + try { + assertStorageWritable(filePath); + mkdirSync(dirname(filePath), {recursive: true, mode: 0o700}); + } catch (error) { + throw storageError('write the RadioCLI library', error); + } const deadline = Date.now() + 1000; while (true) { try { mkdirSync(lockPath, {mode: 0o700}); - return () => rmSync(lockPath, {recursive: true, force: true}); + return () => { + // The write has already succeeded or failed. Lock cleanup cannot reverse that result. + try { rmSync(lockPath, {recursive: true, force: true}); } catch { /* Stale locks expire below. */ } + }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== 'EEXIST') throw error; + if (code !== 'EEXIST') throw storageError('lock the RadioCLI library', error); try { if (Date.now() - statSync(lockPath).mtimeMs > 10_000) { rmSync(lockPath, {recursive: true, force: true}); - continue; } - } catch { - continue; + } catch (lockError) { + if ((lockError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw storageError('lock the RadioCLI library', lockError); + } } if (Date.now() >= deadline) { - throw new Error(`RadioCLI library is busy: ${filePath}`); + throw storageError('lock the RadioCLI library', Object.assign(new Error('Another writer holds the library lock.'), {code: 'EBUSY', cause: error})); } Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); } } } +class LibraryStorageError extends Error { + readonly code?: string; + readonly errno?: number; + + constructor(operation: string, cause: unknown) { + const code = (cause as NodeJS.ErrnoException | undefined)?.code; + const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : ''; + super(code === 'EBUSY' + ? 'The RadioCLI library is busy. Retry after the other writer finishes, or set RADIOCLI_HOME to a private writable directory.' + : `Unable to ${operation}${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`, {cause}); + this.name = 'LibraryStorageError'; + this.code = code; + this.errno = (cause as NodeJS.ErrnoException | undefined)?.errno; + } +} + +function storageError(operation: string, cause: unknown): LibraryStorageError { + return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause); +} + function mergeLibraryChanges(base: LibraryState, disk: LibraryState, next: LibraryState): LibraryState { return { recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50), From 6754cd23f993af610df32052a8b902bc9ad134f3 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:05:16 -0500 Subject: [PATCH 06/25] fix: keep playback usable when library writes fail --- src/cli.test.ts | 12 ++ src/cli.tsx | 10 +- src/storage/store.ts | 4 +- src/ui/App.tsx | 142 ++++++++------ src/ui/alarm-tui-integration.test.tsx | 256 +++++++++++++++++++++++++- src/ui/use-app-input.ts | 47 +++-- src/ui/use-command-executor.ts | 5 +- 7 files changed, 397 insertions(+), 79 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index fb2b20a..8729dfa 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -7,6 +7,7 @@ import {isDirectRun, runCommand} from './cli.js'; import {detectPlaybackBackends} from './player/backend-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; +import * as platformPaths from './platform/paths.js'; vi.mock('./player/backend-install.js', async importOriginal => { const actual = await importOriginal(); @@ -125,6 +126,17 @@ describe('CLI command dispatch', () => { expect(readFileSync(blockedHome, 'utf8')).toBe('preserve me'); }); + it('checks the selected legacy library location without migrating data during doctor', async () => { + const paths = platformPaths.platformPaths(); + const spy = vi.spyOn(platformPaths, 'platformPaths').mockReturnValue({...paths, library: join(radioCliHome, 'absent.json'), legacyLibrary: radioCliHome}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.storage.status).toBe('unavailable'); + expect(existsSync(join(radioCliHome, 'absent.json'))).toBe(false); + } finally { spy.mockRestore(); } + }); + it('rejects unknown commands with the help hint', async () => { await expect(runCommand(['wat'])).rejects.toThrow('Unknown command: wat\nRun radiocli help.'); }); diff --git a/src/cli.tsx b/src/cli.tsx index d875eba..2a3c36d 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -4,7 +4,7 @@ import {resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import {ProviderManager} from './providers/provider-manager.js'; import {PlayerController} from './player/player-controller.js'; -import {JsonLibraryStore} from './storage/store.js'; +import {defaultLibraryPath, JsonLibraryStore} from './storage/store.js'; import {parsePlaylistFile, stationFromUrl, writeM3u} from './playlists/playlist.js'; import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backend-install.js'; import {resolveCommand} from './player/command.js'; @@ -23,7 +23,6 @@ import {identifyPlatform} from './platform/runtime.js'; import {platformCapabilities} from './platform/capabilities.js'; import {hasGraphicalSession} from './platform/desktop.js'; import {detectPackageManager} from './platform/packages.js'; -import {platformPaths} from './platform/paths.js'; import {storageReadiness} from './platform/storage.js'; const runtime = {nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url)}; @@ -303,7 +302,7 @@ function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic) { commands: integrationCommands, graphicalSession: hasGraphicalSession(host), packageManager: detectPackageManager(process.platform, host.osRelease), - storageWritable: storageReadiness(platformPaths().library).status === 'available' + storageWritable: libraryWritesAvailable() }); return { radioCliVersion: appVersion(), @@ -327,6 +326,11 @@ function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic) { }; } +function libraryWritesAvailable(): boolean { + try { return storageReadiness(defaultLibraryPath()).status === 'available'; } + catch { return false; } +} + function printMpvDiagnostic(diagnostic: CommandDiagnostic): void { console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`); console.log(`mpv_discovery=${diagnostic.discovery}`); diff --git a/src/storage/store.ts b/src/storage/store.ts index d499e93..5afbfbe 100644 --- a/src/storage/store.ts +++ b/src/storage/store.ts @@ -283,7 +283,7 @@ export class JsonLibraryStore { private readonly now: () => Date; constructor( - filePath = defaultStorePath(), + filePath = defaultLibraryPath(), options: {idGenerator?: () => string; now?: () => Date} = {} ) { this.filePath = filePath; @@ -713,7 +713,7 @@ function recoverActiveListeningSessionInState(state: LibraryState): LibraryState return finishActiveListeningSessionInState(state, new Date(recoveredEnd)); } -function defaultStorePath(): string { +export function defaultLibraryPath(): string { const {library, legacyLibrary} = platformPaths(); for (const filePath of [library, legacyLibrary]) { try { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c2a6009..8ec43ef 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -22,6 +22,7 @@ import {isAirPlayCodePromptActive} from './screens/AirPlayCodeScreen.js'; import {isAirPlayBackendAvailable} from './airplay-settings.js'; import {audioOutputLabel, resolvedAudioOutput} from './audio-output.js'; import {copyToClipboard, openExternal} from './system-actions.js'; +import {safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText} from '../safety.js'; import {appVersion} from '../version.js'; import {automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall} from '../update-check.js'; import {helpItemCount} from './help-content.js'; @@ -116,6 +117,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const [selected, setSelected] = useState(0); const [settingsPage, setSettingsPage] = useState('root'); const [message, setMessage] = useState(null); + const [persistenceWarning, setPersistenceWarning] = useState(null); const [footerMessage, setFooterMessage] = useState(null); const [countries, setCountries] = useState([]); const [countryFilter, setCountryFilter] = useState(''); @@ -139,6 +141,22 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const [sleepUntil, setSleepUntil] = useState(null); const [showDiagnostics, setShowDiagnostics] = useState(false); const [capturingTransportAction, setCapturingTransportAction] = useState(null); + const reportActionError = useCallback((error: unknown) => { + setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.'); + }, []); + // Optional history cannot change the result of playback or navigation. Keep + // its warning until an explicit save succeeds; a no-op checkpoint proves nothing. + const persistLibrary = useCallback((write: () => LibraryState): LibraryState | undefined => { + try { + const nextLibrary = write(); + setLibrary(nextLibrary); + return nextLibrary; + } catch (error) { + const detail = sanitizeTerminalText(error instanceof Error ? error.message : String(error)); + setPersistenceWarning(`Library not saved. Set RADIOCLI_HOME to a writable directory.${detail ? ` ${detail}` : ''}`); + return undefined; + } + }, []); const announcedUpdateRef = useRef(false); const automaticUpdateCheckStartedRef = useRef(false); const installingUpdateRef = useRef(false); @@ -310,26 +328,26 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const key = stationKey(station); if (activeListeningStationRef.current !== key) { activeListeningStationRef.current = key; - setLibrary(store.startListeningSession(station)); + persistLibrary(() => store.startListeningSession(station)); } return; } if (activeListeningStationRef.current) { activeListeningStationRef.current = null; - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); } - }, [playback.ready, playback.state, playingStation, store]); + }, [persistLibrary, playback.ready, playback.state, playingStation, store]); useEffect(() => { if (playback.state !== 'playing' || !playback.ready || !playingStation) { return; } const timer = setInterval(() => { - setLibrary(store.checkpointActiveListeningSession()); + persistLibrary(() => store.checkpointActiveListeningSession()); }, LISTENING_HEARTBEAT_MS); return () => clearInterval(timer); - }, [playback.ready, playback.state, playingStation, store]); + }, [persistLibrary, playback.ready, playback.state, playingStation, store]); useEffect( () => @@ -340,10 +358,10 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const trackKey = `${stationKey(station)}:${metadata.title}`; if (lastRecordedTrackRef.current === trackKey) return; lastRecordedTrackRef.current = trackKey; - setLibrary(store.recordTrack(station, metadata.title)); + persistLibrary(() => store.recordTrack(station, metadata.title!)); } }), - [player, store] + [persistLibrary, player, store] ); useEffect(() => { @@ -427,20 +445,20 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const delayMs = sleepUntil - Date.now(); if (delayMs <= 0) { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); - void player.stop(); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError); setSleepUntil(null); return; } const timer = setTimeout(() => { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); - void player.stop(); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError); setSleepUntil(null); }, delayMs); return () => clearTimeout(timer); - }, [player, sleepUntil, store]); + }, [persistLibrary, player, reportActionError, sleepUntil, store]); useEffect(() => { const backends = player.refreshDetectedBackends(); @@ -461,9 +479,9 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const refreshUpdateCheck = useCallback(async () => { const updateCheck = await updateChecker({currentVersion: installedVersion}); - setLibrary(store.updateCheckState(updateCheck)); + persistLibrary(() => store.updateCheckState(updateCheck)); return updateCheck; - }, [installedVersion, store, updateChecker]); + }, [installedVersion, persistLibrary, store, updateChecker]); useEffect(() => { if ( @@ -484,12 +502,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe announcedUpdateRef.current = true; setMessage(`Update available: v${updateCheck.latestVersion} · run :update`); } + }).catch(error => { + if (!cancelled) reportActionError(error); }); return () => { cancelled = true; }; - }, [library.settings.automaticUpdateChecks, refreshUpdateCheck]); + }, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]); const setStationContextFor = useCallback((key: StationContextKey, context: StationContext) => { setStationContexts(current => ({...current, [key]: context})); @@ -507,6 +527,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const nextLibrary = store.updateSettings(settings); settingsRef.current = nextLibrary.settings; setLibrary(nextLibrary); + setPersistenceWarning(null); return nextLibrary; }, [store] @@ -523,9 +544,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return; } - updateSettings({preferredBackend}); - setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`); - }, [availableBackends, selectedAirPlayDevice, updateSettings]); + try { + updateSettings({preferredBackend}); + setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`); + } catch (error) { + reportActionError(error); + } + }, [availableBackends, reportActionError, selectedAirPlayDevice, updateSettings]); const updateMediaKeys = useCallback( (mediaKeys: AppSettings['mediaKeys']) => { @@ -617,14 +642,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const shutdown = useCallback(() => { tuneRequestRef.current += 1; - store.finishActiveListeningSession(); - player.stop().finally(exit); - }, [exit, player, store]); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError).finally(exit); + }, [exit, persistLibrary, player, reportActionError, store]); useEffect(() => { const finishForSignal = () => { - store.finishActiveListeningSession(); - void player.stop().finally(() => process.exit(0)); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError).finally(() => process.exit(0)); }; process.once('SIGTERM', finishForSignal); process.once('SIGHUP', finishForSignal); @@ -632,7 +657,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe process.off('SIGTERM', finishForSignal); process.off('SIGHUP', finishForSignal); }; - }, [player, store]); + }, [persistLibrary, player, reportActionError, store]); const showStationContext = useCallback( (context: StationContext, next: Screen = 'stations', options: NavigationOptions = {}) => { @@ -855,7 +880,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }); selectedByScreenRef.current.search = 0; lastSubmittedSearchRef.current = query.trim(); - setLibrary(store.addSearch(query)); + persistLibrary(() => store.addSearch(query)); searchHistoryRef.current = {cursor: -1, draft: ''}; setSelected(0); setEditingSearch(true); @@ -866,7 +891,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (requestId === searchRequestRef.current) setLoadingStations(false); } }, - [filters, providers, searchQuery, setStationContextFor, store] + [filters, persistLibrary, providers, searchQuery, setStationContextFor, store] ); const loadMoreSearchResults = useCallback(async () => { @@ -1088,8 +1113,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe setPlayingStation(station); setTuningStation(null); playbackQueueRef.current = queue; - const nextLibrary = store.addRecent(station); - if (screenRef.current === 'library') { + const nextLibrary = persistLibrary(() => store.addRecent(station)); + if (nextLibrary && screenRef.current === 'library') { const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters); const nextLibraryIndex = nextLibraryStations.findIndex(item => stationMatches(item, station)); if (nextLibraryIndex >= 0) { @@ -1098,7 +1123,6 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } } - setLibrary(nextLibrary); if (options.openNowPlaying) { go('now-playing'); } @@ -1125,7 +1149,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe setMessage(message); } }, - [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store] + [filters, go, persistLibrary, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store] ); // Resume the most recent station on launch when opted in, like a radio @@ -1188,6 +1212,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const wasFavorite = store.isFavorite(station); setLibrary(store.toggleFavorite(station)); + setPersistenceWarning(null); const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`; if (screenRef.current === 'library') { showTransientFooterMessage(favoriteMessage); @@ -1196,10 +1221,10 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } if (!wasFavorite && settingsRef.current.shareDirectoryVotes) { // Best-effort upvote back to the directory; never blocks favoriting. - void providers.vote(station); + void providers.vote(station).catch(reportActionError); } }, - [providers, showTransientFooterMessage, store] + [providers, reportActionError, showTransientFooterMessage, store] ); const showControlResult = useCallback((result: PlaybackControlResult) => { @@ -1214,10 +1239,16 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return; } + const url = safeExternalHttpUrl(station.homepage); + if (!url) { + setMessage('This station has no valid HTTP(S) homepage.'); + return; + } + setMessage( - await openExternal(station.homepage) + await openExternal(url) ? `Opening homepage: ${station.name}` - : `Could not open a browser in this session. Homepage: ${station.homepage}` + : `Could not open a browser in this session. Homepage: ${sanitizeTerminalText(url) ?? ''}` ); }, []); @@ -1233,13 +1264,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined); } - if (!url) { - setMessage(`No stream URL available for ${station.name}.`); + const safeUrl = url ? safeMediaTarget(url) : null; + if (!safeUrl) { + setMessage(`No safe stream URL available for ${station.name}.`); return; } - const copied = await copyToClipboard(url); - setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${url}`); + const copied = await copyToClipboard(safeUrl); + setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${sanitizeTerminalText(safeUrl) ?? ''}`); }, [providers] ); @@ -1269,9 +1301,9 @@ export function App({store: providedStore, providers: providedProviders, alarmSe updateSettings({volume: player.getState().volume}); } showControlResult(result); - }); + }).catch(reportActionError); }, - [player, showControlResult, updateSettings] + [player, reportActionError, showControlResult, updateSettings] ); const adjustVolume = useCallback( @@ -1283,18 +1315,18 @@ export function App({store: providedStore, providers: providedProviders, alarmSe updateSettings({volume: player.getState().volume}); } showControlResult(result); - }); + }).catch(reportActionError); }, - [player, showControlResult, updateSettings] + [player, reportActionError, showControlResult, updateSettings] ); const toggleMute = useCallback(() => { - void player.toggleMute().then(showControlResult); - }, [player, showControlResult]); + void player.toggleMute().then(showControlResult).catch(reportActionError); + }, [player, reportActionError, showControlResult]); const togglePause = useCallback(() => { - void player.togglePause().then(showControlResult); - }, [player, showControlResult]); + void player.togglePause().then(showControlResult).catch(reportActionError); + }, [player, reportActionError, showControlResult]); const showTransientMessage = useCallback((nextMessage: string) => { if (transientMessageTimerRef.current) { @@ -1682,6 +1714,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe loadCountry, openAirPlaySettings, openLibrary, + persistLibrary, player, playingStation, providers, @@ -1766,7 +1799,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (command.type === 'resume') { const control = await player.resume(); return respond(control.message ?? 'Resumed.', control.ok); } if (command.type === 'stop') { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); playingStationRef.current = null; setPlayingStation(null); @@ -1776,7 +1809,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } if (command.type === 'alarm-preempt') { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); playingStationRef.current = null; setPlayingStation(null); @@ -1991,7 +2024,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const hasActiveMicroPlayback = Boolean( footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused') ); - const microFooter = footerMessage ?? message ?? ( + const statusMessage = message ?? persistenceWarning; + const microFooter = footerMessage ?? statusMessage ?? ( pageFooterOwnsCompactRow ? pageFooter : footerPlayback.state === 'loading' && playbackFooter @@ -2002,13 +2036,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ); const compactGlobalFooter = '←/→ tabs · ? help · q quit'; const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2; - const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter); + const fullStatusRows = fullStatusFooterRows(screen, statusMessage, footerMessage, playbackFooter); const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve); - const compactStatus = footerMessage ?? message ?? playbackFooter; + const compactStatus = footerMessage ?? statusMessage ?? playbackFooter; const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0)); const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve); const microLegendWidth = Math.max(1,frameWidth-versionReserve); - const microLegend = commandMode || capturingTransportAction || footerMessage || message + const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage ? microFooter : microShortcutFooterText(microFooter, microLegendWidth); const footerText = (value: string): string => displayMode.ascii ? toAsciiSafe(value) : value; @@ -2090,10 +2124,10 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ) : ( <> - {layout.mode === 'micro' ? + {layout.mode === 'micro' ? {footerText(truncate(microLegend, microLegendWidth))} : <> - {compactStatus ? {footerText(truncate(compactStatus, frameWidth))} : null} + {compactStatus ? {footerText(truncate(compactStatus, frameWidth))} : null} {compactLegendRows.map((row, index) => index===compactLegendRows.length-1?{footerText(row)}:{footerText(row)})} } diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index 7fc82f7..a594e8d 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -1,17 +1,19 @@ import {act} from 'react'; -import {mkdtempSync, rmSync} from 'node:fs'; +import {chmodSync, mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {join} from 'node:path'; import {tmpdir} from 'node:os'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, render as inkRender} from 'ink-testing-library'; import {ProviderManager} from '../providers/provider-manager.js'; +import {PlayerController} from '../player/player-controller.js'; import * as backendInstall from '../player/backend-install.js'; import * as airplayDiscovery from '../player/airplay-discovery.js'; import * as updates from '../update-check.js'; import * as session from '../agent/session.js'; import * as presence from '../alarms/tui-presence.js'; +import * as systemActions from './system-actions.js'; import {JsonLibraryStore} from '../storage/store.js'; -import type {Alarm, Station} from '../types.js'; +import type {Alarm, IcyNowPlaying, PlaybackState, Station} from '../types.js'; import type {AlarmTuiService, TuiActiveAlarm} from './alarm-tui-service.js'; import {App} from './App.js'; @@ -87,6 +89,256 @@ async function settle(elapsed = 0): Promise { } async function moveDown(app: ReturnType, count: number): Promise { for (let index = 0; index < count; index += 1) app.stdin.write('\u001B[B'); await settle(); } +function simulatedPlayer() { + let state: PlaybackState = {backend: 'mpv', state: 'idle', ready: false, volume: 70, muted: false}; + const listeners = new Set<(state: PlaybackState) => void>(); + const metadataListeners = new Set<(metadata: IcyNowPlaying) => void>(); + const change = (next: Partial) => { + state = {...state, ...next}; + for (const listener of listeners) listener({...state}); + }; + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...state})); + vi.spyOn(PlayerController.prototype, 'onChange').mockImplementation(listener => { + listeners.add(listener); + listener({...state}); + return () => { listeners.delete(listener); }; + }); + vi.spyOn(PlayerController.prototype, 'onMetadata').mockImplementation(listener => { + metadataListeners.add(listener); + return () => { metadataListeners.delete(listener); }; + }); + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (selected, url) => { + change({state: 'playing', ready: true, stationName: selected.name, streamUrl: url}); + }); + const stop = vi.spyOn(PlayerController.prototype, 'stop').mockImplementation(async () => { + change({state: 'idle', ready: false}); + }); + vi.spyOn(PlayerController.prototype, 'adjustVolume').mockImplementation(async delta => { + change({volume: state.volume + delta}); + return {ok: true}; + }); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async selected => ({url: selected.streamUrl ?? 'https://example.test/live'})); + return {play, stop, state: () => state, metadata: (metadata: IcyNowPlaying) => { + for (const listener of metadataListeners) listener(metadata); + }}; +} + +async function tuneLibrary(app: ReturnType): Promise { + app.stdin.write('2'); + await settle(); + app.stdin.write('\r'); + await settle(); +} + +describe('Persistence failure boundaries', () => { + const failedSave = () => { throw new Error('Library is read-only. Set RADIOCLI_HOME to a writable directory.'); }; + // Windows ACLs and a privileged POSIX user do not enforce this chmod fixture. + const nativePermissions = process.platform !== 'win32' && process.getuid?.() !== 0; + + it.skipIf(!nativePermissions)('tunes and navigates with a readable read-only library without inventing saved history', async () => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const original = readFileSync(store.filePath, 'utf8'); + chmodSync(store.filePath, 0o400); + try { + const app = render(); + await settle(); + await tuneLibrary(app); + expect(player.play).toHaveBeenCalledOnce(); + expect(player.state()).toMatchObject({state: 'playing', ready: true}); + expect(app.lastFrame()).toContain('Library not saved'); + expect(app.lastFrame()).not.toContain('Skipping'); + expect(readFileSync(store.filePath, 'utf8')).toBe(original); + app.stdin.write('b'); + await settle(); + expect(app.lastFrame()).toContain('Overview'); + app.stdin.write('q'); + await settle(); + expect(player.state().state).toBe('idle'); + app.unmount(); + } finally { + chmodSync(store.filePath, 0o600); + } + }); + + it.each([':stop\r', 'q'])('still stops playback through %j when the listening-history save fails', async input => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + vi.spyOn(store, 'finishActiveListeningSession').mockImplementation(failedSave); + + app.stdin.write(input); + await settle(); + expect(player.state().state).toBe('idle'); + expect(player.stop).toHaveBeenCalledTimes(2); + app.unmount(); + }); + + it('honors the sleep timer when the listening-history save fails', async () => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + vi.spyOn(store, 'finishActiveListeningSession').mockImplementation(failedSave); + + app.stdin.write(':sleep 1\r'); + await settle(); + await settle(60_000); + expect(player.state().state).toBe('idle'); + expect(app.lastFrame()).toContain('Library not saved'); + app.unmount(); + }); + + it.each(['stop', 'alarm-preempt'] as const)('honors agent %s when the listening-history save fails', async type => { + const {store, service} = fixture(); + store.updateSettings({agentControl: {...store.snapshot().settings.agentControl!, enabled: true}}); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + vi.spyOn(store, 'finishActiveListeningSession').mockImplementation(failedSave); + const handle = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + + await act(async () => { + const result = await handle({type}); + expect(result).toMatchObject({ok: true, status: {station: null, playback: {state: 'idle'}}}); + }); + expect(player.state().state).toBe('idle'); + expect(app.lastFrame()).toContain('Library not saved'); + app.unmount(); + }); + + it('keeps successful search results usable when search history cannot be saved', async () => { + const {store, service} = fixture(); + vi.spyOn(ProviderManager.prototype, 'search').mockResolvedValue([station]); + vi.spyOn(store, 'addSearch').mockImplementation(failedSave); + const app = render(); + await settle(); + app.stdin.write(':search jazz\r'); + await settle(); + expect(app.lastFrame()).toContain('KEXP'); + expect(app.lastFrame()).toContain('Library not saved'); + expect(store.snapshot().searchHistory).toEqual([]); + app.unmount(); + }); + + it.each(['metadata', 'checkpoint'] as const)('reports failed %s persistence while keeping playback active', async source => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + const before = store.snapshot(); + if (source === 'metadata') { + vi.spyOn(store, 'recordTrack').mockImplementation(failedSave); + act(() => player.metadata({title: 'A song', raw: '', updatedAt: new Date().toISOString()})); + await settle(); + } else { + vi.spyOn(store, 'checkpointActiveListeningSession').mockImplementation(failedSave); + await settle(30_000); + } + expect(app.lastFrame()).toContain('Library not saved'); + expect(player.state().state).toBe('playing'); + expect(store.snapshot()).toEqual(before); + app.unmount(); + }); + + it('reports update-check persistence failure without rejecting the background check', async () => { + vi.stubEnv('CI', 'false'); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '0'); + const {store, service} = fixture(); + vi.spyOn(store, 'updateCheckState').mockImplementation(failedSave); + const app = render(); + await settle(); + expect(updates.checkForUpdate).toHaveBeenCalledOnce(); + expect(app.lastFrame()).toContain('Library not saved'); + expect(store.snapshot().updateCheck).toBeUndefined(); + app.unmount(); + }); + + it('reports a rejected background update check', async () => { + vi.stubEnv('CI', 'false'); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '0'); + const {store, service} = fixture(); + vi.mocked(updates.checkForUpdate).mockRejectedValueOnce(new Error('Update service unavailable.')); + const app = render(); + await settle(); + expect(app.lastFrame()).toContain('Update service unavailable.'); + app.unmount(); + }); + + it.each(['t', ':location off\r', '+'])('reports a failed explicit setting through %j without claiming success', async input => { + const {store, service} = fixture(); + simulatedPlayer(); + const app = render(); + await settle(); + const settings = store.snapshot().settings; + vi.spyOn(store, 'updateSettings').mockImplementation(failedSave); + app.stdin.write(input); + await settle(); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(store.snapshot().settings).toEqual(settings); + app.stdin.write('9'); + await settle(); + expect(app.lastFrame()).toContain('Choose a category'); + app.unmount(); + }); + + it('reports a failed favorite write and preserves the favorite', async () => { + const {store, service} = fixture(); + const app = render(); + await settle(); + app.stdin.write('2'); + await settle(); + vi.spyOn(store, 'toggleFavorite').mockImplementation(failedSave); + app.stdin.write('f'); + await settle(); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(app.lastFrame()).not.toContain('Removed from favorites'); + expect(store.isFavorite(station)).toBe(true); + app.unmount(); + }); + + it('reports a failed media-key save from raw input', async () => { + const {store, service} = fixture(); + const app = render(); + await settle(); + app.stdin.write(':learn next\r'); + await settle(); + vi.spyOn(store, 'updateSettings').mockImplementation(failedSave); + app.stdin.write('x'); + await settle(); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(store.snapshot().settings.mediaKeys.next).toEqual([]); + app.unmount(); + }); + + it.each([ + {key: 'O', field: 'homepage' as const, value: 'javascript:unsafe-homepage'}, + {key: 'y', field: 'streamUrl' as const, value: 'javascript:unsafe-stream'} + ])('does not echo or dispatch an invalid $field when a desktop helper is unavailable', async ({key, field, value}) => { + const {store, service} = fixture(); + store.toggleFavorite(station); + store.toggleFavorite({...station, [field]: value}); + const open = vi.spyOn(systemActions, 'openExternal').mockResolvedValue(false); + const copy = vi.spyOn(systemActions, 'copyToClipboard').mockResolvedValue(false); + const app = render(); + await settle(); + app.stdin.write('2'); + await settle(); + app.stdin.write(key); + await settle(); + expect(open).not.toHaveBeenCalled(); + expect(copy).not.toHaveBeenCalled(); + expect(app.lastFrame()).not.toContain(value); + expect(app.lastFrame()).toMatch(/valid|safe/i); + app.unmount(); + }); +}); + describe('Settings TUI integration', () => { it('checks once at launch and shows the available version beside the installed version', async () => { vi.stubEnv('CI', 'false'); diff --git a/src/ui/use-app-input.ts b/src/ui/use-app-input.ts index 77b05b5..7e637a6 100644 --- a/src/ui/use-app-input.ts +++ b/src/ui/use-app-input.ts @@ -1,5 +1,5 @@ -import {useEffect} from 'react'; -import {useInput} from 'ink'; +import {useCallback, useEffect} from 'react'; +import {useInput, type Key} from 'ink'; import type {Dispatch, SetStateAction} from 'react'; import type {Country, Screen, Station, AppSettings} from '../types.js'; import type {PlayerController} from '../player/player-controller.js'; @@ -19,6 +19,7 @@ import { import {parseTerminalMouseEvents, primaryMousePress, wheelScrollDelta} from './terminal-mouse.js'; import {completeCommand} from './help-content.js'; import {commitImmediateSelection} from './selection-state.js'; +import {sanitizeTerminalText} from '../safety.js'; type CurrentRef = { current: T; @@ -40,7 +41,7 @@ type AppInputOptions = { commandText: string; confirmCtrlCExit: () => void; copyStationUrl: (station: Station | null) => Promise; - openStationHomepage: (station: Station | null) => void; + openStationHomepage: (station: Station | null) => Promise; currentItemCount: (screen: Screen) => number; cycleDisplayColor: () => void; cycleAudioOutput: () => void; @@ -190,6 +191,9 @@ export function useAppInput({ toggleSkipBrokenStreams, updateFromSettings }: AppInputOptions): void { + const reportInputError = useCallback((error: unknown) => { + setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.'); + }, [setMessage]); const commitSelection = (next: number): void => { commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screen)); }; @@ -199,7 +203,7 @@ export function useAppInput({ }; useEffect(() => { - const onData = (data: Buffer | string) => { + const handleData = (data: Buffer | string) => { const rawInput = String(data); if (capturingTransportAction) { if (rawInput === '\u001B') { @@ -253,6 +257,10 @@ export function useAppInput({ } }; + const onData = (data: Buffer | string) => { + try { handleData(data); } catch (error) { reportInputError(error); } + }; + stdin.on('data', onData); return () => { stdin.off('data', onData); @@ -268,6 +276,7 @@ export function useAppInput({ lastRawTransportAtRef, playAdjacent, player, + reportInputError, moveExploreCursorToCell, saveLearnedTransportKey, screen, @@ -280,7 +289,7 @@ export function useAppInput({ togglePause ]); - useInput((input, key) => { + const handleInput = async (input: string, key: Key): Promise => { if (key.ctrl && input === 'c') { confirmCtrlCExit(); return; @@ -300,9 +309,9 @@ export function useAppInput({ if (commandMode) { if (key.return) { - void executeCommand(commandText); setCommandText(''); setCommandMode(false); + await executeCommand(commandText); return; } @@ -336,9 +345,9 @@ export function useAppInput({ if (key.return) { const inputStation = selectedStationForInput(); if (searchQuery.trim() && searchQuery.trim() === lastSubmittedSearchRef.current && inputStation) { - void playStation(inputStation); + await playStation(inputStation); } else { - void runSearch(); + await runSearch(); } return; } @@ -458,7 +467,7 @@ export function useAppInput({ if (input.startsWith(':')) { const seed = input.slice(1).replace(/[\r\n]+$/g, ''); if (/[\r\n]/.test(input)) { - void executeCommand(seed); + await executeCommand(seed); } else { setCommandMode(true); setCommandText(seed); @@ -618,12 +627,12 @@ export function useAppInput({ } if (input === 'O') { - openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation)); + await openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation)); return; } if (input === 'y') { - void copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation)); + await copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation)); return; } @@ -674,7 +683,7 @@ export function useAppInput({ if (screen === 'countries') { const country = filteredCountries[selectedRef.current]; if (country) { - void loadCountry(country); + await loadCountry(country); } return; } @@ -723,9 +732,9 @@ export function useAppInput({ } else if (item === 'Automatically check for updates') { toggleSetting('automaticUpdateChecks'); } else if (item === 'Allow local agent control') { - void setAgentIntegrationEnabled(); + await setAgentIntegrationEnabled(); } else if (item === 'Install or repair MCP integrations') { - void repairMcpIntegrations(); + await repairMcpIntegrations(); } else if (item === 'Open TUI for agent playback') { toggleAgentSetting('openUiOnPlay'); } else if (item === 'Show Now Playing for agent playback') { @@ -737,7 +746,7 @@ export function useAppInput({ setCommandText('import '); setCommandMode(true); } else if (item === 'Check for updates') { - void updateFromSettings(); + await updateFromSettings(); } else if (item === 'Refresh provider health') { refreshProviderHealth(); setMessage('Provider health refreshed.'); @@ -761,16 +770,20 @@ export function useAppInput({ if (screen === 'map') { const country = filteredCountries[selectedRef.current]; if (country) { - void loadCountry(country); + await loadCountry(country); } return; } const inputStation = selectedStationForInput(); if (inputStation) { - void playStation(inputStation); + await playStation(inputStation); } } + }; + + useInput((input, key) => { + void handleInput(input, key).catch(reportInputError); }); } diff --git a/src/ui/use-command-executor.ts b/src/ui/use-command-executor.ts index f526c86..b801376 100644 --- a/src/ui/use-command-executor.ts +++ b/src/ui/use-command-executor.ts @@ -17,6 +17,7 @@ type UseCommandExecutorOptions = { loadCountry: (country: Country) => Promise; openAirPlaySettings: () => void; openLibrary: () => void; + persistLibrary: (write: () => LibraryState) => LibraryState | undefined; player: PlayerController; playingStation: Station | null; providers: ProviderManager; @@ -46,6 +47,7 @@ export function useCommandExecutor({ loadCountry, openAirPlaySettings, openLibrary, + persistLibrary, player, playingStation, providers, @@ -271,7 +273,7 @@ export function useCommandExecutor({ } if (name === 'stop') { - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); return; } @@ -311,6 +313,7 @@ export function useCommandExecutor({ loadCountry, openAirPlaySettings, openLibrary, + persistLibrary, player, playingStation, providers, From 4d017b268f1db854e2de06cbd5eb774df6965030 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:14:42 -0500 Subject: [PATCH 07/25] test: make native fixtures independent of host shell paths --- src/alarms/inhibitor.test.ts | 13 ++++++++++--- src/alarms/scheduler.test.ts | 8 +++++--- src/ui/alarm-tui-integration.test.tsx | 4 ++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/alarms/inhibitor.test.ts b/src/alarms/inhibitor.test.ts index 72c2a3d..70a7308 100644 --- a/src/alarms/inhibitor.test.ts +++ b/src/alarms/inhibitor.test.ts @@ -1,7 +1,6 @@ import {describe, expect, it, vi} from 'vitest'; import {mkdtempSync,rmSync,writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; -import {join} from 'node:path'; +import {basename,join,posix} from 'node:path'; import {createPowerInhibitor} from './inhibitor.js'; describe('power inhibitor', () => { @@ -27,5 +26,13 @@ describe('power inhibitor', () => { it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('reports unavailable sleep protection on %s without spawning a surrogate',async platform=>{const spawn=vi.fn();const inhibitor=createPowerInhibitor({platform,spawn,commandExists:()=>true});expect(inhibitor.status()).toMatchObject({supported:false,active:false});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/unsupported/i);expect(spawn).not.toHaveBeenCalled();}); - it('spawns the executable resolved from the supplied PATH',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-inhibitor-path-'));try{const command=join(root,'systemd-inhibit');writeFileSync(command,'#!/bin/sh\nexit 0\n',{mode:0o700});let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const spawn=vi.fn(()=>({pid:42,kill:()=>{exit();return true;},exited}));const inhibitor=createPowerInhibitor({platform:'linux',env:{PATH:root},spawn});const lease=await inhibitor.acquire('Alarm');expect(spawn).toHaveBeenCalledWith(command,expect.any(Array));await lease.release();}finally{rmSync(root,{recursive:true,force:true});}}); + it('spawns the executable resolved from the supplied PATH',async()=>{ + // Keep the injected POSIX PATH free of Windows drive-letter delimiters. + const root=mkdtempSync(join(process.cwd(),'.radiocli-inhibitor-path-')); + try{ + writeFileSync(join(root,'systemd-inhibit'),'#!/bin/sh\nexit 0\n',{mode:0o700});let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const spawn=vi.fn(()=>({pid:42,kill:()=>{exit();return true;},exited})); + const pathEntry=basename(root);const inhibitor=createPowerInhibitor({platform:'linux',env:{PATH:pathEntry},spawn});const lease=await inhibitor.acquire('Alarm'); + expect(spawn).toHaveBeenCalledWith(posix.join(pathEntry,'systemd-inhibit'),expect.any(Array));await lease.release(); + }finally{rmSync(root,{recursive:true,force:true});} + }); }); diff --git a/src/alarms/scheduler.test.ts b/src/alarms/scheduler.test.ts index 66b8ed2..98821f2 100644 --- a/src/alarms/scheduler.test.ts +++ b/src/alarms/scheduler.test.ts @@ -1,7 +1,7 @@ import {describe, expect, it, vi} from 'vitest'; import {existsSync,mkdirSync,mkdtempSync,readdirSync,rmSync,writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; -import {dirname,join} from 'node:path'; +import {basename,dirname,join} from 'node:path'; import type {Alarm} from '../types.js'; import {createSchedulerAdapter, SchedulerService,shouldRunLaunchdOccurrence,type SchedulerAdapter} from './scheduler.js'; @@ -174,11 +174,13 @@ describe('native alarm schedulers', () => { }); it('uses the injected executable environment for scheduler discovery', () => { - const root = mkdtempSync(join(tmpdir(), 'radiocli-scheduler-path-')); + // A relative PATH entry also represents a POSIX search path when this + // injected Linux case runs on Windows, whose drive prefix contains ':'. + const root = mkdtempSync(join(process.cwd(), '.radiocli-scheduler-path-')); vi.stubEnv('PATH', '/missing-radiocli-executables'); try { writeFileSync(join(root, 'systemctl'), '#!/bin/sh\nexit 0\n', {mode: 0o755}); - const adapter = createSchedulerAdapter({platform: 'linux', env: {PATH: root}, home: root, writeFile: vi.fn(), removeFile: vi.fn(), run: vi.fn()}); + const adapter = createSchedulerAdapter({platform: 'linux', env: {PATH: basename(root)}, home: root, writeFile: vi.fn(), removeFile: vi.fn(), run: vi.fn()}); expect(adapter.capabilities().supported).toBe(true); } finally { vi.unstubAllEnvs(); rmSync(root, {recursive: true, force: true}); } }); diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index a594e8d..6164919 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -25,6 +25,10 @@ beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // ink-testing-library sets columns but omits rows. Give Ink's fallback the + // fixture size so every layout avoids host terminal commands such as tput. + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '24'); const testHome = mkdtempSync(join(tmpdir(), 'radiocli-alarm-tui-home-')); directories.push(testHome); vi.stubEnv('RADIOCLI_HOME', testHome); From 9892d6ca535ffa1bb659cd4218318207cae9bc25 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:20:45 -0500 Subject: [PATCH 08/25] feat(platform): add explicit BSD package and desktop plans --- src/cli.test.ts | 24 +++++ src/cli.tsx | 15 ++- src/platform/desktop.test.ts | 18 ++++ src/platform/desktop.ts | 4 +- src/platform/executables.test.ts | 5 + src/platform/executables.ts | 3 + src/platform/packages.test.ts | 39 ++++++++ src/platform/packages.ts | 155 ++++++++++++++++++++++++----- src/platform/runtime.test.ts | 4 + src/platform/runtime.ts | 1 + src/platform/shell.test.ts | 24 +++++ src/platform/shell.ts | 26 +++++ src/player/backend-install.test.ts | 6 ++ src/player/backend-install.ts | 77 +------------- src/setup.test.ts | 37 +++++++ src/setup.ts | 27 +++-- 16 files changed, 355 insertions(+), 110 deletions(-) create mode 100644 src/platform/desktop.test.ts create mode 100644 src/platform/packages.test.ts create mode 100644 src/platform/shell.test.ts create mode 100644 src/platform/shell.ts diff --git a/src/cli.test.ts b/src/cli.test.ts index 8729dfa..4859fc2 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -8,6 +8,7 @@ import {detectPlaybackBackends} from './player/backend-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; import * as platformPaths from './platform/paths.js'; +import * as schedulers from './alarms/scheduler.js'; vi.mock('./player/backend-install.js', async importOriginal => { const actual = await importOriginal(); @@ -137,6 +138,29 @@ describe('CLI command dispatch', () => { } finally { spy.mockRestore(); } }); + it('reports an inaccessible user scheduler while preserving unrelated diagnostics', async () => { + const adapter = schedulers.createSchedulerAdapter(); + const spy = vi.spyOn(schedulers, 'createSchedulerAdapter').mockReturnValue({...adapter, + probeCapabilities: async () => ({supported: false, message: 'No active systemd user manager.', catchUpAfterWake: false, exactWake: false})}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.backgroundScheduling).toMatchObject({status: 'unavailable', message: 'No active systemd user manager.'}); + expect(report.capabilities.storage.status).toBe('available'); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + } finally { spy.mockRestore(); } + }); + + it('keeps doctor usable if an optional scheduler probe fails', async () => { + const adapter = schedulers.createSchedulerAdapter(); + const spy = vi.spyOn(schedulers, 'createSchedulerAdapter').mockReturnValue({...adapter, + probeCapabilities: async () => { throw new Error('probe failed'); }}); + try { + await runCommand(['doctor', '--json']); + expect(JSON.parse(logs.join('\n')).capabilities.backgroundScheduling.status).toBe('unavailable'); + } finally { spy.mockRestore(); } + }); + it('rejects unknown commands with the help hint', async () => { await expect(runCommand(['wat'])).rejects.toThrow('Unknown command: wat\nRun radiocli help.'); }); diff --git a/src/cli.tsx b/src/cli.tsx index 2a3c36d..12da4f5 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -13,6 +13,7 @@ import {airPlaySenderHealth} from './player/airplay-sender-health.js'; import {appVersion} from './version.js'; import {checkForUpdate, installUpdate, updateCommandForInstall} from './update-check.js'; import {runAlarmCommand} from './alarms/cli.js'; +import {createSchedulerAdapter, type SchedulerCapabilities} from './alarms/scheduler.js'; import {runSetup} from './setup.js'; import {runAgentCliCommand, runMcpCommand} from './agent/cli.js'; import {decodeAgentCommand} from './agent/service.js'; @@ -102,7 +103,7 @@ export async function runCommand(args: string[]): Promise { if (command === 'doctor') { const backends = detectPlaybackBackends(); const mpvDiagnostic = diagnoseCommand('mpv'); - const report = doctorReport(backends, mpvDiagnostic); + const report = await doctorReport(backends, mpvDiagnostic); if (rest.includes('--json')) { console.log(JSON.stringify(report, null, 2)); return; @@ -286,12 +287,19 @@ function printPlaybackBackendStatus(backends: string[]): void { } } -function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic) { +async function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic) { const commands = Object.fromEntries( ['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]) ); const airPlay = airPlaySenderHealth(); const host = identifyPlatform(); + let scheduler: SchedulerCapabilities | undefined; + try { + const adapter = createSchedulerAdapter(); + scheduler = await adapter.probeCapabilities?.(); + } catch { + scheduler = {supported: false, exactWake: false, catchUpAfterWake: false, message: 'The optional scheduler probe failed. Run radiocli alarm doctor for repair guidance.'}; + } const integrationCommands = [ 'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit', 'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer', @@ -302,7 +310,8 @@ function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic) { commands: integrationCommands, graphicalSession: hasGraphicalSession(host), packageManager: detectPackageManager(process.platform, host.osRelease), - storageWritable: libraryWritesAvailable() + storageWritable: libraryWritesAvailable(), + scheduler }); return { radioCliVersion: appVersion(), diff --git a/src/platform/desktop.test.ts b/src/platform/desktop.test.ts new file mode 100644 index 0000000..3d0490b --- /dev/null +++ b/src/platform/desktop.test.ts @@ -0,0 +1,18 @@ +import {describe, expect, it} from 'vitest'; +import {browserCommands, clipboardCandidates, hasGraphicalSession} from './desktop.js'; +import {identifyPlatform} from './runtime.js'; + +describe('portable desktop command plans', () => { + it.each(['linux', 'freebsd', 'openbsd', 'netbsd'])('uses explicitly supported Unix desktop tools on %s', platform => { + const host = identifyPlatform({platform, env: {}}); + expect(browserCommands(host)).toEqual([{command: 'xdg-open', args: []}]); + expect(clipboardCandidates(host).map(item => item.command)).toEqual(['wl-copy', 'xclip', 'xsel']); + expect(hasGraphicalSession(host, {})).toBe(false); + expect(hasGraphicalSession(host, {DISPLAY: ':0'})).toBe(true); + }); + it('requires a deliberate adapter on an unknown Unix system', () => { + const host = identifyPlatform({platform: 'futureos', env: {}}); + expect(browserCommands(host)).toEqual([]); + expect(clipboardCandidates(host)).toEqual([]); + }); +}); diff --git a/src/platform/desktop.ts b/src/platform/desktop.ts index 7f2ee38..5da7419 100644 --- a/src/platform/desktop.ts +++ b/src/platform/desktop.ts @@ -6,14 +6,14 @@ export type SystemCommand = {command: string; args: string[]}; export function browserCommands(host: PlatformProfile = identifyPlatform()): SystemCommand[] { if (host.id === 'darwin') return [{command: 'open', args: []}]; if (host.id === 'win32') return [{command: 'explorer', args: []}]; - if (host.id === 'linux') return [{command: 'xdg-open', args: []}]; + if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id)) return [{command: 'xdg-open', args: []}]; return []; } export function clipboardCandidates(host: PlatformProfile = identifyPlatform()): SystemCommand[] { if (host.id === 'darwin') return [{command: 'pbcopy', args: []}]; if (host.id === 'win32') return [{command: 'clip', args: []}]; - if (host.id === 'linux') return [ + if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id)) return [ {command: 'wl-copy', args: []}, {command: 'xclip', args: ['-selection', 'clipboard']}, {command: 'xsel', args: ['--clipboard', '--input']} diff --git a/src/platform/executables.test.ts b/src/platform/executables.test.ts index 4ec66f9..d74823d 100644 --- a/src/platform/executables.test.ts +++ b/src/platform/executables.test.ts @@ -12,6 +12,11 @@ afterEach(() => { }); describe('safe executable configuration and permissions', () => { + it.each([ + ['freebsd', '/usr/local/bin/mpv'], ['openbsd', '/usr/local/bin/mpv'], ['netbsd', '/usr/pkg/bin/mpv'] + ] as const)('finds the %s package prefix when an inherited PATH is incomplete', (platform, path) => { + expect(resolveCommandDetails('mpv', {platform, env: {PATH: '/bin'}, isRunnable: candidate => candidate === path})).toMatchObject({path}); + }); it('treats a configured path with spaces and Unicode as a single executable', () => { const path = '/opt/Radio 日本/player mpv'; expect(resolveCommandDetails('mpv', {platform: 'linux', env: {PATH: '/usr/bin', RADIOCLI_MPV_PATH: path}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'configured-path'}); diff --git a/src/platform/executables.ts b/src/platform/executables.ts index 636d176..aefa94d 100644 --- a/src/platform/executables.ts +++ b/src/platform/executables.ts @@ -146,6 +146,9 @@ function knownBinaryDirs({platform, env, home}: CommandResolutionOptions): strin return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', posix.join(home, '.local', 'bin')]; } + if (platform === 'freebsd' || platform === 'openbsd') return ['/usr/local/bin', '/usr/bin']; + if (platform === 'netbsd') return ['/usr/pkg/bin', '/usr/local/bin', '/usr/bin']; + if (platform === 'win32') { const dirs: string[] = []; if (env.LOCALAPPDATA) dirs.push(win32.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links')); diff --git a/src/platform/packages.test.ts b/src/platform/packages.test.ts new file mode 100644 index 0000000..aba3577 --- /dev/null +++ b/src/platform/packages.test.ts @@ -0,0 +1,39 @@ +import {describe, expect, it} from 'vitest'; +import {detectPackageManager, packageCommandInvocation, packageInstallCommand} from './packages.js'; + +describe('native package plans', () => { + it.each([ + ['freebsd', 'pkg'], ['openbsd', 'pkg_add'], ['netbsd', 'pkgin'] + ] as const)('selects %s package tools without confusing other Unix commands', (platform, expected) => { + expect(detectPackageManager(platform, '', () => true)).toBe(expected); + expect(detectPackageManager(platform, '', () => false)).toBeNull(); + }); + + it('does not treat the illumos IPS pkg command as FreeBSD pkg', () => { + expect(detectPackageManager('sunos', '', command => command === 'pkg')).toBeNull(); + }); + + it('uses verified noninteractive BSD syntax', () => { + expect(packageInstallCommand('pkg', 'mpv')).toMatchObject({program: 'sudo', args: ['pkg', 'install', '-y', 'mpv']}); + expect(packageInstallCommand('pkg_add', 'mpv', {elevation: 'doas'})).toMatchObject({program: 'doas', args: ['pkg_add', '-I', 'mpv']}); + expect(packageInstallCommand('pkgin', 'mpv', {elevation: null})).toMatchObject({program: 'pkgin', args: ['-y', 'install', 'mpv']}); + expect(packageInstallCommand('pkgin', 'ffmpeg')).toBeNull(); + }); + + it('uses the available privilege boundary for non-systemd Linux', () => { + expect(packageInstallCommand('apk', 'mpv', {elevation: 'doas'})).toMatchObject({program: 'doas', args: ['apk', 'add', 'mpv']}); + expect(packageInstallCommand('apt', 'mpv', {elevation: null})).toMatchObject({program: 'apt-get', args: ['install', '-y', 'mpv']}); + }); + + it('uses an encoded PowerShell transport for Windows script shims', () => { + const plan = packageInstallCommand('scoop', 'mpv')!; + const windows = packageCommandInvocation(plan, 'win32'); + expect(windows.program).toBe('powershell.exe'); + expect(windows.args).toContain('-EncodedCommand'); + expect(windows.args).not.toContain('/c'); + const script = Buffer.from(windows.args.at(-1)!, 'base64').toString('utf16le'); + const payload = JSON.parse(Buffer.from(/FromBase64String\('([^']+)'\)/.exec(script)![1]!, 'base64').toString('utf8')); + expect(payload).toEqual({command: 'scoop', args: ['install', 'mpv'], environment: {}}); + expect(packageCommandInvocation(plan, 'linux')).toEqual({program: 'scoop', args: ['install', 'mpv']}); + }); +}); diff --git a/src/platform/packages.ts b/src/platform/packages.ts index b40a676..f4556b7 100644 --- a/src/platform/packages.ts +++ b/src/platform/packages.ts @@ -1,8 +1,9 @@ import {existsSync, readFileSync} from 'node:fs'; import {commandExists} from './executables.js'; +import {powershellCommand} from './shell.js'; export type SetupComponent = 'mpv' | 'ffmpeg' | 'vlc'; -export type SetupPackageManager = 'brew' | 'winget' | 'scoop' | 'choco' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper'; +export type SetupPackageManager = 'brew' | 'winget' | 'scoop' | 'choco' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper' | 'pkg' | 'pkg_add' | 'pkgin'; export type SetupCommand = { component: SetupComponent | null; @@ -12,7 +13,14 @@ export type SetupCommand = { display: string; }; -export const packageManagers: SetupPackageManager[] = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper']; +export const packageManagers: SetupPackageManager[] = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper', 'pkg', 'pkg_add', 'pkgin']; + +/** Package names/options are selected from the recipes below, never shell input. */ +export function packageCommandInvocation(command: SetupCommand, platform: NodeJS.Platform): {program: string; args: string[]} { + return platform === 'win32' + ? {program: 'powershell.exe', args: powershellCommand([command.program, ...command.args])} + : {program: command.program, args: command.args}; +} export function detectPackageManager( platform: NodeJS.Platform, @@ -21,6 +29,9 @@ export function detectPackageManager( ): SetupPackageManager | null { if (platform === 'darwin') return hasCommand('brew') ? 'brew' : null; if (platform === 'win32') return firstAvailable(['winget', 'scoop', 'choco'], hasCommand); + if (platform === 'freebsd') return hasCommand('pkg') ? 'pkg' : null; + if (platform === 'openbsd') return hasCommand('pkg_add') ? 'pkg_add' : null; + if (platform === 'netbsd') return hasCommand('pkgin') ? 'pkgin' : null; if (platform !== 'linux') return null; const ids = linuxReleaseIds(osRelease); @@ -38,8 +49,12 @@ export function detectPackageManager( return firstAvailable(preferred, hasCommand); } -export function packageInstallCommand(manager: SetupPackageManager, component: SetupComponent): SetupCommand { - const packages: Record> = { +export function packageInstallCommand( + manager: SetupPackageManager, + component: SetupComponent, + {elevation = 'sudo'}: {elevation?: 'sudo' | 'doas' | null} = {} +): SetupCommand | null { + const packages: Record>> = { brew: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, winget: {mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC'}, scoop: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, @@ -48,9 +63,15 @@ export function packageInstallCommand(manager: SetupPackageManager, component: S dnf: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, pacman: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, apk: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - zypper: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'} + zypper: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + pkg: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + pkg_add: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + // pkgsrc FFmpeg/ffplay packages and executables carry a major version. + // Choose an available matching pair manually instead of guessing a catalog. + pkgin: {mpv: 'mpv', vlc: 'vlc'} }; const packageName = packages[manager][component]; + if (!packageName) return null; let program: string = manager; let args: string[]; @@ -58,26 +79,32 @@ export function packageInstallCommand(manager: SetupPackageManager, component: S else if (manager === 'winget') args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements']; else if (manager === 'scoop') args = ['install', packageName]; else if (manager === 'choco') args = ['install', packageName, '-y']; - else if (manager === 'apt') { - program = 'sudo'; - args = ['apt-get', 'install', '-y', packageName]; - } else if (manager === 'dnf') { - program = 'sudo'; - args = ['dnf', 'install', '-y', packageName]; - } else if (manager === 'pacman') { - program = 'sudo'; - args = ['pacman', '-S', '--needed', '--noconfirm', packageName]; - } else if (manager === 'apk') { - program = 'sudo'; - args = ['apk', 'add', packageName]; - } else { - program = 'sudo'; - args = ['zypper', '--non-interactive', 'install', packageName]; - } + else if (manager === 'apt') { program = 'apt-get'; args = ['install', '-y', packageName]; } + else if (manager === 'dnf') args = ['install', '-y', packageName]; + else if (manager === 'pacman') args = ['-S', '--needed', '--noconfirm', packageName]; + else if (manager === 'apk') args = ['add', packageName]; + else if (manager === 'zypper') args = ['--non-interactive', 'install', packageName]; + else if (manager === 'pkg') args = ['install', '-y', packageName]; + else if (manager === 'pkg_add') args = ['-I', packageName]; + else args = ['-y', 'install', packageName]; + if (packageManagerNeedsRoot(manager) && elevation) { + args = [program, ...args]; + program = elevation; + } return {component, label: componentLabel(component), program, args, display: [program, ...args].join(' ')}; } +export function packageManagerNeedsRoot(manager: SetupPackageManager): boolean { + return !['brew', 'winget', 'scoop', 'choco'].includes(manager); +} + +export function packageManagerNotes(manager: SetupPackageManager | null): string[] { + if (manager === 'pkg') return ['FreeBSD ffmpeg provides ffplay only when built with SDL; verify the executable separately.']; + if (manager === 'pkgin') return ['pkgsrc uses versioned FFmpeg/ffplay packages. Choose a matching available pair and set RADIOCLI_FFPLAY_PATH to the absolute ffplay executable.']; + return []; +} + export function platformLabel(platform: NodeJS.Platform, osRelease: string): string { if (platform === 'darwin') return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`; if (platform === 'win32') return 'Windows'; @@ -104,7 +131,7 @@ export function readLinuxOsRelease(platform: NodeJS.Platform = process.platform) } } -export function linuxReleaseIds(osRelease: string): Set { +function linuxReleaseIds(osRelease: string): Set { const ids = new Set(); for (const line of osRelease.split('\n')) { const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); @@ -119,6 +146,88 @@ function osReleaseValue(osRelease: string, key: string): string { return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? ''; } -export function hasAny(values: Set, candidates: string[]): boolean { +function hasAny(values: Set, candidates: string[]): boolean { return candidates.some(candidate => values.has(candidate)); } + +export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { + if (platform === 'darwin') { + return 'brew install mpv'; + } + + if (platform === 'win32') { + return 'winget install --id shinchiro.mpv -e'; + } + + if (platform === 'freebsd') return 'pkg install -y mpv (as root, or use sudo/doas)'; + if (platform === 'openbsd') return 'doas pkg_add -I mpv'; + if (platform === 'netbsd') return 'pkgin -y install mpv (as root, or use sudo/doas)'; + + if (platform !== 'linux') { + return 'install mpv with your system package manager'; + } + + const ids = linuxReleaseIds(osRelease); + if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { + return 'sudo apt install mpv'; + } + + if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { + return 'sudo dnf install mpv'; + } + + if (hasAny(ids, ['arch', 'manjaro'])) { + return 'sudo pacman -S mpv'; + } + + if (hasAny(ids, ['alpine'])) { + return 'sudo apk add mpv'; + } + + if (hasAny(ids, ['opensuse', 'suse'])) { + return 'sudo zypper install mpv'; + } + + return 'install mpv with your system package manager'; +} + +export function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { + if (platform === 'darwin') { + return 'brew install ffmpeg'; + } + + if (platform === 'win32') { + return 'winget install --id Gyan.FFmpeg -e'; + } + + if (platform === 'freebsd') return 'pkg install -y ffmpeg; ffplay requires the SDL build option'; + if (platform === 'openbsd') return 'doas pkg_add -I ffmpeg'; + if (platform === 'netbsd') return 'install a matching pkgsrc ffmpegN/ffplayN pair; set RADIOCLI_FFPLAY_PATH'; + + if (platform !== 'linux') { + return 'install FFmpeg with your system package manager'; + } + + const ids = linuxReleaseIds(osRelease); + if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { + return 'sudo apt install ffmpeg'; + } + + if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { + return 'sudo dnf install ffmpeg'; + } + + if (hasAny(ids, ['arch', 'manjaro'])) { + return 'sudo pacman -S ffmpeg'; + } + + if (hasAny(ids, ['alpine'])) { + return 'sudo apk add ffmpeg'; + } + + if (hasAny(ids, ['opensuse', 'suse'])) { + return 'sudo zypper install ffmpeg'; + } + + return 'install FFmpeg with your system package manager'; +} diff --git a/src/platform/runtime.test.ts b/src/platform/runtime.test.ts index 4503e69..a18f26a 100644 --- a/src/platform/runtime.test.ts +++ b/src/platform/runtime.test.ts @@ -43,6 +43,10 @@ describe('platform identity and adapter policy', () => { expect(nativeAdapters(host('win32'))).toMatchObject({scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', airPlay: false, ipc: 'named-pipe', posixPermissions: false}); }); + it.each(['freebsd', 'openbsd', 'netbsd'])('selects a graphical terminal family independently of scheduling on %s', platform => { + expect(nativeAdapters(identifyPlatform({platform, env: {}}))).toMatchObject({terminal: 'unix', scheduler: null, inhibitor: null}); + }); + it('keeps unknown runtimes explicit rather than calling them Linux', () => { const host = identifyPlatform({platform: 'future-os', arch: 'future-cpu', nodeVersion: '24.20.0', env: {}}); expect(host).toMatchObject({id: 'unknown', platform: 'future-os', arch: 'future-cpu', nodeMajor: 24}); diff --git a/src/platform/runtime.ts b/src/platform/runtime.ts index 52f531a..486f25a 100644 --- a/src/platform/runtime.ts +++ b/src/platform/runtime.ts @@ -68,6 +68,7 @@ export function nativeAdapters(host: PlatformProfile = identifyPlatform()): Nati if (host.id === 'darwin') return {...portable, scheduler: 'launchd', inhibitor: 'caffeinate', volume: 'macos', terminal: 'macos', airPlay: true}; if (host.id === 'win32') return {...portable, scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', ipc: 'named-pipe', posixPermissions: false}; if (host.id === 'linux') return {...portable, scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix'}; + if (['freebsd', 'openbsd', 'netbsd'].includes(host.id)) return {...portable, terminal: 'unix'}; return portable; } diff --git a/src/platform/shell.test.ts b/src/platform/shell.test.ts new file mode 100644 index 0000000..13d4f0a --- /dev/null +++ b/src/platform/shell.test.ts @@ -0,0 +1,24 @@ +import {spawn} from 'node:child_process'; +import {describe,expect,it} from 'vitest'; +import {powershellCommand} from './shell.js'; +import {resolveCommandDetails} from './executables.js'; + +describe('PowerShell command transport',()=>{ + it('transports paths, arguments, and environment as data rather than executable source',()=>{ + const values=['C:\\Tools %PATH%! & \' 单播\\scoop.cmd','install','mpv','text "quoted" ; $(throw boom)'];const environment={RADIOCLI_HOME:'C:\\Data %PATH%! & "quoted" \' 单播'}; + const args=powershellCommand(values,environment);expect(args.slice(0,-1)).toEqual(['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand']); + const script=Buffer.from(args.at(-1)!,'base64').toString('utf16le');const encoded=/FromBase64String\('([^']+)'\)/.exec(script)?.[1];expect(encoded).toBeDefined(); + expect(JSON.parse(Buffer.from(encoded!,'base64').toString('utf8'))).toEqual({command:values[0],args:values.slice(1),environment}); + expect(script).not.toContain(values[0]);expect(script).not.toContain(values.at(-1));expect(script).not.toContain(environment.RADIOCLI_HOME);expect(script).toContain('exit $LASTEXITCODE'); + }); + + it('can leave an interactive console open after the child command',()=>{const args=powershellCommand(['node','cli.js'],{}, {keepOpen:true});expect(args).toContain('-NoExit');expect(args).not.toContain('-NonInteractive');expect(Buffer.from(args.at(-1)!,'base64').toString('utf16le')).not.toContain('exit $LASTEXITCODE');}); + it('rejects empty commands and NUL bytes before starting a process',()=>{expect(()=>powershellCommand([])).toThrow(/command/i);expect(()=>powershellCommand(['node','bad\0value'])).toThrow(/NUL/i);}); + + it.skipIf(process.platform!=='win32')('preserves special data-home characters under native Windows PowerShell',async()=>{ + const powershell=resolveCommandDetails('powershell.exe').path;expect(powershell).not.toBeNull();const home='C:\\Data %PATH%! & "quoted" \' 单播'; + const args=powershellCommand([process.execPath,'-e',"process.stdout.write(process.env.RADIOCLI_HOME)"],{RADIOCLI_HOME:home}); + const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{const child=spawn(powershell!,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',chunk=>stdout+=String(chunk));child.stderr.on('data',chunk=>stderr+=String(chunk));child.once('error',reject);child.once('close',code=>resolve({code:code??1,stdout,stderr}));}); + expect(result).toEqual({code:0,stdout:home,stderr:''}); + }); +}); diff --git a/src/platform/shell.ts b/src/platform/shell.ts new file mode 100644 index 0000000..29aa74f --- /dev/null +++ b/src/platform/shell.ts @@ -0,0 +1,26 @@ +/** + * Keep values out of PowerShell source when a native tool or script shim must + * run there. Native argument conversion still belongs to the target command; + * terminal launches use an encoded Node bootstrap for arbitrary argv values. + */ +export function powershellCommand( + values: readonly string[], + environment: Record = {}, + options: {keepOpen?: boolean} = {} +): string[] { + if (!values[0]) throw new Error('A PowerShell command is required.'); + if ([...values, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) { + throw new Error('PowerShell command values cannot contain NUL bytes.'); + } + const payload = Buffer.from(JSON.stringify({command: values[0], args: values.slice(1), environment}), 'utf8').toString('base64'); + const script = [ + "$ErrorActionPreference='Stop'", + `$radiocliInvocation=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json`, + "foreach($radiocliVariable in $radiocliInvocation.environment.PSObject.Properties){[Environment]::SetEnvironmentVariable($radiocliVariable.Name,[string]$radiocliVariable.Value,'Process')}", + '$radiocliArguments=@($radiocliInvocation.args | ForEach-Object {[string]$_})', + '$global:LASTEXITCODE=0', + '& ([string]$radiocliInvocation.command) @radiocliArguments', + ...(options.keepOpen ? [] : ['$radiocliSucceeded=$?', 'if(-not $radiocliSucceeded -and $LASTEXITCODE -eq 0){exit 1}', 'exit $LASTEXITCODE']) + ].join(';'); + return ['-NoLogo', '-NoProfile', ...(options.keepOpen ? ['-NoExit'] : ['-NonInteractive']), '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]; +} diff --git a/src/player/backend-install.test.ts b/src/player/backend-install.test.ts index cfeb095..14c7aef 100644 --- a/src/player/backend-install.test.ts +++ b/src/player/backend-install.test.ts @@ -18,6 +18,12 @@ describe('playback backend install guidance', () => { expect(mpvInstallCommand('win32')).toBe('winget install --id shinchiro.mpv -e'); }); + it.each([ + ['freebsd', 'pkg install -y mpv'], ['openbsd', 'doas pkg_add -I mpv'], ['netbsd', 'pkgin -y install mpv'] + ] as const)('describes the explicit %s mpv installation path', (platform, command) => { + expect(mpvInstallCommand(platform)).toContain(command); + }); + it('reports npm and native playback responsibilities separately', () => { expect(playbackBackendStatusLines([], 'darwin')).toEqual([ 'playback=missing', diff --git a/src/player/backend-install.ts b/src/player/backend-install.ts index 0072eb6..bce355d 100644 --- a/src/player/backend-install.ts +++ b/src/player/backend-install.ts @@ -1,4 +1,5 @@ -import {hasAny, linuxReleaseIds, readLinuxOsRelease} from '../platform/packages.js'; +import {ffplayInstallCommand, mpvInstallCommand, readLinuxOsRelease} from '../platform/packages.js'; +export {mpvInstallCommand}; import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; import {commandExists} from './command.js'; import {airPlaySenderHealth} from './airplay-sender-health.js'; @@ -168,77 +169,3 @@ export function playbackBackendStatusLines( ...lines ]; } - -export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { - if (platform === 'darwin') { - return 'brew install mpv'; - } - - if (platform === 'win32') { - return 'winget install --id shinchiro.mpv -e'; - } - - if (platform !== 'linux') { - return 'install mpv with your system package manager'; - } - - const ids = linuxReleaseIds(osRelease); - if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { - return 'sudo apt install mpv'; - } - - if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { - return 'sudo dnf install mpv'; - } - - if (hasAny(ids, ['arch', 'manjaro'])) { - return 'sudo pacman -S mpv'; - } - - if (hasAny(ids, ['alpine'])) { - return 'sudo apk add mpv'; - } - - if (hasAny(ids, ['opensuse', 'suse'])) { - return 'sudo zypper install mpv'; - } - - return 'install mpv with your system package manager'; -} - -function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { - if (platform === 'darwin') { - return 'brew install ffmpeg'; - } - - if (platform === 'win32') { - return 'winget install --id Gyan.FFmpeg -e'; - } - - if (platform !== 'linux') { - return 'install FFmpeg with your system package manager'; - } - - const ids = linuxReleaseIds(osRelease); - if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { - return 'sudo apt install ffmpeg'; - } - - if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { - return 'sudo dnf install ffmpeg'; - } - - if (hasAny(ids, ['arch', 'manjaro'])) { - return 'sudo pacman -S ffmpeg'; - } - - if (hasAny(ids, ['alpine'])) { - return 'sudo apk add ffmpeg'; - } - - if (hasAny(ids, ['opensuse', 'suse'])) { - return 'sudo zypper install ffmpeg'; - } - - return 'install FFmpeg with your system package manager'; -} diff --git a/src/setup.test.ts b/src/setup.test.ts index 3af6a4d..8060635 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -122,6 +122,43 @@ describe('RadioCLI setup', () => { expect(text).toContain('Agent integration: would be enabled and installed for detected MCP clients.'); }); + it.each([ + {platform: 'freebsd' as const, manager: 'pkg', uid: 0, commands: ['pkg'], expected: 'pkg install -y mpv'}, + {platform: 'openbsd' as const, manager: 'pkg_add', uid: 1000, commands: ['pkg_add', 'doas'], expected: 'doas pkg_add -I mpv'}, + {platform: 'linux' as const, manager: 'apk', uid: 0, commands: ['apk'], expected: 'apk add mpv'} + ])('executes the reviewed $manager privilege plan', async ({platform, manager, uid, commands, expected}) => { + const execute = vi.fn(async (_command: {display: string}) => undefined); + await runSetup({platform, osRelease: 'ID=alpine', args: ['--yes', '--only=mpv', `--package-manager=${manager}`], + input: new PassThrough(), output: new PassThrough(), hasCommand: command => commands.includes(command), + getUid: () => uid, runCommand: execute}); + expect(execute).toHaveBeenCalledOnce(); + expect(execute.mock.calls[0]?.[0]).toMatchObject({display: expected}); + }); + + it('rejects installation when a privileged package tool has no usable privilege helper', async () => { + const execute = vi.fn(async () => undefined); + await expect(runSetup({platform: 'freebsd', args: ['--yes', '--only=mpv'], getUid: () => 1000, + input: new PassThrough(), output: new PassThrough(), hasCommand: command => command === 'pkg', runCommand: execute + })).rejects.toThrow('requires root, sudo, or doas'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('shows a manual pkgsrc FFmpeg plan without invoking a guessed package', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => { text += String(chunk); }); + const execute = vi.fn(async () => undefined); + await runSetup({platform: 'netbsd', args: ['--dry-run', '--only=ffmpeg'], getUid: () => 0, + input: new PassThrough(), output, hasCommand: command => command === 'pkgin', runCommand: execute}); + expect(text).toContain('manual installation required'); + expect(text).toContain('RADIOCLI_FFPLAY_PATH'); + expect(execute).not.toHaveBeenCalled(); + await expect(runSetup({platform: 'netbsd', args: ['--yes', '--only=ffmpeg'], getUid: () => 0, + input: new PassThrough(), output, hasCommand: command => command === 'pkgin', runCommand: execute + })).rejects.toThrow('No verified automatic installation command'); + expect(execute).not.toHaveBeenCalled(); + }); + it('runs selected installers sequentially and prints final verification', async () => { const output = new PassThrough(); let text = ''; diff --git a/src/setup.ts b/src/setup.ts index e32c79f..6800f88 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,4 +1,4 @@ -import {componentLabel, detectPackageManager, packageInstallCommand, packageManagers, platformLabel, readLinuxOsRelease, type SetupCommand, type SetupComponent, type SetupPackageManager} from './platform/packages.js'; +import {componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageManagerNeedsRoot, packageManagerNotes, packageManagers, platformLabel, readLinuxOsRelease, type SetupCommand, type SetupComponent, type SetupPackageManager} from './platform/packages.js'; export {detectPackageManager}; export type {SetupComponent}; import {spawn, type SpawnOptions} from 'node:child_process'; @@ -28,6 +28,7 @@ type SetupOptions = { output?: Writable; hasCommand?: (command: string) => boolean; runCommand?: (command: SetupCommand, output: Writable) => Promise; + getUid?: () => number | undefined; }; type ParsedSetupArgs = { @@ -72,8 +73,11 @@ export async function runSetup(options: SetupOptions = {}): Promise { agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true); } - const plan = createSetupPlan({platform, osRelease, packageManager, installed, selected}); + const isRoot = (options.getUid ?? process.getuid)?.() === 0; + const elevation = isRoot ? null : hasCommand('sudo') ? 'sudo' : hasCommand('doas') ? 'doas' : 'sudo'; + const plan = createSetupPlan({platform, osRelease, packageManager, installed, selected, elevation}); printPlan(plan, output); + for (const note of packageManagerNotes(packageManager)) output.write(` Note: ${note}\n`); const missing = plan.selected.filter(component => !plan.installed[component]); @@ -99,6 +103,12 @@ export async function runSetup(options: SetupOptions = {}): Promise { return; } + const manual = missing.filter(component => !plan.commands.some(command => command.component === component)); + if (manual.length) throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`); + if (packageManagerNeedsRoot(plan.packageManager) && !isRoot && !hasCommand(elevation!)) { + throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.'); + } + if (parsed.packageManager && !hasCommand(parsed.packageManager)) { throw new Error(`Requested package manager is not available: ${parsed.packageManager}.`); } @@ -118,7 +128,7 @@ export async function runSetup(options: SetupOptions = {}): Promise { await runVisibleCommand('sudo', ['-v']); } - const execute = options.runCommand ?? runInstallCommand; + const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform)); output.write('\nInstalling\n'); for (const command of plan.commands) { await execute(command, output); @@ -135,17 +145,19 @@ export function createSetupPlan({ osRelease = '', packageManager, installed, - selected + selected, + elevation = 'sudo' }: { platform: NodeJS.Platform; osRelease?: string; packageManager: SetupPackageManager | null; installed: Record; selected: SetupComponent[]; + elevation?: 'sudo' | 'doas' | null; }): SetupPlan { const uniqueSelected = components.filter(component => selected.includes(component)); const missing = uniqueSelected.filter(component => !installed[component]); - const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component)) : []; + const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, {elevation})).filter((command): command is SetupCommand => command !== null) : []; if (packageManager === 'scoop' && missing.some(component => component === 'mpv' || component === 'vlc')) { commands.unshift({ component: null, @@ -301,7 +313,7 @@ function printPlan(plan: SetupPlan, output: Writable): void { if (plan.selected.length === 0) output.write(' No components selected\n'); } -async function runInstallCommand(command: SetupCommand, output: Writable): Promise { +async function runInstallCommand(command: SetupCommand, output: Writable, platform: NodeJS.Platform): Promise { const interactive = Boolean((output as NodeJS.WriteStream).isTTY); const startedAt = Date.now(); let timer: NodeJS.Timeout | undefined; @@ -319,7 +331,8 @@ async function runInstallCommand(command: SetupCommand, output: Writable): Promi try { await new Promise((resolve, reject) => { - const child = spawn(command.program, command.args, {stdio: ['inherit', 'pipe', 'pipe']} satisfies SpawnOptions); + const invocation = packageCommandInvocation(command, platform); + const child = spawn(invocation.program, invocation.args, {shell: false, stdio: ['inherit', 'pipe', 'pipe']} satisfies SpawnOptions); child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); }); child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); }); child.once('error', reject); From f836f95fd37029e99ad88c4146c186acd97d0ef2 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:25:09 -0500 Subject: [PATCH 09/25] feat(platform): centralize verified terminal launch requests --- src/agent/launcher.test.ts | 39 ++++++ src/agent/launcher.ts | 65 ++------- src/alarms/terminal-launcher.test.ts | 43 ++++-- src/alarms/terminal-launcher.ts | 201 +++++++++++++-------------- src/platform/shell.test.ts | 6 +- src/platform/terminals.test.ts | 55 ++++++++ src/platform/terminals.ts | 123 ++++++++++++++++ 7 files changed, 360 insertions(+), 172 deletions(-) create mode 100644 src/agent/launcher.test.ts create mode 100644 src/platform/terminals.test.ts create mode 100644 src/platform/terminals.ts diff --git a/src/agent/launcher.test.ts b/src/agent/launcher.test.ts new file mode 100644 index 0000000..feaebde --- /dev/null +++ b/src/agent/launcher.test.ts @@ -0,0 +1,39 @@ +import {EventEmitter} from 'node:events'; +import {spawn as nodeSpawn,type ChildProcess} from 'node:child_process'; +import {mkdtempSync,mkdirSync,rmSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import {launchRadioTui,resolveExecutable} from './launcher.js'; + +vi.mock('node:child_process',async importOriginal=>({...await importOriginal(),spawn:vi.fn()})); +const roots:string[]=[]; +const calls:Array<{command:string;args:readonly string[]}>=[]; +const spawn=vi.fn((command:string,args:readonly string[])=>{calls.push({command,args});const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',0);});return child;}); +beforeEach(()=>{calls.length=0;spawn.mockClear();vi.mocked(nodeSpawn).mockImplementation(spawn as unknown as typeof nodeSpawn);}); +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); +function fixture(){const root=mkdtempSync(join(tmpdir(),'radiocli-launcher-'));roots.push(root);return root;} + +describe('agent graphical launcher',()=>{ + it('does not resolve a directory as a command',()=>{const root=fixture();const path=join(root,'radiocli-test-command');mkdirSync(path);expect(resolveExecutable(path,{PATH:root},'linux')).toBeUndefined();}); + it.skipIf(process.platform==='win32')('requires executable permission for Unix commands',()=>{const root=fixture();const path=join(root,'radiocli-test-command');writeFileSync(path,'test',{mode:0o600});expect(resolveExecutable(path,{PATH:root},'linux')).toBeUndefined();}); + it('preserves the existing executable lookup API for runnable files',()=>{const root=fixture();const path=join(root,`radiocli-test-command${process.platform==='win32'?'.exe':''}`);writeFileSync(path,'test',{mode:0o700});expect(resolveExecutable('radiocli-test-command',{PATH:root},process.platform)).toBe(path);}); + + it.each(['win32:console','win32:windows-terminal'])('encodes hostile paths and the data home without cmd interpolation in %s',async terminal=>{ + const home='C:\\Data %PATH%! & "quoted" \' 单播';const nodePath='C:\\Node %TEMP%! & 单播\\node.exe';const cliPath='C:\\Radio %PATH%! & 单播\\cli.js'; + await launchRadioTui(nodePath,cliPath,'encoded-agent-command',{platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:terminal,RADIOCLI_HOME:home},spawn,resolve:command=>command}); + const call=calls[0]!;expect(call.command).not.toBe('cmd.exe');const encoded=call.args[call.args.indexOf('-EncodedCommand')+1];expect(call.args).toContain('-EncodedCommand'); + const script=Buffer.from(encoded!,'base64').toString('utf16le');const match=/FromBase64String\('([^']+)'\)/.exec(script);expect(match).not.toBeNull(); + const invocation=JSON.parse(Buffer.from(match![1]!,'base64').toString('utf8')) as {command:string;args:string[]};expect(invocation.command).toBe(nodePath); + const payload=JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')); + expect(payload).toEqual({args:[cliPath,'agent-ui','encoded-agent-command'],environment:{RADIOCLI_HOME:home}});expect(script).not.toContain(home);expect(script).not.toContain(cliPath); + }); + + it('surfaces an immediate native launcher failure after spawn',async()=>{ + const launch=vi.fn(()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',1);});return child;});vi.mocked(nodeSpawn).mockImplementation(launch as typeof nodeSpawn); + await expect(launchRadioTui('/node','/cli.js','encoded',{platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal'},spawn:launch,resolve:command=>command})).rejects.toThrow(/terminal.*exit|launcher.*exit/i); + }); + + it('does not launch a graphical terminal in a headless Unix session',async()=>{await expect(launchRadioTui('/node','/cli.js','encoded',{platform:'linux',env:{RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},spawn,resolve:command=>command})).rejects.toThrow(/graphical|display|headless/i);expect(spawn).not.toHaveBeenCalled();}); + it.each(['freebsd','openbsd','netbsd'] as const)('requests the same agent session command in a supported %s desktop',async platform=>{await launchRadioTui('/node','/cli.js','encoded',{platform,env:{DISPLAY:':0',TERMINAL:'xterm'},spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined});expect(calls[0]).toEqual({command:'/usr/local/bin/xterm',args:['-e','/node','/cli.js','agent-ui','encoded']});}); +}); diff --git a/src/agent/launcher.ts b/src/agent/launcher.ts index 91ba156..203a62f 100644 --- a/src/agent/launcher.ts +++ b/src/agent/launcher.ts @@ -1,69 +1,20 @@ -import {spawn, type ChildProcess} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {basename, join} from 'node:path'; -import {detectAlarmTerminal} from '../alarms/terminal-launcher.js'; +import {spawn} from 'node:child_process'; +import {resolveCommandDetails} from '../platform/executables.js'; +import {launchTerminalCommand, waitForLaunch, type TerminalOptions} from '../platform/terminals.js'; -const linuxTerminals = new Set(['alacritty', 'foot', 'ghostty', 'gnome-terminal', 'kitty', 'konsole', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'wezterm', 'xfce4-terminal', 'x-terminal-emulator']); - -export async function launchRadioTui(nodePath: string, cliPath: string, encodedCommand: string): Promise { - const terminal = detectAlarmTerminal(); - const direct = environmentCommand(nodePath, cliPath, ['agent-ui', encodedCommand]); - const command = `${direct.map(shellQuote).join(' ')}`; - if (terminal === 'darwin:apple-terminal') await launched(spawnDetached('/usr/bin/osascript', appleTerminalScript(command))); - else if (terminal === 'darwin:iterm') await launched(spawnDetached('/usr/bin/osascript', iTermScript(command))); - else if (terminal === 'darwin:wezterm') await launched(spawnDetached('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct])); - else if (terminal === 'darwin:ghostty') await launched(spawnDetached('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...direct])); - else if (terminal === 'darwin:kitty') await launched(spawnDetached('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...direct])); - else if (terminal === 'win32:windows-terminal') await launched(spawnDetached('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI', ...direct])); - else if (terminal === 'win32:console') await launched(spawnDetached('cmd.exe', ['/d', '/c', 'start', 'RadioCLI', 'cmd.exe', '/k', windowsCommand(direct)])); - else if (terminal.startsWith('linux:')) { - const executable = terminal.slice('linux:'.length); - if (!linuxTerminals.has(basename(executable))) throw new Error('Saved Linux terminal is not supported.'); - const name = basename(executable); - const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' - ? ['--'] - : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e']; - await launched(spawnDetached(executable, [...prefix, ...direct])); - } else { - throw new Error('No supported graphical terminal was found. Set agentControl.openUiOnPlay to false or open radiocli manually.'); - } +/** The agent service verifies the authenticated session after this request. */ +export async function launchRadioTui(nodePath: string, cliPath: string, encodedCommand: string, options: TerminalOptions = {}): Promise { + await launchTerminalCommand({...options, nodePath, args: [cliPath, 'agent-ui', encodedCommand], title: 'RadioCLI'}); } export async function launchHeadlessHost(nodePath: string, cliPath: string): Promise { - await launched(spawnDetached(nodePath, [cliPath, 'agent-host'])); -} - -function environmentCommand(nodePath: string, cliPath: string, args: string[]): string[] { - const command = [nodePath, cliPath, ...args]; - const radioCliHome = process.env.RADIOCLI_HOME; - if (!radioCliHome) return command; - if (process.platform === 'win32') { - return ['cmd.exe', '/d', '/c', `set "RADIOCLI_HOME=${cmdEscape(radioCliHome)}" && ${windowsCommand(command)}`]; - } - return ['/usr/bin/env', `RADIOCLI_HOME=${radioCliHome}`, ...command]; + await waitForLaunch(spawn(nodePath, [cliPath, 'agent-host'], {detached: true, stdio: 'ignore', windowsHide: false})); } -function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'`; } -function windowsCommand(values: string[]): string { return values.map(value => `"${value.replaceAll('"', '""')}"`).join(' '); } -function cmdEscape(value: string): string { return value.replaceAll('%', '%%').replaceAll('"', '""').replaceAll('^', '^^').replaceAll('&', '^&').replaceAll('|', '^|').replaceAll('<', '^<').replaceAll('>', '^>'); } -function appleTerminalScript(command: string): string[] { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; } -function iTermScript(command: string): string[] { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; } -function spawnDetached(command: string, args: readonly string[]): ChildProcess { return spawn(command, [...args], {detached: true, stdio: 'ignore', windowsHide: false}); } -function launched(child: ChildProcess): Promise { return new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', () => { child.unref(); resolve(); }); }); } - export function resolveExecutable( input: string, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform ): string | undefined { - if ((input.includes('/') || input.includes('\\')) && existsSync(input)) return input; - const suffixes = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; - const pathDelimiter = platform === 'win32' ? ';' : ':'; - for (const directory of (env.PATH ?? '').split(pathDelimiter)) { - for (const suffix of suffixes) { - const path = join(directory, `${input}${suffix}`); - if (existsSync(path)) return path; - } - } - return undefined; + return resolveCommandDetails(input, {platform, env}).path ?? undefined; } diff --git a/src/alarms/terminal-launcher.test.ts b/src/alarms/terminal-launcher.test.ts index 36ea87f..3723dbf 100644 --- a/src/alarms/terminal-launcher.test.ts +++ b/src/alarms/terminal-launcher.test.ts @@ -9,6 +9,17 @@ function spawnRecorder(){ const spawn=vi.fn((command:string,args:readonly string[])=>{calls.push({command,args});const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();queueMicrotask(()=>child.emit('spawn'));return child;}); return{calls,spawn}; } +function powershellInvocation(args: readonly string[]): {command: string; args: string[]} { + const index = args.indexOf('-EncodedCommand'); + expect(index).toBeGreaterThanOrEqual(0); + const script = Buffer.from(args[index + 1]!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(encoded).toBeDefined(); + return JSON.parse(Buffer.from(encoded!, 'base64').toString('utf8')); +} +function bootstrapPayload(args: readonly string[]): {args: string[]; environment: Record} { + return JSON.parse(Buffer.from(args.at(-1)!, 'base64url').toString('utf8')); +} describe('alarm terminal launcher',()=>{ it('remembers recognizable terminal emulators per platform',()=>{ @@ -21,18 +32,31 @@ describe('alarm terminal launcher',()=>{ it('opens Apple Terminal with RadioCLI and its saved data home',async()=>{ const recorded=spawnRecorder(); const result=await openAlarmControls({platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal',RADIOCLI_HOME:"/Data/O'Brien"},nodePath:'/usr/bin/node',cliPath:'/app/cli.js',spawn:recorded.spawn}); - expect(result.opened).toBe(true); + expect(result).toMatchObject({opened:false,requested:true,message:expect.stringMatching(/requested.*unverified/i)}); expect(recorded.calls[0]?.command).toBe('/usr/bin/osascript'); expect(recorded.calls[0]?.args.at(-1)).toContain("RADIOCLI_HOME='/Data/O'\\''Brien'"); expect(recorded.calls[0]?.args.at(-1)).toContain("'/app/cli.js'"); }); - it('opens the saved Windows Terminal profile in a new RadioCLI tab',async()=>{ + it('reports opened only after the new TUI becomes observable',async()=>{const recorded=spawnRecorder();const hasLiveTui=vi.fn().mockReturnValueOnce(false).mockReturnValue(true);const result=await openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,hasLiveTui,timeoutMs:100});expect(result).toMatchObject({opened:true,requested:true});expect(hasLiveTui).toHaveBeenCalledTimes(2);}); + + it('distinguishes an accepted launch request from a TUI that never appears',async()=>{const recorded=spawnRecorder();const result=await openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,hasLiveTui:()=>false,timeoutMs:20});expect(result).toMatchObject({opened:false,requested:true,message:expect.stringMatching(/requested.*did not become ready/i)});}); + + it('rejects native launcher failure even when a process was spawned',async()=>{const launch=vi.fn(()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',1);});return child;});await expect(openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:launch})).rejects.toThrow(/terminal.*exit|launcher.*exit/i);}); + + it('requests the saved Windows Terminal with its literal data home in a new tab',async()=>{ const recorded=spawnRecorder(); - await openAlarmControls({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:windows-terminal'},nodePath:'C:\\Node\\node.exe',cliPath:'C:\\RadioCLI\\cli.js',spawn:recorded.spawn}); - expect(recorded.calls[0]).toEqual({command:'wt.exe',args:['-w','new','new-tab','--title','RadioCLI Alarm','C:\\Node\\node.exe','C:\\RadioCLI\\cli.js']}); + const home='C:\\Radio %PATH%! & "quotes" 单播'; + await openAlarmControls({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:windows-terminal',RADIOCLI_HOME:home},nodePath:'C:\\Node\\node.exe',cliPath:'C:\\RadioCLI\\cli.js',spawn:recorded.spawn,resolve:command=>command}); + expect(recorded.calls[0]?.command).toBe('wt.exe'); + expect(recorded.calls[0]?.args.slice(0,7)).toEqual(['-w','new','new-tab','--title','RadioCLI Alarm','powershell.exe','-NoLogo']); + const invocation=powershellInvocation(recorded.calls[0]!.args); + expect(invocation.command).toBe('C:\\Node\\node.exe'); + expect(bootstrapPayload(invocation.args)).toEqual({args:['C:\\RadioCLI\\cli.js'],environment:{RADIOCLI_HOME:home}}); }); + it.each(['freebsd','openbsd','netbsd'] as const)('requests an installed graphical terminal on %s',async platform=>{const recorded=spawnRecorder();const result=await openAlarmControls({platform,env:{DISPLAY:':0',TERMINAL:'xterm'},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined});expect(result).toMatchObject({opened:false,requested:true,terminal:`${platform}:/usr/local/bin/xterm`});expect(recorded.calls[0]).toEqual({command:'/usr/local/bin/xterm',args:['-e','/node','/cli.js']});}); + it('does not open a second terminal when a RadioCLI TUI is already live',async()=>{ const recorded=spawnRecorder(); const result=await openAlarmControls({platform:'linux',env:{RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,hasLiveTui:()=>true}); @@ -44,24 +68,25 @@ describe('alarm terminal launcher',()=>{ it('surfaces a denied macOS terminal permission without affecting other platforms',async()=>{const spawn=vi.fn(()=>{const child=new EventEmitter() as ChildProcess;queueMicrotask(()=>child.emit('close',1));return child;});await expect(prepareAlarmTerminalAccess({platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal'},spawn})).rejects.toThrow(/permission.*automatic ringing controls/i);await expect(prepareAlarmTerminalAccess({platform:'win32',spawn})).resolves.toBeUndefined();expect(spawn).toHaveBeenCalledOnce();}); - it('rejects setup verification when no graphical terminal can expose ringing controls',async()=>{await expect(verifyAlarmTerminalLaunch({platform:'freebsd',timeoutMs:10})).rejects.toThrow(/no supported graphical terminal/i);}); + it('rejects setup verification when no graphical terminal can expose ringing controls',async()=>{await expect(verifyAlarmTerminalLaunch({platform:'freebsd',env:{},timeoutMs:10})).rejects.toThrow(/no supported graphical terminal/i);}); - it('requires the launched terminal process to complete an authenticated callback',async()=>{const launch=vi.fn((_command:string,args:readonly string[])=>{const index=args.indexOf(process.execPath);expect(index).toBeGreaterThanOrEqual(0);return nodeSpawn(process.execPath,args.slice(index+1),{stdio:'ignore'});});await expect(verifyAlarmTerminalLaunch({platform:'win32',env:{WT_SESSION:'test'},nodePath:process.execPath,spawn:launch,timeoutMs:2_000})).resolves.toBe('win32:windows-terminal');expect(launch).toHaveBeenCalledOnce();}); + it('requires the launched terminal process to complete an authenticated callback',async()=>{const launch=vi.fn((_command:string,args:readonly string[])=>{const invocation=powershellInvocation(args);expect(invocation.command).toBe(process.execPath);expect(args).not.toContain('-NoExit');return nodeSpawn(invocation.command,invocation.args,{stdio:'ignore'});});await expect(verifyAlarmTerminalLaunch({platform:'win32',env:{WT_SESSION:'test'},nodePath:process.execPath,spawn:launch,resolve:command=>command,timeoutMs:2_000})).resolves.toBe('win32:windows-terminal');expect(launch).toHaveBeenCalledOnce();}); it('bounds and closes a callback connection that never sends its token', async () => { let socket: Socket | undefined; const launch = vi.fn((_command: string, args: readonly string[]) => { const child = new EventEmitter() as ChildProcess; child.unref = vi.fn(); - const port = Number(args.at(-3)); - const host = args.at(-1)!; + const probeArgs = bootstrapPayload(powershellInvocation(args).args).args; + const port = Number(probeArgs.at(-3)); + const host = probeArgs.at(-1)!; socket = connect(port, host); socket.on('error', () => undefined); queueMicrotask(() => child.emit('spawn')); return child; }); try { - await expect(verifyAlarmTerminalLaunch({platform: 'win32', env: {WT_SESSION: 'test'}, spawn: launch, timeoutMs: 30})).rejects.toThrow(/did not connect back/i); + await expect(verifyAlarmTerminalLaunch({platform: 'win32', env: {WT_SESSION: 'test'}, spawn: launch, resolve: command => command, timeoutMs: 30})).rejects.toThrow(/did not connect back/i); } finally { socket?.destroy(); } }); }); diff --git a/src/alarms/terminal-launcher.ts b/src/alarms/terminal-launcher.ts index c21773f..2490a4a 100644 --- a/src/alarms/terminal-launcher.ts +++ b/src/alarms/terminal-launcher.ts @@ -1,77 +1,49 @@ import {spawn, type ChildProcess} from 'node:child_process'; -import {existsSync} from 'node:fs'; import {createServer, type Socket} from 'node:net'; import {randomBytes} from 'node:crypto'; -import {basename, delimiter, join} from 'node:path'; import {listenLoopback} from '../platform/loopback.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; +import {detectGraphicalTerminal, launchTerminalCommand, type TerminalOptions, type TerminalResolver} from '../platform/terminals.js'; -export type AlarmTerminalLaunchResult = {opened: boolean; terminal: string; message: string}; -type Spawn = (command: string, args: readonly string[]) => ChildProcess; -type LaunchOptions = { - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; +export type AlarmTerminalLaunchResult = {opened: boolean; requested?: boolean; terminal: string; message: string}; +type LaunchOptions = TerminalOptions & { nodePath: string; cliPath: string; - spawn?: Spawn; hasLiveTui?: () => boolean; + timeoutMs?: number; }; -type PermissionOptions={platform?:NodeJS.Platform;env?:NodeJS.ProcessEnv;spawn?:Spawn;permissionTimeoutMs?:number}; -type ProbeOptions=PermissionOptions&{nodePath?:string;timeoutMs?:number}; +type PermissionOptions = TerminalOptions & {permissionTimeoutMs?: number}; +type ProbeOptions = PermissionOptions & {nodePath?: string; timeoutMs?: number}; -const linuxTerminals = new Set(['alacritty','foot','ghostty','gnome-terminal','kitty','konsole','mate-terminal','qterminal','terminator','tilix','wezterm','xfce4-terminal','x-terminal-emulator']); - -export function detectAlarmTerminal(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): string { - const configured = env.RADIOCLI_ALARM_TERMINAL?.trim(); - if (configured && validDescriptor(configured, platform)) return configured; - if (platform === 'darwin') { - const program = `${env.TERM_PROGRAM ?? ''} ${env.__CFBundleIdentifier ?? ''}`.toLowerCase(); - if (program.includes('iterm')) return 'darwin:iterm'; - if (program.includes('wezterm')) return 'darwin:wezterm'; - if (program.includes('ghostty')) return 'darwin:ghostty'; - if (program.includes('kitty')) return 'darwin:kitty'; - return 'darwin:apple-terminal'; - } - if (platform === 'win32') return env.WT_SESSION ? 'win32:windows-terminal' : 'win32:console'; - if (platform === 'linux') { - const requested = env.TERMINAL?.trim() || env.TERM_PROGRAM?.trim(); - const resolved = requested ? resolveExecutable(requested, env) : undefined; - if (resolved && linuxTerminals.has(basename(resolved))) return `linux:${resolved}`; - for (const command of ['ghostty','wezterm','kitty','gnome-terminal','konsole','xfce4-terminal','x-terminal-emulator']) { - const path = resolveExecutable(command, env); if (path) return `linux:${path}`; - } - } - return `${platform}:unsupported`; +export function detectAlarmTerminal(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, resolve?: TerminalResolver): string { + return detectGraphicalTerminal(platform, env, resolve); } export async function openAlarmControls(options: LaunchOptions): Promise { - if (options.hasLiveTui?.()) return {opened:false,terminal:'existing-tui',message:'An existing RadioCLI TUI will show the ringing controls.'}; - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const terminal = detectAlarmTerminal(platform, env); - const command = shellCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME); - const directArgs = environmentCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME); - const launch = options.spawn ?? spawnDetached; - if (terminal === 'darwin:apple-terminal') await launched(launch('/usr/bin/osascript', appleTerminalScript(command))); - else if (terminal === 'darwin:iterm') await launched(launch('/usr/bin/osascript', iTermScript(command))); - else if (terminal === 'darwin:wezterm') await launched(launch('/usr/bin/open', ['-na','WezTerm','--args','start','--always-new-process','--',...directArgs])); - else if (terminal === 'darwin:ghostty') await launched(launch('/usr/bin/open', ['-na','Ghostty','--args','-e',...directArgs])); - else if (terminal === 'darwin:kitty') await launched(launch('/usr/bin/open', ['-na','kitty','--args','--detach',...directArgs])); - else if (terminal === 'win32:windows-terminal') await launched(launch('wt.exe', ['-w','new','new-tab','--title','RadioCLI Alarm',options.nodePath,options.cliPath])); - else if (terminal === 'win32:console') await launched(launch('cmd.exe', ['/d','/c','start','RadioCLI Alarm','cmd.exe','/k',windowsCommand(options.nodePath,options.cliPath,env.RADIOCLI_HOME)])); - else if (terminal.startsWith('linux:')) { - const executable = terminal.slice('linux:'.length); if (!linuxTerminals.has(basename(executable))) throw new Error('Saved Linux terminal is not supported.'); - const name = basename(executable); const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' ? ['--'] : name === 'wezterm' ? ['start','--always-new-process','--'] : ['-e']; - await launched(launch(executable,[...prefix,...directArgs])); - } else throw new Error('No supported graphical terminal was found. Open radiocli manually and press ! for alarm controls.'); - return {opened:true,terminal,message:'Opened RadioCLI alarm controls in the saved terminal.'}; + if (options.hasLiveTui?.()) return {opened: false, terminal: 'existing-tui', message: 'An existing RadioCLI TUI will show the ringing controls.'}; + const terminal = await launchTerminalCommand({...options, args: [options.cliPath], title: 'RadioCLI Alarm'}); + if (!options.hasLiveTui) return {opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls; TUI startup is unverified.'}; + const deadline = Date.now() + (options.timeoutMs ?? 8_000); + while (true) { + if (options.hasLiveTui()) return {opened: true, requested: true, terminal, message: 'RadioCLI alarm controls are ready in the saved terminal.'}; + const remaining = deadline - Date.now(); + if (remaining <= 0) return {opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls, but the TUI did not become ready before verification timed out. It may still be starting.'}; + await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining))); + } } /** Ask for macOS Automation access while the user is configuring the alarm. */ -export async function prepareAlarmTerminalAccess(options:PermissionOptions={}):Promise{ - const platform=options.platform??process.platform;if(platform!=='darwin')return; - const terminal=detectAlarmTerminal(platform,options.env??process.env);const application=terminal==='darwin:apple-terminal'?'Terminal':terminal==='darwin:iterm'?'iTerm':undefined;if(!application)return; - const launch=options.spawn??spawnAttached;const child=launch('/usr/bin/osascript',['-e',`tell application "${application}" to count windows`]); - const code=await completed(child,options.permissionTimeoutMs??60_000);if(code!==0)throw new Error(`macOS did not grant RadioCLI permission to control ${application}. Audio can still play, but automatic ringing controls cannot open. Enable Node under System Settings > Privacy & Security > Automation, then press Repair.`); +export async function prepareAlarmTerminalAccess(options: PermissionOptions = {}): Promise { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + if (nativeAdapters(identifyPlatform({platform, env})).terminal !== 'macos') return; + const terminal = detectAlarmTerminal(platform, env, options.resolve); + const application = terminal === 'darwin:apple-terminal' ? 'Terminal' : terminal === 'darwin:iterm' ? 'iTerm' : undefined; + if (!application) return; + const launch = options.spawn ?? spawnAttached; + const child = launch('/usr/bin/osascript', ['-e', `tell application "${application}" to count windows`]); + const code = await completed(child, options.permissionTimeoutMs ?? 60_000); + if (code !== 0) throw new Error(`macOS did not grant RadioCLI permission to control ${application}. Audio can still play, but automatic ringing controls cannot open. Enable Node under System Settings > Privacy & Security > Automation, then press Repair.`); } /** @@ -79,57 +51,78 @@ export async function prepareAlarmTerminalAccess(options:PermissionOptions={}):P * terminal exits immediately after proving that a native background job can * expose the ringing TUI on this desktop session. */ -export async function verifyAlarmTerminalLaunch(options:ProbeOptions={}):Promise{ - const platform=options.platform??process.platform;const env=options.env??process.env; +export async function verifyAlarmTerminalLaunch(options: ProbeOptions = {}): Promise { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; await prepareAlarmTerminalAccess(options); - const terminal=detectAlarmTerminal(platform,env);if(terminal.endsWith(':unsupported'))throw new Error('No supported graphical terminal was found for automatic alarm controls.'); - const token=randomBytes(24).toString('base64url');const server=createServer(); - const address=await listenLoopback(server); - const script="const n=require('node:net');const s=n.connect(Number(process.argv[1]),process.argv[3],()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));"; - const direct=[options.nodePath??process.execPath,'-e',script,String(address.port),token,address.host]; - const command=`${direct.map(shellQuote).join(' ')}; exit`; - const launch=options.spawn??spawnDetached; - const sockets=new Set(); - const received=new Promise((resolve,reject)=>{server.on('connection',socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));let value='';socket.setEncoding('utf8');socket.on('data',chunk=>{value+=chunk;if(value.length>token.length){socket.destroy();reject(new Error('The terminal verification response was not authentic.'));}});socket.on('end',()=>value===token?resolve():reject(new Error('The terminal verification response was not authentic.')));socket.on('error',reject);});}); + const terminal = detectAlarmTerminal(platform, env, options.resolve); + if (terminal.endsWith(':unsupported')) throw new Error('No supported graphical terminal was found for automatic alarm controls.'); + const token = randomBytes(24).toString('base64url'); + const server = createServer(); + const address = await listenLoopback(server); + const script = "const n=require('node:net');const s=n.connect(Number(process.argv[1]),process.argv[3],()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));"; + const nodePath = options.nodePath ?? process.execPath; + const args = ['-e', script, String(address.port), token, address.host]; + const sockets = new Set(); + const received = new Promise((resolve, reject) => { + server.on('connection', socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + let value = ''; + socket.setEncoding('utf8'); + socket.on('data', chunk => { + value += chunk; + if (value.length > token.length) { + socket.destroy(); + reject(new Error('The terminal verification response was not authentic.')); + } + }); + socket.on('end', () => value === token ? resolve() : reject(new Error('The terminal verification response was not authentic.'))); + socket.on('error', reject); + }); + }); // A callback can fail while the launcher is still reporting its own result. // Keep the rejection observed until it is awaited below. - void received.catch(()=>undefined); - try{ - if(terminal==='darwin:apple-terminal')await launched(launch('/usr/bin/osascript',appleTerminalScript(command))); - else if(terminal==='darwin:iterm')await launched(launch('/usr/bin/osascript',iTermScript(command))); - else if(terminal==='darwin:wezterm')await launched(launch('/usr/bin/open',['-na','WezTerm','--args','start','--always-new-process','--',...direct])); - else if(terminal==='darwin:ghostty')await launched(launch('/usr/bin/open',['-na','Ghostty','--args','-e',...direct])); - else if(terminal==='darwin:kitty')await launched(launch('/usr/bin/open',['-na','kitty','--args','--detach',...direct])); - else if(terminal==='win32:windows-terminal')await launched(launch('wt.exe',['-w','new','new-tab','--title','RadioCLI Alarm Verification',...direct])); - else if(terminal==='win32:console')await launched(launch('cmd.exe',['/d','/c','start','RadioCLI Alarm Verification','cmd.exe','/c',windowsCommandArgs(direct)])); - else if(terminal.startsWith('linux:')){const executable=terminal.slice(6);const name=basename(executable);const prefix=name==='gnome-terminal'||name==='mate-terminal'||name==='xfce4-terminal'?['--']:name==='wezterm'?['start','--always-new-process','--']:['-e'];await launched(launch(executable,[...prefix,...direct]));} - else throw new Error('The saved terminal is not supported for alarm controls.'); - await withTimeout(received,options.timeoutMs??8_000,'The terminal opened but did not connect back to RadioCLI.'); + void received.catch(() => undefined); + try { + await launchTerminalCommand({...options, nodePath, args, title: 'RadioCLI Alarm Verification', closeOnExit: true}); + await withTimeout(received, options.timeoutMs ?? 8_000, 'The terminal opened but did not connect back to RadioCLI.'); return terminal; - }finally{for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(()=>resolve()));} + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } } -function validDescriptor(value:string,platform:NodeJS.Platform):boolean { - if(platform==='darwin')return['darwin:apple-terminal','darwin:iterm','darwin:wezterm','darwin:ghostty','darwin:kitty'].includes(value); - if(platform==='win32')return value==='win32:windows-terminal'||value==='win32:console'; - if(platform==='linux'&&value.startsWith('linux:'))return linuxTerminals.has(basename(value.slice(6))); - return false; +function spawnAttached(command: string, args: readonly string[]): ChildProcess { + return spawn(command, [...args], {stdio: 'ignore', windowsHide: true}); } -function resolveExecutable(input:string,env:NodeJS.ProcessEnv):string|undefined { - if(input.includes('/')&&existsSync(input))return input; - for(const directory of (env.PATH??'').split(delimiter)){const path=join(directory,input);if(existsSync(path))return path;} - return undefined; + +function completed(child: ChildProcess, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (work: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + work(); + }; + const timer = setTimeout(() => { + child.kill('SIGTERM'); + finish(() => reject(new Error('Timed out waiting for the macOS Automation permission response.'))); + }, timeoutMs); + child.once('error', error => finish(() => reject(error))); + child.once('close', code => finish(() => resolve(code ?? 1))); + }); +} + +async function withTimeout(promise: Promise, milliseconds: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), milliseconds); + })]); + } finally { + if (timer) clearTimeout(timer); + } } -function shellQuote(value:string):string{return `'${value.replaceAll("'",`'\\''`)}'`;} -function shellCommand(nodePath:string,cliPath:string,home?:string):string{return `${home?`RADIOCLI_HOME=${shellQuote(home)} `:''}${shellQuote(nodePath)} ${shellQuote(cliPath)}`;} -function environmentCommand(nodePath:string,cliPath:string,home?:string):string[]{return home?['/usr/bin/env',`RADIOCLI_HOME=${home}`,nodePath,cliPath]:[nodePath,cliPath];} -function windowsCommand(nodePath:string,cliPath:string,home?:string):string{return `${home?`set "RADIOCLI_HOME=${cmdEscape(home)}" && `:''}"${nodePath.replaceAll('"','""')}" "${cliPath.replaceAll('"','""')}"`;} -function windowsCommandArgs(values:string[]):string{return values.map(value=>`"${value.replaceAll('"','""')}"`).join(' ');} -function cmdEscape(value:string){return value.replaceAll('%','%%').replaceAll('"','""').replaceAll('^','^^').replaceAll('&','^&').replaceAll('|','^|').replaceAll('<','^<').replaceAll('>','^>');} -function appleTerminalScript(command:string):string[]{return['-e','on run argv','-e','tell application "Terminal"','-e','activate','-e','do script (item 1 of argv)','-e','end tell','-e','end run',command];} -function iTermScript(command:string):string[]{return['-e','on run argv','-e','tell application "iTerm"','-e','activate','-e','set w to (create window with default profile)','-e','tell current session of w to write text (item 1 of argv)','-e','end tell','-e','end run',command];} -function spawnDetached(command:string,args:readonly string[]):ChildProcess{return spawn(command,[...args],{detached:true,stdio:'ignore',windowsHide:false});} -function spawnAttached(command:string,args:readonly string[]):ChildProcess{return spawn(command,[...args],{stdio:'ignore',windowsHide:true});} -function launched(child:ChildProcess):Promise{return new Promise((resolve,reject)=>{child.once('error',reject);child.once('spawn',()=>{child.unref();resolve();});});} -function completed(child:ChildProcess,timeoutMs:number):Promise{return new Promise((resolve,reject)=>{let settled=false;const finish=(work:()=>void)=>{if(settled)return;settled=true;clearTimeout(timer);work();};const timer=setTimeout(()=>{child.kill('SIGTERM');finish(()=>reject(new Error('Timed out waiting for the macOS Automation permission response.')));},timeoutMs);child.once('error',error=>finish(()=>reject(error)));child.once('close',code=>finish(()=>resolve(code??1)));});} -async function withTimeout(promise:Promise,milliseconds:number,message:string):Promise{let timer:NodeJS.Timeout|undefined;try{return await Promise.race([promise,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(message)),milliseconds);})]);}finally{if(timer)clearTimeout(timer);}} diff --git a/src/platform/shell.test.ts b/src/platform/shell.test.ts index 13d4f0a..3f477cf 100644 --- a/src/platform/shell.test.ts +++ b/src/platform/shell.test.ts @@ -17,8 +17,10 @@ describe('PowerShell command transport',()=>{ it.skipIf(process.platform!=='win32')('preserves special data-home characters under native Windows PowerShell',async()=>{ const powershell=resolveCommandDetails('powershell.exe').path;expect(powershell).not.toBeNull();const home='C:\\Data %PATH%! & "quoted" \' 单播'; - const args=powershellCommand([process.execPath,'-e',"process.stdout.write(process.env.RADIOCLI_HOME)"],{RADIOCLI_HOME:home}); + // Keep captured output ASCII so this checks environment transport without + // depending on the Windows console's separate legacy code-page behavior. + const args=powershellCommand([process.execPath,'-e',"process.stdout.write(Buffer.from(process.env.RADIOCLI_HOME,'utf8').toString('base64'))"],{RADIOCLI_HOME:home}); const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{const child=spawn(powershell!,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',chunk=>stdout+=String(chunk));child.stderr.on('data',chunk=>stderr+=String(chunk));child.once('error',reject);child.once('close',code=>resolve({code:code??1,stdout,stderr}));}); - expect(result).toEqual({code:0,stdout:home,stderr:''}); + expect(result.code).toBe(0);expect(result.stderr).toBe('');expect(Buffer.from(result.stdout,'base64').toString('utf8')).toBe(home); }); }); diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts new file mode 100644 index 0000000..6cc32d0 --- /dev/null +++ b/src/platform/terminals.test.ts @@ -0,0 +1,55 @@ +import {execFileSync,type ChildProcess} from 'node:child_process'; +import {EventEmitter} from 'node:events'; +import {mkdtempSync,mkdirSync,rmSync,writeFileSync} from 'node:fs'; +import {basename,join,posix} from 'node:path'; +import {afterEach,describe,expect,it,vi} from 'vitest'; +import {createTerminalLaunch,detectGraphicalTerminal,waitForLaunch} from './terminals.js'; + +const roots:string[]=[]; +afterEach(()=>{vi.useRealTimers();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); +const resolve=(command:string)=>command; +function fixture(){const root=mkdtempSync(join(process.cwd(),'.radiocli-terminals-'));roots.push(root);return root;} +function terminalPath(root:string,name:string){return posix.join(basename(root),name);} + +describe('shared graphical terminal discovery',()=>{ + it.each(['linux','freebsd','openbsd','netbsd'] as const)('requires the central Unix adapter policy and an installed terminal on %s',platform=>{const env={DISPLAY:':0',TERMINAL:'xterm'};expect(detectGraphicalTerminal(platform,env,command=>command==='xterm'?'/usr/local/bin/xterm':undefined)).toBe(`${platform}:/usr/local/bin/xterm`);expect(detectGraphicalTerminal(platform,env,()=>undefined)).toBe(`${platform}:unsupported`);expect(detectGraphicalTerminal(platform,{...env,DISPLAY:''},resolve)).toBe(`${platform}:unsupported`);}); + it.each(['freebsd','openbsd','netbsd'] as const)('accepts a legacy Linux descriptor after revalidating the saved Unix terminal on %s',platform=>{expect(detectGraphicalTerminal(platform,{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/local/bin/xterm'},resolve)).toBe('linux:/usr/local/bin/xterm');expect(detectGraphicalTerminal(platform,{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/local/bin/xterm'},()=>undefined)).toBe(`${platform}:unsupported`);}); + it('requires an X11 or Wayland session before selecting a Unix terminal',()=>{expect(detectGraphicalTerminal('linux',{RADIOCLI_ALARM_TERMINAL:'linux:/bin/kitty'},resolve)).toBe('linux:unsupported');}); + it('revalidates saved executable paths and refuses stale files and directories',()=>{const root=fixture();const kitty=join(root,'kitty');mkdirSync(kitty);const env={DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:`linux:${terminalPath(root,'kitty')}`};expect(detectGraphicalTerminal('linux',env)).toBe('linux:unsupported');rmSync(kitty,{recursive:true});expect(detectGraphicalTerminal('linux',env)).toBe('linux:unsupported');}); + it.skipIf(process.platform==='win32')('refuses a saved Unix terminal without executable permission',()=>{const root=fixture();writeFileSync(join(root,'kitty'),'stub',{mode:0o600});expect(detectGraphicalTerminal('linux',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:`linux:${terminalPath(root,'kitty')}`})).toBe('linux:unsupported');}); + it('resolves installed terminals from the supplied PATH',()=>{const root=fixture();writeFileSync(join(root,'kitty'),'stub',{mode:0o700});expect(detectGraphicalTerminal('linux',{DISPLAY:':0',PATH:basename(root),TERMINAL:'kitty'})).toBe(`linux:${terminalPath(root,'kitty')}`);}); + it('does not select an X11-only terminal for a Wayland-only session',()=>{expect(detectGraphicalTerminal('linux',{WAYLAND_DISPLAY:'wayland-0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/xterm'},resolve)).toBe('linux:unsupported');}); + it('does not select a Wayland-only terminal for an X11-only session',()=>{expect(detectGraphicalTerminal('linux',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/foot'},resolve)).toBe('linux:unsupported');}); + it('does not accept a saved Linux descriptor on unsupported runtimes',()=>{expect(detectGraphicalTerminal('linux',{TERMUX_VERSION:'0.118.3',DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},resolve)).toBe('termux:unsupported');expect(detectGraphicalTerminal('haiku',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},resolve)).toBe('haiku:unsupported');}); +}); + +describe('graphical terminal invocation plans',()=>{ + it.each([ + ['gnome-terminal',['--']],['mate-terminal',['-x']],['xfce4-terminal',['-x']],['terminator',['-x']], + ['wezterm',['start','--always-new-process','--']],['kitty',['-e']],['alacritty',['-e']],['konsole',['-e']],['ghostty',['-e']],['tilix',['-e']],['x-terminal-emulator',['-e']],['xterm',['-e']],['uxterm',['-e']],['foot',['--']] + ] as const)('uses %s argument boundaries without joining application argv', (name,prefix)=>{const nodePath='/Node A/单播/node';const args=['/Radio A/cli.js','agent-ui','a;& quoted "value"'];const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',WAYLAND_DISPLAY:'wayland-0',RADIOCLI_ALARM_TERMINAL:`linux:/tools/${name}`},nodePath,args,resolve});expect(plan).toMatchObject({command:`/tools/${name}`,args:[...prefix,nodePath,...args]});}); + it.skipIf(process.platform==='win32')('uses a literal-safe shell boundary for qterminal command parsing',()=>{const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/tools/qterminal'},nodePath:process.execPath,args:['-e','process.stdout.write(process.argv[1])','--',"Data ' \" ; $() 单播"],resolve});expect(plan.args.slice(0,3)).toEqual(['-e','/bin/sh','-c']);expect(execFileSync('/bin/sh',plan.args.slice(2),{encoding:'utf8'})).toBe("Data ' \" ; $() 单播");}); + it('preserves a special data home and every argument through the Windows Node bootstrap',()=>{ + const home='C:\\Data %PATH%! & "quote" \' 单播';const values=['space value','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; + const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home},nodePath:process.execPath,args:['-e',"process.stdout.write(JSON.stringify({args:process.argv.slice(1),home:process.env.RADIOCLI_HOME}))",'--',...values],resolve,closeOnExit:true}); + const script=Buffer.from(plan.args.at(-1)!,'base64').toString('utf16le');const payload=JSON.parse(Buffer.from(/FromBase64String\('([^']+)'\)/.exec(script)![1]!,'base64').toString('utf8')) as {command:string;args:string[]}; + expect(payload.args[1]).not.toContain('"');const output=execFileSync(payload.command,payload.args,{encoding:'utf8'});expect(JSON.parse(output)).toEqual({args:values,home});expect(plan.command).toBe('powershell.exe'); + }); + it.skipIf(process.platform!=='win32')('round-trips literal CLI paths and argv through native Windows PowerShell',()=>{ + const root=fixture();const cli=join(root,"Radio %PATH%! & ' 单播.cjs"); + // ASCII output separates exact argv transport from console code pages. + writeFileSync(cli,"process.stdout.write(Buffer.from(JSON.stringify({args:process.argv.slice(2),home:process.env.RADIOCLI_HOME}),'utf8').toString('base64'))"); + const home='C:\\Data %PATH%! & "quote" \' 单播';const args=['spaces here','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; + const plan=createTerminalLaunch({platform:'win32',env:{...process.env,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home},nodePath:process.execPath,args:[cli,...args],closeOnExit:true}); + const output=execFileSync(plan.command,plan.args,{encoding:'utf8',windowsHide:true,timeout:10_000}); + expect(JSON.parse(Buffer.from(output,'base64').toString('utf8'))).toEqual({args,home}); + }); + it('uses the same data-home bootstrap without assuming /usr/bin/env exists',()=>{const home="/Data A/O'Brien 单播";const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/tools/kitty',RADIOCLI_HOME:home},nodePath:process.execPath,args:['-e','process.stdout.write(process.env.RADIOCLI_HOME)'],resolve});expect(plan.args).not.toContain('/usr/bin/env');expect(execFileSync(process.execPath,plan.args.slice(2),{encoding:'utf8'})).toBe(home);}); + it('fails before spawning when Windows has no runnable PowerShell',()=>{expect(()=>createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve:()=>undefined})).toThrow(/PowerShell.*unavailable/i);}); +}); + +describe('terminal launcher acceptance',()=>{ + it('rejects a launcher that exits unsuccessfully immediately after spawn',async()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();const result=waitForLaunch(child);child.emit('spawn');child.emit('close',1);await expect(result).rejects.toThrow(/launcher.*exit.*1/i);}); + it('accepts a live launcher without claiming application readiness',async()=>{vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();const result=waitForLaunch(child);child.emit('spawn');await vi.advanceTimersByTimeAsync(100);await expect(result).resolves.toBeUndefined();expect(child.unref).toHaveBeenCalledOnce();}); + it('bounds a launcher that never reports process startup',async()=>{vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;const result=expect(waitForLaunch(child)).rejects.toThrow(/did not report process startup/i);await vi.advanceTimersByTimeAsync(3_000);await result;}); +}); diff --git a/src/platform/terminals.ts b/src/platform/terminals.ts new file mode 100644 index 0000000..5760aac --- /dev/null +++ b/src/platform/terminals.ts @@ -0,0 +1,123 @@ +import {spawn,type ChildProcess} from 'node:child_process'; +import {posix,win32} from 'node:path'; +import {resolveCommandDetails} from './executables.js'; +import {identifyPlatform,nativeAdapters} from './runtime.js'; +import {powershellCommand} from './shell.js'; + +export type TerminalResolver=(command:string)=>string|undefined; +type TerminalSpawn=(command:string,args:readonly string[])=>ChildProcess; +export type TerminalOptions={platform?:NodeJS.Platform;env?:NodeJS.ProcessEnv;resolve?:TerminalResolver;spawn?:TerminalSpawn}; +type TerminalCommand=TerminalOptions&{nodePath:string;args:readonly string[];title?:string;closeOnExit?:boolean}; +type TerminalLaunch={terminal:string;command:string;args:string[]}; + +type UnixTerminal={name:string;args:string[];display?:'x11'|'wayland';shell?:boolean}; +const unixTerminals:UnixTerminal[]=[ + {name:'ghostty',args:['-e']},{name:'wezterm',args:['start','--always-new-process','--']}, + {name:'kitty',args:['-e']},{name:'gnome-terminal',args:['--']},{name:'konsole',args:['-e']}, + {name:'xfce4-terminal',args:['-x']},{name:'x-terminal-emulator',args:['-e']}, + {name:'alacritty',args:['-e']},{name:'foot',args:['--'],display:'wayland'}, + {name:'mate-terminal',args:['-x']},{name:'qterminal',args:['-e'],shell:true}, + {name:'terminator',args:['-x']},{name:'tilix',args:['-e']}, + {name:'xterm',args:['-e'],display:'x11'},{name:'uxterm',args:['-e'],display:'x11'} +]; +const macTerminals=['darwin:apple-terminal','darwin:iterm','darwin:wezterm','darwin:ghostty','darwin:kitty']; + +export function detectGraphicalTerminal(platform:NodeJS.Platform=process.platform,env:NodeJS.ProcessEnv=process.env,resolve:TerminalResolver=resolver(platform,env)):string{ + const host=identifyPlatform({platform,env});const adapter=nativeAdapters(host).terminal; + const unsupported=`${host.id==='unknown'?platform:host.id}:unsupported`; + const configured=env.RADIOCLI_ALARM_TERMINAL?.trim(); + if(adapter==='macos'){ + if(configured&&macTerminals.includes(configured))return configured; + const program=`${env.TERM_PROGRAM??''} ${env.__CFBundleIdentifier??''}`.toLowerCase(); + for(const name of ['iterm','wezterm','ghostty','kitty'])if(program.includes(name))return`darwin:${name}`; + return'darwin:apple-terminal'; + } + if(adapter==='windows'){ + if(configured==='win32:windows-terminal'||configured==='win32:console')return configured; + return env.WT_SESSION?'win32:windows-terminal':'win32:console'; + } + if(adapter!=='unix'||!hasDisplay(env))return unsupported; + if(configured){ + const separator=configured.indexOf(':');const prefix=configured.slice(0,separator); + if(prefix===host.id||prefix==='linux'){ + const path=resolveUnixTerminal(configured.slice(separator+1),env,resolve); + return path?`${prefix}:${path}`:unsupported; + } + } + const requested=env.TERMINAL?.trim()||env.TERM_PROGRAM?.trim(); + const selected=requested&&resolveUnixTerminal(requested,env,resolve); + if(selected)return`${host.id}:${selected}`; + for(const terminal of unixTerminals){const path=resolveUnixTerminal(terminal.name,env,resolve);if(path)return`${host.id}:${path}`;} + return unsupported; +} + +export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ + const platform=options.platform??process.platform;const env=options.env??process.env;const resolve=options.resolve??resolver(platform,env); + const terminal=detectGraphicalTerminal(platform,env,resolve);const values=[options.nodePath,...options.args]; + if(values.some(value=>value.includes('\0')))throw new Error('Terminal command values cannot contain NUL bytes.'); + const home=env.RADIOCLI_HOME; + if(terminal.endsWith(':unsupported')){ + if(nativeAdapters(identifyPlatform({platform,env})).terminal==='unix'&&!hasDisplay(env))throw new Error('No graphical desktop session is available: DISPLAY and WAYLAND_DISPLAY are unset. Open radiocli manually for controls.'); + throw new Error('No supported installed graphical terminal was found. Open radiocli manually for controls.'); + } + if(terminal==='darwin:apple-terminal'||terminal==='darwin:iterm'){ + const command=`${home!==undefined?`RADIOCLI_HOME=${shellQuote(home)} `:''}${values.map(shellQuote).join(' ')}${options.closeOnExit?'; exit':''}`; + return{terminal,command:'/usr/bin/osascript',args:terminal==='darwin:apple-terminal'?appleTerminalScript(command):iTermScript(command)}; + } + const direct=home===undefined?values:nodeBootstrap(options.nodePath,options.args,{RADIOCLI_HOME:home}); + if(terminal==='darwin:wezterm')return{terminal,command:'/usr/bin/open',args:['-na','WezTerm','--args','start','--always-new-process','--',...direct]}; + if(terminal==='darwin:ghostty')return{terminal,command:'/usr/bin/open',args:['-na','Ghostty','--args','-e',...direct]}; + if(terminal==='darwin:kitty')return{terminal,command:'/usr/bin/open',args:['-na','kitty','--args','--detach',...direct]}; + if(terminal.startsWith('win32:')){ + const systemRoot=env.SystemRoot??env.WINDIR; + const powershell=resolve('powershell.exe')??(systemRoot?resolve(win32.join(systemRoot,'System32','WindowsPowerShell','v1.0','powershell.exe')):undefined)??resolve('pwsh.exe'); + if(!powershell)throw new Error('PowerShell is unavailable; a RadioCLI terminal cannot be requested.'); + // PowerShell 5 does not preserve arbitrary native argv quoting. Only a + // fixed quote-free Node program and encoded JSON cross that boundary. + const command=nodeBootstrap(options.nodePath,options.args,home===undefined?{}:{RADIOCLI_HOME:home}); + const args=powershellCommand(command,{}, {keepOpen:!options.closeOnExit}); + if(terminal==='win32:console')return{terminal,command:powershell,args}; + const wt=resolve('wt.exe')??(env.LOCALAPPDATA?resolve(win32.join(env.LOCALAPPDATA,'Microsoft','WindowsApps','wt.exe')):undefined); + if(!wt)throw new Error('Windows Terminal is unavailable; the saved terminal cannot be requested.'); + return{terminal,command:wt,args:['-w','new','new-tab','--title',options.title??'RadioCLI',powershell,...args]}; + } + const executable=terminal.slice(terminal.indexOf(':')+1);const spec=unixTerminals.find(item=>item.name===posix.basename(executable)); + if(!spec)throw new Error('The saved graphical terminal is unsupported.'); + // qterminal reparses its first -e argument as a command string. A fixed + // POSIX shell keeps a Node path containing quotes/spaces out of that parser. + if(spec.shell){const sh=resolve('/bin/sh');if(!sh)throw new Error('The POSIX shell required by qterminal is unavailable.');return{terminal,command:executable,args:[...spec.args,sh,'-c',direct.map(shellQuote).join(' ')]};} + return{terminal,command:executable,args:[...spec.args,...direct]}; +} + +/** Request acceptance is separate from the caller's TUI/session verification. */ +export async function launchTerminalCommand(options:TerminalCommand):Promise{ + const plan=createTerminalLaunch(options); + const child=options.spawn?options.spawn(plan.command,plan.args):spawn(plan.command,plan.args,{env:options.env??process.env,detached:true,stdio:'ignore',windowsHide:false}); + await waitForLaunch(child);return plan.terminal; +} + +export function waitForLaunch(child:ChildProcess):Promise{ + return new Promise((resolve,reject)=>{ + let settled=false;let acceptedTimer:NodeJS.Timeout|undefined; + const finish=(error?:Error)=>{if(settled)return;settled=true;clearTimeout(startupTimer);if(acceptedTimer)clearTimeout(acceptedTimer);if(error)reject(error);else resolve();}; + const startupTimer=setTimeout(()=>finish(new Error('Terminal launcher did not report process startup.')),3_000); + child.once('error',error=>finish(error)); + child.once('close',(code,signal)=>finish(code===0?undefined:new Error(`Terminal launcher exited with ${code??signal??'unknown status'}.`))); + child.once('spawn',()=>{child.unref();acceptedTimer=setTimeout(()=>finish(),100);}); + }); +} + +function resolver(platform:NodeJS.Platform,env:NodeJS.ProcessEnv):TerminalResolver{return command=>resolveCommandDetails(command,{platform,env}).path??undefined;} +function hasDisplay(env:NodeJS.ProcessEnv){return Boolean(env.DISPLAY?.trim()||env.WAYLAND_DISPLAY?.trim());} +function resolveUnixTerminal(input:string,env:NodeJS.ProcessEnv,resolve:TerminalResolver):string|undefined{ + const spec=unixTerminals.find(item=>item.name===posix.basename(input).toLowerCase()); + if(!spec||spec.display==='x11'&&!env.DISPLAY?.trim()||spec.display==='wayland'&&!env.WAYLAND_DISPLAY?.trim())return undefined; + const path=resolve(input);return path&&unixTerminals.some(item=>item.name===posix.basename(path))?path:undefined; +} +function shellQuote(value:string):string{return`'${value.replaceAll("'",`'\\''`)}'`;} +function nodeBootstrap(nodePath:string,args:readonly string[],environment:Record):string[]{ + const program="const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...process.env,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);"; + return[nodePath,'-e',program,Buffer.from(JSON.stringify({args,environment}),'utf8').toString('base64url')]; +} +function appleTerminalScript(command:string):string[]{return['-e','on run argv','-e','tell application "Terminal"','-e','activate','-e','do script (item 1 of argv)','-e','end tell','-e','end run',command];} +function iTermScript(command:string):string[]{return['-e','on run argv','-e','tell application "iTerm"','-e','activate','-e','set w to (create window with default profile)','-e','tell current session of w to write text (item 1 of argv)','-e','end tell','-e','end run',command];} From 6d06b9ae032ebb1f8205859f255c74eb957c7751 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:34:21 -0500 Subject: [PATCH 10/25] feat(tui): add portable rendering and offline network modes --- src/cli.test.ts | 15 + src/cli.tsx | 47 +- src/platform/network.test.ts | 274 ++++ src/platform/network.ts | 115 ++ src/platform/terminal.test.ts | 124 ++ src/platform/terminal.ts | 68 + src/player/airplay-discovery.test.ts | 18 +- src/player/airplay-discovery.ts | 4 +- src/providers/radio-browser.test.ts | 84 ++ src/providers/radio-browser.ts | 73 +- src/providers/radio-garden.test.ts | 58 + src/providers/radio-garden.ts | 38 +- src/setup.test.ts | 29 + src/setup.ts | 43 +- src/ui/AdaptiveContent.tsx | 63 +- src/ui/App.tsx | 43 +- .../visual-baseline.test.tsx.snap | 1114 ++++++++--------- src/ui/alarm-tui-integration.test.tsx | 4 + src/ui/ascii.test.ts | 2 + src/ui/ascii.ts | 10 +- src/ui/components/AdaptiveMarquee.tsx | 9 +- src/ui/components/Logo.tsx | 9 +- src/ui/components/Menu.tsx | 6 +- src/ui/components/ScreenHeader.tsx | 2 +- src/ui/components/StationList.tsx | 14 +- src/ui/components/TopTabs.tsx | 4 +- src/ui/display-context.test.ts | 20 +- src/ui/display-context.ts | 16 +- src/ui/portable-app.test.tsx | 149 +++ src/ui/portable-render.test.tsx | 144 +++ src/ui/screens/AirPlayCodeScreen.tsx | 8 +- src/ui/screens/AirPlaySettingsScreen.tsx | 20 +- src/ui/screens/ExploreScreen.tsx | 2 + src/ui/screens/HelpScreen.tsx | 10 +- src/ui/screens/HomeScreen.tsx | 6 +- src/ui/screens/MapScreen.tsx | 2 +- src/ui/screens/NowPlayingScreen.tsx | 16 +- src/ui/screens/SearchScreen.tsx | 8 +- src/ui/screens/SettingsScreen.tsx | 10 +- src/ui/screens/StatsScreen.tsx | 12 +- src/ui/terminal-renderer.ts | 17 + src/update-check.test.ts | 28 + src/update-check.ts | 25 +- 43 files changed, 1994 insertions(+), 769 deletions(-) create mode 100644 src/platform/network.test.ts create mode 100644 src/platform/network.ts create mode 100644 src/platform/terminal.test.ts create mode 100644 src/platform/terminal.ts create mode 100644 src/providers/radio-garden.test.ts create mode 100644 src/ui/portable-app.test.tsx create mode 100644 src/ui/portable-render.test.tsx create mode 100644 src/ui/terminal-renderer.ts diff --git a/src/cli.test.ts b/src/cli.test.ts index 4859fc2..ace4b4e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -151,6 +151,21 @@ describe('CLI command dispatch', () => { } finally { spy.mockRestore(); } }); + it('reports offline and constrained-terminal policy without writing state', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + vi.stubEnv('TERM', 'dumb'); + vi.stubEnv('FORCE_COLOR', '3'); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.network).toMatchObject({status: 'offline', offline: true}); + expect(report.terminal).toMatchObject({unicode: false, colorLevel: 0, reduceMotion: true, interactive: false}); + expect(report.capabilities.unicode.status).toBe('unavailable'); + expect(report.capabilities.color.status).toBe('unavailable'); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + } finally {vi.unstubAllEnvs();} + }); + it('keeps doctor usable if an optional scheduler probe fails', async () => { const adapter = schedulers.createSchedulerAdapter(); const spy = vi.spyOn(schedulers, 'createSchedulerAdapter').mockReturnValue({...adapter, diff --git a/src/cli.tsx b/src/cli.tsx index 12da4f5..f794fa9 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -25,6 +25,8 @@ import {platformCapabilities} from './platform/capabilities.js'; import {hasGraphicalSession} from './platform/desktop.js'; import {detectPackageManager} from './platform/packages.js'; import {storageReadiness} from './platform/storage.js'; +import {resolveTerminalCapabilities} from './platform/terminal.js'; +import {networkDiagnostic} from './platform/network.js'; const runtime = {nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url)}; @@ -34,11 +36,7 @@ if (isDirectRun(process.argv[1], import.meta.url)) { if (args[0] === 'agent-ui') { const encoded = args[1]; if (!encoded) throw new Error('Missing RadioCLI agent startup request.'); - const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); - render(, { - exitOnCtrlC: false, - kittyKeyboard: {mode: 'auto', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']} - }); + await renderTui(encoded); } else if (args[0] === 'agent-host') { await runHeadlessAgentHost(); } else if (args.length > 0) { @@ -47,18 +45,30 @@ if (isDirectRun(process.argv[1], import.meta.url)) { process.exitCode = 1; }); } else { - const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); - render(, { - // App owns Ctrl+C so it can confirm before performing a clean shutdown. - exitOnCtrlC: false, - kittyKeyboard: { - mode: 'auto', - flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] - } - }); + await renderTui(); } } +async function renderTui(encodedCommand?: string): Promise { + if (!process.stdin.isTTY) { + console.error('The interactive TUI needs terminal input. Use radiocli commands in a headless session, or connect with a terminal (for example, ssh -t).'); + printHelp(); + process.exitCode = 1; + return; + } + const {configureTerminalRenderer} = await import('./ui/terminal-renderer.js'); + const terminal = await configureTerminalRenderer(process.env, {isTTY: process.stdout.isTTY, colorDepth: process.stdout.getColorDepth?.()}); + const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); + render(, { + // App owns Ctrl+C so it can confirm before performing a clean shutdown. + exitOnCtrlC: false, + isScreenReaderEnabled: terminal.screenReader, + interactive: terminal.interactive ? undefined : false, + debug: !terminal.interactive, + kittyKeyboard: {mode: terminal.interactive ? 'auto' : 'disabled', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']} + }); +} + export async function runCommand(args: string[]): Promise { const [command, ...rest] = args; @@ -114,6 +124,7 @@ export async function runCommand(args: string[]): Promise { for (const [name, capability] of Object.entries(report.capabilities)) { console.log(`capability_${name}=${capability.status} ${capability.message}`); } + console.log(`network=${report.network.status} ${report.network.message}`); return; } @@ -293,6 +304,7 @@ async function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic ); const airPlay = airPlaySenderHealth(); const host = identifyPlatform(); + const terminal = resolveTerminalCapabilities(process.env, {isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.()}); let scheduler: SchedulerCapabilities | undefined; try { const adapter = createSchedulerAdapter(); @@ -311,7 +323,10 @@ async function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic graphicalSession: hasGraphicalSession(host), packageManager: detectPackageManager(process.platform, host.osRelease), storageWritable: libraryWritesAvailable(), - scheduler + scheduler, + unicode: terminal.unicode, + color: terminal.colorLevel > 0, + screenReader: terminal.screenReader }); return { radioCliVersion: appVersion(), @@ -320,6 +335,8 @@ async function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic architecture: process.arch, host: {id: host.id, arch: host.arch, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl}, capabilities, + terminal, + network: networkDiagnostic(), backends, commands, mpv: redactDiagnostic(mpvDiagnostic), diff --git a/src/platform/network.test.ts b/src/platform/network.test.ts new file mode 100644 index 0000000..a3a4b71 --- /dev/null +++ b/src/platform/network.test.ts @@ -0,0 +1,274 @@ +import {createServer, type Server} from 'node:http'; +import {createServer as createHttpsServer} from 'node:https'; +import {connect} from 'node:net'; +import {spawn} from 'node:child_process'; +import type {Duplex} from 'node:stream'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {networkDiagnostic, networkPolicy, withExternalResponse} from './network.js'; + +const servers: Server[] = []; +const sockets = new Set(); +const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, 'node')!; +const execArgv = [...process.execArgv]; +const proxyVariables = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy']; + +beforeEach(() => { + for (const key of [...proxyVariables, 'NODE_USE_ENV_PROXY', 'NODE_OPTIONS', 'NODE_TLS_REJECT_UNAUTHORIZED', 'RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH']) vi.stubEnv(key, undefined); +}); + +afterEach(async () => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + Object.defineProperty(process.versions, 'node', nodeVersion); + process.execArgv.splice(0, process.execArgv.length, ...execArgv); + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await Promise.all(servers.splice(0).map(server => { + server.closeAllConnections(); + return new Promise(resolve => server.close(() => resolve())); + })); +}); + +describe('public network policy', () => { + it('reports configuration separately from connectivity and redacts proxy credentials', () => { + expect(networkDiagnostic()).toMatchObject({status: 'configured', offline: false}); + vi.stubEnv('HTTPS_PROXY', 'socks5://private-user:private-password@proxy.invalid:1080'); + expect(networkDiagnostic()).toMatchObject({status: 'unavailable'}); + expect(JSON.stringify(networkDiagnostic())).not.toMatch(/private-user|private-password|proxy.invalid/); + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + expect(networkDiagnostic()).toMatchObject({status: 'offline', offline: true}); + }); + + it('keeps offline and low-bandwidth preferences independent and explicitly opt in', () => { + expect(networkPolicy({})).toEqual({offline: false, lowBandwidth: false}); + expect(networkPolicy({RADIOCLI_OFFLINE: '1'})).toEqual({offline: true, lowBandwidth: false}); + expect(networkPolicy({RADIOCLI_LOW_BANDWIDTH: '1'})).toEqual({offline: false, lowBandwidth: true}); + expect(networkPolicy({RADIOCLI_OFFLINE: '0', RADIOCLI_LOW_BANDWIDTH: 'true'})).toEqual({offline: false, lowBandwidth: false}); + }); + + it('does not invoke fetch in offline mode', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/offline/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + for (const variable of ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy']) { + it(`rejects unsupported ${variable} without contacting the destination or exposing credentials`, async () => { + let destinationRequests = 0; + const server = createServer((_request, response) => {destinationRequests += 1;response.end('{}');}); + const url = await start(server); + vi.stubEnv(variable, 'socks5://private-user:private-password@127.0.0.1:9'); + const error = await withExternalResponse(url, {timeoutMs: 100}, response => response.json()).then(() => null, value => value as Error); + expect(error).toBeInstanceOf(Error); + expect(error!.message).toMatch(/SOCKS|ALL_PROXY|all_proxy/); + expect(error!.message).not.toMatch(/private-user|private-password|127\.0\.0\.1:9/); + expect(error!.cause).toBeUndefined(); + expect(destinationRequests).toBe(0); + }); + } + + it('explains that ALL_PROXY alone is not implemented even for HTTP proxy URLs', async () => { + vi.stubEnv('ALL_PROXY', 'http://proxy.invalid:8080'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/ALL_PROXY/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('redacts invalid proxy URLs and invalid percent-encoded credentials', async () => { + const fetchImpl = vi.fn(async () => new Response('{}')); + for (const value of ['not-a-url-private-password', 'http://private-user:private-password%ZZ@proxy.invalid']) { + vi.stubEnv('HTTPS_PROXY', value); + const error = await withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json()).then(() => null, value => value as Error); + expect(error!.message).toMatch(/HTTPS_PROXY/); + expect(error!.message).not.toMatch(/private-user|private-password|proxy\.invalid/); + expect(error!.cause).toBeUndefined(); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('requires native proxy opt-in instead of silently sending configured traffic directly', async () => { + vi.stubEnv('HTTPS_PROXY', 'http://proxy.invalid:8080'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/NODE_USE_ENV_PROXY=1/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('honors lowercase proxy precedence and the fetch HTTPS fallback to HTTP_PROXY', async () => { + vi.stubEnv('NODE_USE_ENV_PROXY', '1'); + vi.stubEnv('HTTP_PROXY', 'socks5://ignored.invalid:1'); + vi.stubEnv('http_proxy', 'http://proxy.invalid:8080'); + vi.stubEnv('ALL_PROXY', 'socks5://also-ignored.invalid:1'); + const fetchImpl = vi.fn(async () => new Response('{"ok":true}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).resolves.toEqual({ok: true}); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('recognizes a launch flag but respects an explicit command-line override', async () => { + vi.stubEnv('HTTPS_PROXY', 'http://proxy.invalid:8080'); + vi.stubEnv('NODE_OPTIONS', '--use-env-proxy'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).resolves.toEqual({}); + process.execArgv.push('--no-use-env-proxy'); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/NODE_USE_ENV_PROXY=1/); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('reports unsupported Node versions before attempting a configured proxy', async () => { + vi.stubEnv('NODE_USE_ENV_PROXY', '1'); + vi.stubEnv('HTTPS_PROXY', 'http://proxy.invalid:8080'); + const fetchImpl = vi.fn(async () => new Response('{}')); + for (const version of ['22.0.0', '22.20.0', '23.11.0']) { + Object.defineProperty(process.versions, 'node', {...nodeVersion, value: version}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/22\.21|24/); + } + expect(fetchImpl).not.toHaveBeenCalled(); + for (const version of ['22.21.0', '24.0.0', '24.5.0']) { + Object.defineProperty(process.versions, 'node', {...nodeVersion, value: version}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).resolves.toEqual({}); + } + }); +}); + +describe('external response lifetime', () => { + it('keeps the deadline alive after real response headers while the body is stalled', async () => { + const server = createServer((_request, response) => {response.writeHead(200, {'content-type': 'application/json'});response.flushHeaders();response.write('{');}); + const url = await start(server); + let receivedHeaders = false; + const started = Date.now(); + await expect(withExternalResponse(url, {timeoutMs: 1000}, response => { + receivedHeaders = true; + return response.json(); + })).rejects.toThrow(/timed out/i); + expect(receivedHeaders).toBe(true); + expect(Date.now() - started).toBeLessThan(4500); + }); + + it('cancels an unused body after a status-only callback', async () => { + const cancel = vi.fn(); + const fetchImpl = async () => new Response(new ReadableStream({cancel}), {status: 503}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.status)).resolves.toBe(503); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('cancels an unused body when the consumer rejects the response', async () => { + const cancel = vi.fn(); + const fetchImpl = async () => new Response(new ReadableStream({cancel}), {status: 503}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, () => {throw new Error('bad status');})).rejects.toThrow('bad status'); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('bounds the callback even when a custom fetch implementation does not react to abort', async () => { + vi.useFakeTimers(); + const cancel = vi.fn(); + const fetchImpl = async () => new Response(new ReadableStream({cancel})); + const outcome = withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, () => new Promise(() => {})).then(() => null, error => error as Error); + await vi.advanceTimersByTimeAsync(100); + expect((await outcome)?.message).toMatch(/timed out/i); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('preserves caller cancellation', async () => { + const controller = new AbortController(); + const reason = new Error('caller cancelled'); + controller.abort(reason); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl, init: {signal: controller.signal}}, response => response.json())).rejects.toBe(reason); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('cancels a late response without invoking its consumer after the deadline', async () => { + vi.useFakeTimers(); + let deliver!: (response: Response) => void; + const fetchImpl = () => new Promise(resolve => {deliver = resolve;}); + const consume = vi.fn((response: Response) => response.status); + const outcome = withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, consume).catch(error => error as Error); + await vi.advanceTimersByTimeAsync(100); + expect(await outcome).toMatchObject({message: expect.stringMatching(/timed out/i)}); + const cancel = vi.fn(); + deliver(new Response(new ReadableStream({cancel}))); + await vi.advanceTimersByTimeAsync(0); + expect(consume).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('preserves TLS certificate verification against a real self-signed server', async () => { + let requests = 0; + const server = createHttpsServer({key: fixtureKey, cert: fixtureCertificate}, (_request, response) => {requests += 1;response.end('{}');}); + const url = (await start(server)).replace('http:', 'https:'); + await expect(withExternalResponse(url, {timeoutMs: 1000}, response => response.json())).rejects.toMatchObject({cause: {code: 'DEPTH_ZERO_SELF_SIGNED_CERT'}}); + expect(requests).toBe(0); + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined(); + }); + + it.skipIf(!process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))('uses the native HTTP proxy in a fresh Node process', async () => { + let originProxyAuthorization: string | undefined; + const origin = createServer((request, response) => {originProxyAuthorization = request.headers['proxy-authorization'];response.end('{"route":"origin"}');}); + const destination = await start(origin); + let proxyConnections = 0; + let authenticatedProxy = false; + const proxy = createServer(); + proxy.on('connect', (request, socket, head) => { + proxyConnections += 1; + authenticatedProxy = request.headers['proxy-authorization'] === `Basic ${Buffer.from('test-user:test-password').toString('base64')}`; + sockets.add(socket); + const upstream = connect(Number(new URL(destination).port), '127.0.0.1', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length) upstream.write(head); + upstream.pipe(socket); + socket.pipe(upstream); + }); + sockets.add(upstream); + socket.on('error', () => upstream.destroy()); + upstream.on('error', () => socket.destroy()); + }); + const proxyUrl = new URL(await start(proxy)); + proxyUrl.username = 'test-user'; + proxyUrl.password = 'test-password'; + const env = {...process.env}; + for (const key of Object.keys(env)) if (/^(?:https?|all|no)_proxy$/i.test(key) || key === 'NODE_OPTIONS') delete env[key]; + env.NODE_USE_ENV_PROXY = '1'; + env.HTTP_PROXY = proxyUrl.href; + env.NO_PROXY = ''; + const source = `import {withExternalResponse} from ${JSON.stringify(new URL('./network.ts', import.meta.url).href)};console.log(JSON.stringify(await withExternalResponse(${JSON.stringify(destination)}, {timeoutMs: 2000}, response => response.json())));`; + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', source], {env, timeout: 5000}); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => {stdout += String(chunk);}); + child.stderr.on('data', chunk => {stderr += String(chunk);}); + child.once('error', reject); + child.once('close', code => code === 0 ? resolve(stdout) : reject(new Error(`Proxy probe exited ${code}: ${stderr}`))); + }); + expect(JSON.parse(result)).toEqual({route: 'origin'}); + expect(proxyConnections).toBe(1); + expect(authenticatedProxy).toBe(true); + expect(originProxyAuthorization).toBeUndefined(); + }); +}); + +async function start(server: Server): Promise { + servers.push(server); + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, '127.0.0.1', resolve);}); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + return `http://127.0.0.1:${address.port}`; +} + +// Public test-only key and self-signed certificate; never used outside this listener. +const fixtureKey = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdlaXqgyzfUEvYbyF +RQfTLHuSl6q9hS1Xei3+nXI+RaqhRANCAASEdb1PI+/xQa9NAqepSjOLpJTpWDyV +Bhw6kqUXtZJVSTPyFFo5z9DrziH6UnxATozGgJv27wWRJoboTqzHTHr+ +-----END PRIVATE KEY-----`; +const fixtureCertificate = `-----BEGIN CERTIFICATE----- +MIIBfTCCASOgAwIBAgIUCUcRiI7iLHzUheIbTonNEDx6QO8wCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkwNzIzMTYyNVoXDTM2MDkwNDIz +MTYyNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEhHW9TyPv8UGvTQKnqUozi6SU6Vg8lQYcOpKlF7WSVUkz8hRaOc/Q684h ++lJ8QE6MxoCb9u8FkSaG6E6sx0x6/qNTMFEwHQYDVR0OBBYEFCWgV7aRyaZ6Tf19 +5SYahE4kmBRlMB8GA1UdIwQYMBaAFCWgV7aRyaZ6Tf195SYahE4kmBRlMA8GA1Ud +EwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIhAIJO7DSRzV+s7lDMftcC7CwU +tZVZiikVcdL2DYCLTu1TAiBjJblbBFao0A6d/BrkpMbn6M/JdApiza4cxQW520VZ +vQ== +-----END CERTIFICATE-----`; diff --git a/src/platform/network.ts b/src/platform/network.ts new file mode 100644 index 0000000..81f2c55 --- /dev/null +++ b/src/platform/network.ts @@ -0,0 +1,115 @@ +type FetchLike = (input: string | URL, init?: RequestInit) => Promise; +type ExternalRequestOptions = {timeoutMs: number; init?: RequestInit; fetchImpl?: FetchLike}; + +export function networkPolicy(env: NodeJS.ProcessEnv = process.env): {offline: boolean; lowBandwidth: boolean} { + return {offline: env.RADIOCLI_OFFLINE === '1', lowBandwidth: env.RADIOCLI_LOW_BANDWIDTH === '1'}; +} + +/** Configuration preflight only: no network request or proxy credentials. */ +export function networkDiagnostic() { + const policy = networkPolicy(); + if (policy.offline) return {...policy, status: 'offline', message: 'Public directory and update requests are disabled; cached data and direct stream URLs remain usable.'}; + try { + validateProxyConfiguration(new URL('http://example.invalid')); + validateProxyConfiguration(new URL('https://example.invalid')); + return {...policy, status: 'configured', message: 'Network configuration accepted; reachability and TLS are checked per request. External player networking is separate.'}; + } catch (error) { + return {...policy, status: 'unavailable', message: error instanceof Error ? error.message : 'Network configuration is invalid.'}; + } +} + +/** Keep the request deadline active through body consumption, including errors. */ +export async function withExternalResponse( + url: string | URL, + {timeoutMs, init = {}, fetchImpl = fetch}: ExternalRequestOptions, + consume: (response: Response) => T | Promise +): Promise { + if (networkPolicy().offline) throw new Error('RadioCLI is offline (RADIOCLI_OFFLINE=1).'); + validateProxyConfiguration(new URL(url)); + init.signal?.throwIfAborted(); + + const controller = new AbortController(); + const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal; + let onAbort: () => void; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, {once: true}); + }); + const timeout = setTimeout(() => controller.abort(new Error(`Network request timed out after ${timeoutMs} ms.`)), timeoutMs); + let response: Response | undefined; + const work = async () => { + response = await fetchImpl(url, {...init, signal}); + try { + // A custom fetch can resolve after the deadline without observing abort. + signal.throwIfAborted(); + return await consume(response); + } finally { + cancelBody(response); + } + }; + try { + return await Promise.race([work(), aborted]); + } finally { + clearTimeout(timeout); + signal.removeEventListener('abort', onAbort!); + controller.abort(); + if (response) cancelBody(response); + } +} + +function cancelBody(response: Response): void { + // Cancellation is best-effort and must not extend the request deadline. A + // locked native body is interrupted by the request's AbortController instead. + if (response.body && !response.body.locked) void response.body.cancel().catch(() => {}); +} + +function validateProxyConfiguration(url: URL): void { + const http = proxyVariable('http_proxy', 'HTTP_PROXY'); + const https = proxyVariable('https_proxy', 'HTTPS_PROXY'); + for (const proxy of [http, https]) { + if (!proxy.value) continue; + let parsed: URL; + try { + if (/[\r\n]/.test(proxy.value)) throw new Error(); + parsed = new URL(proxy.value); + decodeURIComponent(parsed.username); + decodeURIComponent(parsed.password); + } catch { + // URL parse errors can contain credentials. Do not attach the raw cause. + throw new Error(`${proxy.name} contains an invalid proxy URL.`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`${proxy.name} uses an unsupported proxy protocol. This Node HTTP client supports HTTP(S) proxies; SOCKS proxies are unavailable.`); + } + } + + // Native fetch falls back to HTTP_PROXY for HTTPS when HTTPS_PROXY is unset. + const selected = url.protocol === 'https:' && https.value ? https : http; + if (!selected.value) { + const all = proxyVariable('all_proxy', 'ALL_PROXY'); + if (all.value) throw new Error(`${all.name} is not supported by this Node HTTP client. Configure HTTP_PROXY or HTTPS_PROXY with an HTTP(S) proxy; SOCKS proxies are unavailable.`); + return; + } + + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number); + if (major < 24 && !(major === 22 && minor >= 21)) { + throw new Error('Native HTTP(S) proxy support requires Node 22.21+ or Node 24+.'); + } + let enabled = process.env.NODE_USE_ENV_PROXY === '1'; + const environmentFlags = (process.env.NODE_OPTIONS ?? '').match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + for (const raw of [...environmentFlags, ...process.execArgv]) { + const flag = raw.replace(/^["']|["']$/g, ''); + if (flag === '--use-env-proxy') enabled = true; + if (flag === '--no-use-env-proxy') enabled = false; + } + if (!enabled) { + throw new Error(`${selected.name} is configured. Start RadioCLI with NODE_USE_ENV_PROXY=1 to enable the native HTTP(S) proxy.`); + } + // Node configures its dispatcher at startup. Do not mutate proxy environment + // variables or install a global dispatcher here; local IPC has a direct agent. +} + +function proxyVariable(lower: string, upper: string): {name: string; value: string | undefined} { + const name = process.env[lower] !== undefined ? lower : upper; + return {name, value: process.env[name]}; +} diff --git a/src/platform/terminal.test.ts b/src/platform/terminal.test.ts new file mode 100644 index 0000000..856fd5f --- /dev/null +++ b/src/platform/terminal.test.ts @@ -0,0 +1,124 @@ +import {describe, expect, it} from 'vitest'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {resolveTerminalCapabilities} from './terminal.js'; + +describe('terminal capabilities', () => { + it('preserves the existing Unicode and truecolor default when no constraint is known', () => { + expect(resolveTerminalCapabilities({})).toEqual({ + unicode: true, colorLevel: 3, screenReader: false, reduceMotion: false, interactive: true + }); + }); + + it.each(['C', 'POSIX'])('uses ASCII decoration for the %s locale', locale => { + expect(resolveTerminalCapabilities({LANG: locale}).unicode).toBe(false); + }); + + it.each(['C.UTF-8', 'en_US.UTF8', 'ja_JP.UTF-8'])('preserves Unicode for %s', locale => { + expect(resolveTerminalCapabilities({LANG: locale}).unicode).toBe(true); + }); + + it('follows LC_ALL, LC_CTYPE, then LANG precedence and ignores empty locale variables', () => { + expect(resolveTerminalCapabilities({LC_ALL: 'C', LC_CTYPE: 'ja_JP.UTF-8', LANG: 'en_US.UTF-8'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({LC_ALL: '', LC_CTYPE: 'C', LANG: 'ja_JP.UTF-8'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({LC_ALL: 'C.UTF-8', LC_CTYPE: 'POSIX'}).unicode).toBe(true); + }); + + it('allows explicit glyph overrides without using SSH as an ASCII signal', () => { + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: '1', LANG: 'en_US.UTF-8'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({RADIOCLI_UNICODE: '1', LC_ALL: 'C'}, {asciiMode: true}).unicode).toBe(true); + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: '0'}, {asciiMode: true}).unicode).toBe(true); + expect(resolveTerminalCapabilities({RADIOCLI_UNICODE: '0'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: '1', RADIOCLI_UNICODE: '1'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({SSH_TTY: '/dev/pts/2', SSH_CONNECTION: 'host 1 host 2', LANG: 'C.UTF-8'}).unicode).toBe(true); + }); + + it('does not treat malformed glyph overrides as enabling a mode', () => { + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: 'maybe', RADIOCLI_UNICODE: 'later'}, {asciiMode: true}).unicode).toBe(false); + }); + + it('makes dumb terminals ASCII, colorless and static even when color and Unicode are forced', () => { + expect(resolveTerminalCapabilities({TERM: 'dumb', FORCE_COLOR: '3', RADIOCLI_UNICODE: '1'})).toMatchObject({ + unicode: false, colorLevel: 0, reduceMotion: true, interactive: false + }); + }); + + it('honors nonempty NO_COLOR over FORCE_COLOR and keeps an empty value inactive', () => { + expect(resolveTerminalCapabilities({NO_COLOR: '0', FORCE_COLOR: '3'}).colorLevel).toBe(0); + expect(resolveTerminalCapabilities({NO_COLOR: '', FORCE_COLOR: '3'}).colorLevel).toBe(3); + }); + + it.each([ + [{TERM: 'xterm'}, 1], + [{TERM: 'screen-256color'}, 2], + [{TERM: 'xterm-256color', COLORTERM: 'truecolor'}, 3], + [{TERM: 'xterm', COLORTERM: '24bit'}, 3], + [{FORCE_COLOR: '0'}, 0], + [{FORCE_COLOR: '1'}, 1], + [{FORCE_COLOR: '2'}, 2], + [{FORCE_COLOR: '3'}, 3] + ] as const)('selects the supported color level for %j', (env, colorLevel) => { + expect(resolveTerminalCapabilities(env).colorLevel).toBe(colorLevel); + }); + + it.each([[1, 0], [4, 1], [8, 2], [24, 3]])('uses a detected %i-bit color depth', (colorDepth, colorLevel) => { + expect(resolveTerminalCapabilities({}, {colorDepth}).colorLevel).toBe(colorLevel); + }); + + it('keeps piped output plain and static unless color is explicitly requested', () => { + expect(resolveTerminalCapabilities({}, {isTTY: false})).toMatchObject({colorLevel: 0, interactive: false, reduceMotion: true}); + expect(resolveTerminalCapabilities({FORCE_COLOR: '2'}, {isTTY: false}).colorLevel).toBe(2); + }); + + it.each([{INK_SCREEN_READER: 'true'}, {RADIOCLI_SCREEN_READER: '1'}])('disables decoration animation in accessible mode %j', env => { + expect(resolveTerminalCapabilities(env)).toMatchObject({screenReader: true, reduceMotion: true, colorLevel: 0}); + }); + + it('accepts the actual Ink accessibility state and legacy animation overrides', () => { + expect(resolveTerminalCapabilities({}, {screenReader: true})).toMatchObject({screenReader: true, reduceMotion: true}); + expect(resolveTerminalCapabilities({RADIOCLI_DISABLE_ANIMATION: '1'}).reduceMotion).toBe(true); + expect(resolveTerminalCapabilities({RADIO_ATLAS_DISABLE_ANIMATION: '1'}).reduceMotion).toBe(true); + }); + + it('does not mutate the supplied environment', () => { + const env = Object.freeze({NO_COLOR: '1', FORCE_COLOR: '3', RADIOCLI_ASCII: '1'}); + const capabilities = resolveTerminalCapabilities(env); + expect(capabilities.colorLevel).toBe(0); + expect(env).toEqual({NO_COLOR: '1', FORCE_COLOR: '3', RADIOCLI_ASCII: '1'}); + }); +}); + +describe('terminal color startup', () => { + it.each([ + {env: {NO_COLOR: '1', FORCE_COLOR: '3'}, expected: 0}, + {env: {TERM: 'dumb', FORCE_COLOR: '3'}, expected: 0}, + {env: {TERM: 'xterm'}, expected: 1}, + {env: {TERM: 'xterm-256color'}, expected: 2}, + {env: {TERM: 'xterm-256color', COLORTERM: 'truecolor'}, expected: 3}, + {env: {FORCE_COLOR: '1', COLORTERM: 'truecolor'}, expected: 1}, + {env: {FORCE_COLOR: '2', GITHUB_ACTIONS: 'true', CI: 'true'}, expected: 2} + ])('configures real Ink before its color dependency loads for $env', ({env, expected}) => { + const output = execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', ` + import {configureTerminalRenderer} from './src/ui/terminal-renderer.ts'; + const terminal = await configureTerminalRenderer(); + const [{createElement}, {Box, Text, renderToString}, {resolveDisplayMode}] = await Promise.all([ + import('react'), import('ink'), import('./src/ui/display-context.ts') + ]); + const display = resolveDisplayMode({}); + const frame = renderToString(createElement(Box, {backgroundColor: display.app}, + createElement(Text, {color: '#74f28a', backgroundColor: display.panel}, '東京 / Café'))); + process.stdout.write(JSON.stringify({terminal, frame})); + `], { + cwd: fileURLToPath(new URL('../../', import.meta.url)), encoding: 'utf8', stdio: 'pipe', + env: {...process.env, TERM: undefined, COLORTERM: undefined, FORCE_COLOR: undefined, NO_COLOR: undefined, INK_SCREEN_READER: undefined, RADIOCLI_SCREEN_READER: undefined, ...env} + }); + const result = JSON.parse(output) as {terminal: {colorLevel: number}; frame: string}; + expect(result.terminal.colorLevel).toBe(expected); + expect(result.frame).toContain('東京 / Café'); + if (expected === 0) expect(result.frame).not.toContain('\u001B['); + else expect(result.frame).toContain('\u001B['); + if (expected < 3) expect(result.frame).not.toMatch(/\u001B\[(?:38|48);2;/u); + if (expected < 2) expect(result.frame).not.toMatch(/\u001B\[(?:38|48);5;/u); + if (expected === 3) expect(result.frame).toMatch(/\u001B\[(?:38|48);2;/u); + }); +}); diff --git a/src/platform/terminal.ts b/src/platform/terminal.ts new file mode 100644 index 0000000..858df2b --- /dev/null +++ b/src/platform/terminal.ts @@ -0,0 +1,68 @@ +export type TerminalColorLevel = 0 | 1 | 2 | 3; + +export type TerminalEvidence = { + isTTY?: boolean; + colorDepth?: number; + screenReader?: boolean; + asciiMode?: boolean; + reduceMotion?: boolean; +}; + +export type TerminalCapabilities = { + unicode: boolean; + colorLevel: TerminalColorLevel; + screenReader: boolean; + reduceMotion: boolean; + interactive: boolean; +}; + +/** Environment overrides affect this invocation, never the saved settings. */ +export function resolveTerminalCapabilities(env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): TerminalCapabilities { + const dumb = env.TERM?.toLowerCase() === 'dumb'; + const screenReader = evidence.screenReader === true || env.INK_SCREEN_READER === 'true' || flag(env.RADIOCLI_SCREEN_READER) === true; + const interactive = evidence.isTTY !== false && !dumb; + const asciiOverride = flag(env.RADIOCLI_ASCII); + const unicodeOverride = flag(env.RADIOCLI_UNICODE); + const locale = [env.LC_ALL, env.LC_CTYPE, env.LANG].find(value => value?.trim())?.trim(); + // An explicit ASCII request wins when both overrides are enabled. A bare + // C/POSIX locale is a known constraint; SSH and missing locale data are not. + const unicode = dumb ? false + : asciiOverride === true ? false + : unicodeOverride === true ? true + : asciiOverride === false ? true + : unicodeOverride === false ? false + : locale === 'C' || locale === 'POSIX' ? false : !evidence.asciiMode; + const colorLevel = dumb || screenReader || Boolean(env.NO_COLOR) ? 0 : resolveColorLevel(env, evidence); + return { + unicode, + colorLevel, + screenReader, + reduceMotion: Boolean(evidence.reduceMotion) || !interactive || screenReader || env.RADIOCLI_DISABLE_ANIMATION === '1' || env.RADIO_ATLAS_DISABLE_ANIMATION === '1', + interactive + }; +} + +function resolveColorLevel(env: NodeJS.ProcessEnv, evidence: TerminalEvidence): TerminalColorLevel { + const forced = env.FORCE_COLOR; + if (forced === '0' || forced === 'false') return 0; + if (forced === '2') return 2; + if (forced === '3') return 3; + if (forced === '' || forced === '1' || forced === 'true') return 1; + if (evidence.isTTY === false) return 0; + if (evidence.colorDepth !== undefined) { + return evidence.colorDepth <= 1 ? 0 : evidence.colorDepth <= 4 ? 1 : evidence.colorDepth <= 8 ? 2 : 3; + } + if (/^(truecolor|24bit)$/i.test(env.COLORTERM ?? '') || /(?:direct|truecolor)/i.test(env.TERM ?? '')) return 3; + if (/256color/i.test(env.TERM ?? '')) return 2; + if (env.TERM) return 1; + // Preserve the existing rich display unless the environment supplies a + // concrete constraint. Native callers can pass stdout.getColorDepth(). + return 3; +} + +function flag(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + if (/^(1|true|yes|on)$/i.test(value)) return true; + if (/^(0|false|no|off)$/i.test(value)) return false; + return undefined; +} diff --git a/src/player/airplay-discovery.test.ts b/src/player/airplay-discovery.test.ts index 01e4c56..0832fbb 100644 --- a/src/player/airplay-discovery.test.ts +++ b/src/player/airplay-discovery.test.ts @@ -1,7 +1,21 @@ -import {describe, expect, it} from 'vitest'; -import {parseRaopBrowseOutput, parseRaopLookupOutput} from './airplay-discovery.js'; +import * as childProcess from 'node:child_process'; +import {describe, expect, it, vi} from 'vitest'; +import * as commands from './command.js'; +import {discoverAirPlayDevices, parseRaopBrowseOutput, parseRaopLookupOutput} from './airplay-discovery.js'; + +vi.mock('node:child_process', () => ({spawn: vi.fn(() => {throw new Error('discovery must remain offline');})})); describe('AirPlay RAOP discovery parsing', () => { + it('does not spawn multicast discovery in offline mode even when dns-sd exists', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const command = vi.spyOn(commands, 'commandExists').mockReturnValue(true); + const spawn = vi.mocked(childProcess.spawn); + try { + expect(await discoverAirPlayDevices({platform: 'darwin'})).toEqual([]); + expect(spawn).not.toHaveBeenCalled(); + } finally {spawn.mockClear(); command.mockRestore(); vi.unstubAllEnvs();} + }); + it('extracts RAOP instance names from dns-sd browse output', () => { expect( parseRaopBrowseOutput(` diff --git a/src/player/airplay-discovery.ts b/src/player/airplay-discovery.ts index 1e65a83..fecae28 100644 --- a/src/player/airplay-discovery.ts +++ b/src/player/airplay-discovery.ts @@ -3,6 +3,8 @@ import {lookup} from 'node:dns/promises'; import {networkInterfaces} from 'node:os'; import type {AirPlayDevice} from '../types.js'; import {commandExists} from './command.js'; +import {networkPolicy} from '../platform/network.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; export type AirPlayDiscoveryOptions = { platform?: NodeJS.Platform; @@ -24,7 +26,7 @@ export async function discoverAirPlayDevices({ lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes }: AirPlayDiscoveryOptions = {}): Promise { - if (platform !== 'darwin' || !commandExists('dns-sd')) { + if (networkPolicy().offline || !nativeAdapters(identifyPlatform({platform})).airPlay || !commandExists('dns-sd')) { return []; } diff --git a/src/providers/radio-browser.test.ts b/src/providers/radio-browser.test.ts index 7b198c3..625a062 100644 --- a/src/providers/radio-browser.test.ts +++ b/src/providers/radio-browser.test.ts @@ -1,6 +1,7 @@ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; +import {createServer} from 'node:http'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {ProviderCache} from './cache.js'; import {RadioBrowserProvider} from './radio-browser.js'; @@ -10,6 +11,8 @@ const roots: string[] = []; describe('RadioBrowserProvider', () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); for (const root of roots.splice(0)) { rmSync(root, {recursive: true, force: true}); @@ -211,6 +214,81 @@ describe('RadioBrowserProvider', () => { }); }); + it('uses stale cached startup results immediately in explicit offline mode', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const cache = cachedEntry('/json/stations/search?hidebroken=true&limit=1&order=clickcount&reverse=true', [ + {stationuuid: 'offline-fm', name: 'Offline FM', url: 'https://stream.example/live.mp3'} + ], Date.now() - 60 * 60 * 1000); + const fetch = mockFetch(() => {throw new Error('Network must not be attempted.');}); + const provider = new RadioBrowserProvider(['https://primary.example', 'https://secondary.example'], cache); + expect(await provider.popular(1)).toMatchObject([{id: 'offline-fm', streamUrl: 'https://stream.example/live.mp3'}]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reports an offline cache miss without trying a mirror', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = mockFetch(() => jsonResponse([])); + const provider = new RadioBrowserProvider(['https://primary.example'], cacheForTest()); + await expect(provider.popular(1)).rejects.toThrow(/offline.*cache|cache.*offline/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('does not vote, locate, or resolve externally when offline and retains a supplied stream URL', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = mockFetch(() => {throw new Error('Network must not be attempted.');}); + const provider = new RadioBrowserProvider(['https://primary.example'], cacheForTest()); + const station = {...radioBrowserStation('saved'), streamUrl: 'https://stream.example/live.mp3'}; + expect(await provider.vote(station)).toBe(false); + expect(await provider.detectLocation()).toBeNull(); + expect(await provider.resolve(station)).toEqual({url: station.streamUrl, name: station.name}); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('avoids an uncached atlas in low-bandwidth mode while ordinary directory requests still work', async () => { + vi.stubEnv('RADIOCLI_LOW_BANDWIDTH', '1'); + const fetch = mockFetch(() => jsonResponse([{stationuuid: 'small-list', name: 'Small list'}])); + const provider = new RadioBrowserProvider(['https://primary.example'], cacheForTest()); + await expect(provider.nearby({latitude: 48.8, longitude: 2.3, source: 'test'})).rejects.toThrow(/low.bandwidth.*cache|cache.*low.bandwidth/i); + expect(fetch).not.toHaveBeenCalled(); + expect(await provider.popular(1)).toMatchObject([{id: 'small-list'}]); + expect(fetch).toHaveBeenCalledOnce(); + expect(await provider.search('small', {limit: 1})).toMatchObject([{id: 'small-list'}]); + }); + + it('uses a bounded stale atlas in low-bandwidth mode without refreshing it', async () => { + vi.stubEnv('RADIOCLI_LOW_BANDWIDTH', '1'); + const cache = cachedEntry('/json/stations/search?has_geo_info=true&hidebroken=true&limit=100000&order=name', [ + {stationuuid: 'cached-nearby', name: 'Cached nearby', geo_lat: 48.8, geo_long: 2.3} + ], Date.now() - 7 * 60 * 60 * 1000); + const fetch = mockFetch(() => jsonResponse([])); + const provider = new RadioBrowserProvider(['https://primary.example'], cache); + expect(await provider.nearby({latitude: 48.8, longitude: 2.3, source: 'test'}, 1)).toMatchObject([{id: 'cached-nearby'}]); + expect(fetch).not.toHaveBeenCalled(); + }); + + for (const host of ['127.0.0.1', '::1']) { + it(`uses native fetch to read a real ${host} directory endpoint`, async context => { + let requests = 0; + const server = createServer((_request, response) => {requests += 1;response.end(JSON.stringify([{name: 'Japan', iso_3166_1: 'jp', stationcount: 1}]));}); + try { + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, host, resolve);}); + } catch (error) { + if (host === '::1' && ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) context.skip('The host does not provide IPv6 loopback.'); + throw error; + } + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + const provider = new RadioBrowserProvider([`http://${host === '::1' ? '[::1]' : host}:${address.port}`], cacheForTest()); + expect(await provider.countries()).toEqual([{name: 'Japan', code: 'JP', stationCount: 1}]); + expect(requests).toBe(1); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + } + it('loads country stations with limit and offset pagination', async () => { const fetch = mockFetch(url => { expect(url.pathname).toBe('/json/stations/search'); @@ -372,6 +450,12 @@ function cacheForTest(): ProviderCache { return new ProviderCache(cacheFileForTest()); } +function cachedEntry(key: string, value: unknown, createdAt: number): ProviderCache { + const file = cacheFileForTest(); + writeFileSync(file, JSON.stringify({version: 1, entries: {[key]: {createdAt, value}}})); + return new ProviderCache(file); +} + function cacheFileForTest(): string { const root = mkdtempSync(join(tmpdir(), 'radiocli-provider-')); roots.push(root); diff --git a/src/providers/radio-browser.ts b/src/providers/radio-browser.ts index 0b1fe7a..b27b0af 100644 --- a/src/providers/radio-browser.ts +++ b/src/providers/radio-browser.ts @@ -3,6 +3,7 @@ import type {Country, LocationGuess, ResolvedStream, SearchOptions, Station} fro import {ProviderCache} from './cache.js'; import {userAgent} from '../version.js'; import {safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText} from '../safety.js'; +import {networkPolicy, withExternalResponse} from '../platform/network.js'; const stationSchema = z.object({ stationuuid: z.string(), @@ -260,35 +261,26 @@ export class RadioBrowserProvider { } async detectLocation(): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); try { - const response = await fetch('https://ipapi.co/json/', { - signal: controller.signal, - headers: {'User-Agent': userAgent()} + return await withExternalResponse('https://ipapi.co/json/', { + timeoutMs: 5000, + init: {headers: {'User-Agent': userAgent()}} + }, async response => { + if (!response.ok) return null; + const parsed = locationSchema.parse(await response.json()); + if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number') return null; + return { + city: parsed.city, + region: parsed.region, + country: parsed.country_name, + countryCode: parsed.country_code, + latitude: parsed.latitude, + longitude: parsed.longitude, + source: 'ipapi.co' + }; }); - if (!response.ok) { - return null; - } - - const parsed = locationSchema.parse(await response.json()); - if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number') { - return null; - } - - return { - city: parsed.city, - region: parsed.region, - country: parsed.country_name, - countryCode: parsed.country_code, - latitude: parsed.latitude, - longitude: parsed.longitude, - source: 'ipapi.co' - }; } catch { return null; - } finally { - clearTimeout(timeout); } } @@ -351,7 +343,8 @@ export class RadioBrowserProvider { { maxAgeMs: geoAtlasMaxAgeMs, timeoutMs: geoAtlasTotalTimeoutMs, - attemptTimeoutMs: geoAtlasAttemptTimeoutMs + attemptTimeoutMs: geoAtlasAttemptTimeoutMs, + cacheOnly: networkPolicy().lowBandwidth } ); @@ -361,7 +354,7 @@ export class RadioBrowserProvider { private async request( path: string, params: Record = {}, - options: {maxAgeMs?: number; timeoutMs?: number; attemptTimeoutMs?: number} = {} + options: {maxAgeMs?: number; timeoutMs?: number; attemptTimeoutMs?: number; cacheOnly?: boolean} = {} ): Promise { const cacheKey = buildCacheKey(path, params); if (options.maxAgeMs) { @@ -371,6 +364,15 @@ export class RadioBrowserProvider { } } + const policy = networkPolicy(); + if (policy.offline || options.cacheOnly) { + const stale = options.maxAgeMs ? this.cache.getStale(cacheKey) : null; + if (stale !== null) return stale; + throw new ProviderUnavailableError(policy.offline + ? `${this.label} is offline: no cached response is available.` + : `${this.label} is in low-bandwidth mode: no cached atlas is available. Disable RADIOCLI_LOW_BANDWIDTH to download it.`); + } + let lastError: Error | null = null; const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl); const totalTimeoutMs = options.timeoutMs ?? 9000; @@ -454,25 +456,20 @@ function buildCacheKey(path: string, params: Record): string { } async function fetchJsonWithTimeout(url: URL, timeoutMs: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { - signal: controller.signal, + return withExternalResponse(url, { + timeoutMs, + init: { headers: { 'User-Agent': userAgent(' (+https://radio-browser.info)'), Accept: 'application/json' } - }); + } + }, async response => { if (!response.ok) { throw new Error(`Radio Browser request failed: ${response.status} ${response.statusText}`); } - - // Keep the abort timer alive while consuming the body as well as headers. return (await response.json()) as T; - } finally { - clearTimeout(timeout); - } + }); } export function dedupeStations(stations: Station[]): Station[] { diff --git a/src/providers/radio-garden.test.ts b/src/providers/radio-garden.test.ts new file mode 100644 index 0000000..6e8612d --- /dev/null +++ b/src/providers/radio-garden.test.ts @@ -0,0 +1,58 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {RadioGardenProvider} from './radio-garden.js'; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('RadioGardenProvider networking', () => { + it('does not search or probe provider health when offline', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = vi.fn(async () => new Response('{"hits":[]}')); + vi.stubGlobal('fetch', fetch); + const provider = new RadioGardenProvider(); + await expect(provider.search('jazz')).rejects.toThrow(/offline/i); + expect(await provider.health()).toMatch(/offline/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('preserves supplied stream URLs when offline', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = vi.fn(async () => new Response('{}')); + vi.stubGlobal('fetch', fetch); + const provider = new RadioGardenProvider(); + const station = {id: 'saved', provider: 'radio-garden' as const, name: 'Saved station', tags: [], streamUrl: 'https://stream.example/live.mp3'}; + expect(await provider.resolve(station)).toEqual({url: station.streamUrl, name: station.name}); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('aborts a search whose response body stalls after headers', async () => { + vi.useFakeTimers(); + let body!: ReadableStreamDefaultController; + vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({start(controller) {body = controller;}})))); + let error: Error | undefined; + const searching = new RadioGardenProvider().search('jazz').catch(value => {error = value as Error;}); + try { + await vi.advanceTimersByTimeAsync(9001); + expect(error?.message).toMatch(/timed out/i); + } finally { + body.error(new Error('test cleanup')); + await searching; + } + }); + + it('cancels an unused edge-protection response body', async () => { + const cancel = vi.fn(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({cancel}), {status: 403}))); + expect(await new RadioGardenProvider().health()).toBe('blocked by edge protection'); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('retains ordinary search behavior in low-bandwidth mode', async () => { + vi.stubEnv('RADIOCLI_LOW_BANDWIDTH', '1'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({hits: [{_source: {type: 'channel', title: 'Jazz FM', url: '/listen/jazz/one', subtitle: 'Japan'}}]})))); + expect(await new RadioGardenProvider().search('jazz')).toMatchObject([{id: 'one', name: 'Jazz FM'}]); + }); +}); diff --git a/src/providers/radio-garden.ts b/src/providers/radio-garden.ts index 1949404..9bb817b 100644 --- a/src/providers/radio-garden.ts +++ b/src/providers/radio-garden.ts @@ -1,5 +1,6 @@ import {z} from 'zod'; import type {ResolvedStream, SearchOptions, Station} from '../types.js'; +import {withExternalResponse} from '../platform/network.js'; const searchSchema = z .object({ @@ -34,14 +35,13 @@ export class RadioGardenProvider { async health(): Promise { try { - const response = await fetchWithTimeout(`${this.baseUrl}/geo`, 5000, { - headers: {'User-Agent': 'radiocli/0.1'} + return await withExternalResponse(`${this.baseUrl}/geo`, { + timeoutMs: 5000, + init: {headers: {'User-Agent': 'radiocli/0.1'}} + }, response => { + if (response.status === 403) return 'blocked by edge protection'; + return response.ok ? 'online' : `${response.status} ${response.statusText}`; }); - if (response.status === 403) { - return 'blocked by edge protection'; - } - - return response.ok ? 'online' : `${response.status} ${response.statusText}`; } catch (error) { return error instanceof Error ? error.message : 'unavailable'; } @@ -51,15 +51,13 @@ export class RadioGardenProvider { const url = new URL(`${this.baseUrl}/search`); url.searchParams.set('q', query); - const response = await fetchWithTimeout(url, 9000, { - headers: {'User-Agent': 'radiocli/0.1', Accept: 'application/json'} + const parsed = await withExternalResponse(url, { + timeoutMs: 9000, + init: {headers: {'User-Agent': 'radiocli/0.1', Accept: 'application/json'}} + }, async response => { + if (!response.ok) throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`); + return searchSchema.parse(await response.json()); }); - - if (!response.ok) { - throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`); - } - - const parsed = searchSchema.parse(await response.json()); return (parsed.hits ?? []) .map(hit => hit._source) .filter((source): source is NonNullable => source?.type === 'channel' && Boolean(source.url)) @@ -88,13 +86,3 @@ export class RadioGardenProvider { }; } } - -async function fetchWithTimeout(url: string | URL, timeoutMs: number, init: RequestInit = {}): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, {...init, signal: controller.signal}); - } finally { - clearTimeout(timeout); - } -} diff --git a/src/setup.test.ts b/src/setup.test.ts index 8060635..c89e9b9 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -5,6 +5,35 @@ import {createSetupPlan, detectPackageManager, parseSetupArgs, runSetup, type Se const nothingInstalled: Record = {mpv: false, ffmpeg: false, vlc: false}; describe('RadioCLI setup', () => { + it('prints an ASCII-safe setup plan in a dumb terminal without control sequences', async () => { + vi.stubEnv('TERM', 'dumb'); + const output = Object.assign(new PassThrough(), {isTTY: true}); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + try { + await runSetup({platform: 'linux', osRelease: 'ID=debian', args: ['--dry-run', '--only=mpv'], + input: new PassThrough(), output, hasCommand: command => command === 'apt'}); + expect(text).toContain('SETUP RECEIVER'); + expect(text).not.toMatch(/[^\x00-\x7f]/); + expect(text).not.toContain('\u001b'); + } finally {vi.unstubAllEnvs();} + }); + + it('caps setup colors at the terminal depth', async () => { + vi.stubEnv('TERM', 'xterm'); + vi.stubEnv('FORCE_COLOR', undefined); + vi.stubEnv('NO_COLOR', undefined); + const output = Object.assign(new PassThrough(), {isTTY: true, getColorDepth: () => 4}); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + try { + await runSetup({platform: 'linux', osRelease: 'ID=debian', args: ['--dry-run', '--only=mpv'], + input: new PassThrough(), output, hasCommand: command => command === 'apt'}); + expect(text).toContain('\u001b[32m'); + expect(text).not.toContain('38;2;'); + } finally {vi.unstubAllEnvs();} + }); + it('detects native package managers by platform and Linux family', () => { const available = new Set(['brew', 'winget', 'scoop', 'apt', 'dnf', 'pacman']); const hasCommand = (command: string): boolean => available.has(command); diff --git a/src/setup.ts b/src/setup.ts index 6800f88..4f14ae9 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -10,6 +10,7 @@ import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backe import {configureMcpIntegrations} from './agent/mcp-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; +import {resolveTerminalCapabilities} from './platform/terminal.js'; export type SetupPlan = { platform: NodeJS.Platform; @@ -54,7 +55,7 @@ export async function runSetup(options: SetupOptions = {}): Promise { const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand); writeHeader(output); - output.write(`System ${platformLabel(platform, osRelease)} · Node ${process.version}\n`); + output.write(`System ${platformLabel(platform, osRelease)} ${separator(output)} Node ${process.version}\n`); output.write(`Manager ${packageManager ?? 'not detected'}\n\n`); let selected = parsed.only ?? defaultComponents(platform, parsed.all); @@ -284,9 +285,9 @@ async function promptForComponents({ }): Promise { output.write('Choose components (installed items will be skipped):\n'); const selected: SetupComponent[] = []; - if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ' · installed' : ''}`, true)) selected.push('mpv'); - if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ' · installed' : ''}`, platform === 'darwin')) selected.push('ffmpeg'); - if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ' · installed' : ''}`, false)) selected.push('vlc'); + if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true)) selected.push('mpv'); + if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin')) selected.push('ffmpeg'); + if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false)) selected.push('vlc'); return selected; } @@ -307,14 +308,15 @@ function printPlan(plan: SetupPlan, output: Writable): void { if (plan.installed[component]) output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`); else { const command = plan.commands.find(candidate => candidate.component === component); - output.write(` ${pendingMark(output)} ${componentLabel(component)}${command ? ` · ${command.display}` : ' · manual installation required'}\n`); + output.write(` ${pendingMark(output)} ${componentLabel(component)} ${separator(output)} ${command ? command.display : 'manual installation required'}\n`); } } if (plan.selected.length === 0) output.write(' No components selected\n'); } async function runInstallCommand(command: SetupCommand, output: Writable, platform: NodeJS.Platform): Promise { - const interactive = Boolean((output as NodeJS.WriteStream).isTTY); + const terminal = setupTerminal(output); + const interactive = terminal.interactive && !terminal.reduceMotion; const startedAt = Date.now(); let timer: NodeJS.Timeout | undefined; let frame = 0; @@ -355,8 +357,9 @@ function progressFrame(label: string, frame: number, elapsedMs: number, output: const cycle = travel * 2; const offset = frame % cycle; const start = offset <= travel ? offset : cycle - offset; - const bar = Array.from({length: width}, (_, index) => index >= start && index < start + segment ? '█' : '░').join(''); - return ` ${accent(output, '◒')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`; + const unicode = setupTerminal(output).unicode; + const bar = Array.from({length: width}, (_, index) => index >= start && index < start + segment ? (unicode ? '█' : '#') : (unicode ? '░' : '.')).join(''); + return ` ${accent(output, unicode ? '◒' : '*')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`; } function printVerification(output: Writable): void { @@ -370,7 +373,7 @@ function printVerification(output: Writable): void { function writeHeader(output: Writable): void { output.write(`${accent(output, 'RADIOCLI')} SETUP RECEIVER\n`); - output.write(`${accent(output, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}\n`); + output.write(`${accent(output, setupTerminal(output).unicode ? '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' : '------------------------------------')}\n`); } function isInteractive(input: Readable, output: Writable): boolean { @@ -378,23 +381,33 @@ function isInteractive(input: Readable, output: Writable): boolean { } function accent(output: Writable, value: string): string { - return colorsEnabled(output) ? `\u001b[38;2;116;242;138m${value}\u001b[0m` : value; + const level = setupTerminal(output).colorLevel; + const color = level === 3 ? '38;2;116;242;138' : level === 2 ? '38;5;120' : '32'; + return level > 0 ? `\u001b[${color}m${value}\u001b[0m` : value; } function successMark(output: Writable): string { - return accent(output, '✓'); + return accent(output, setupTerminal(output).unicode ? '✓' : '+'); } function pendingMark(output: Writable): string { - return accent(output, '◆'); + return accent(output, setupTerminal(output).unicode ? '◆' : '*'); } function failureMark(output: Writable): string { - return colorsEnabled(output) ? '\u001b[38;2;255;95;135m✗\u001b[0m' : '✗'; + const terminal = setupTerminal(output); + const value = terminal.unicode ? '✗' : 'x'; + const color = terminal.colorLevel === 3 ? '38;2;255;95;135' : terminal.colorLevel === 2 ? '38;5;204' : '31'; + return terminal.colorLevel > 0 ? `\u001b[${color}m${value}\u001b[0m` : value; } -function colorsEnabled(output: Writable): boolean { - return Boolean((output as NodeJS.WriteStream).isTTY) && !process.env.NO_COLOR; +function setupTerminal(output: Writable) { + const stream = output as NodeJS.WriteStream; + return resolveTerminalCapabilities(process.env, {isTTY: Boolean(stream.isTTY), colorDepth: stream.getColorDepth?.()}); +} + +function separator(output: Writable): string { + return setupTerminal(output).unicode ? '·' : '-'; } function clearLine(): string { diff --git a/src/ui/AdaptiveContent.tsx b/src/ui/AdaptiveContent.tsx index 646fed5..eea53c8 100644 --- a/src/ui/AdaptiveContent.tsx +++ b/src/ui/AdaptiveContent.tsx @@ -112,7 +112,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { stationTitle, } = props; const accent = themeAccent(theme); - const {ascii} = useDisplay(); + const {ascii, reduceMotion} = useDisplay(); const {bodyRows} = adaptiveFrameMetrics(mode, height); const title = screenTitle(screen); const status = adaptiveStatus(props); @@ -142,7 +142,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { ascii={ascii} theme={theme} receiverStyle={library.settings.receiverStyle} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ); } @@ -161,7 +161,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { loading={loadingStations} error={props.stationError} ascii={ascii} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ); } @@ -208,7 +208,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { width={width} theme={theme} ascii={ascii} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ); } @@ -237,7 +237,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { const prompt = airPlayCode ? `Code: ${airPlayCode}` : 'Type the code shown by the receiver.'; return ( - {bodyRows > 0 ? {truncate(prompt, width)} : null} + {bodyRows > 0 ? {ascii ? toAsciiSafe(truncate(prompt, width)) : truncate(prompt, width)} : null} ); } @@ -280,7 +280,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { pageSize={bodyRows} width={width} theme={theme} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ) : ( @@ -318,6 +318,7 @@ function AdaptiveExplore({ }): React.ReactElement { const accent = themeAccent(theme); const {headerRows, headerGap, bodyRows} = adaptiveExploreFrameMetrics(mode, height); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const layout = computeAdaptiveExploreLayout(mode, width, Math.max(1, bodyRows)); const marker = React.useMemo( () => [{lat: cursor.latitude, lon: cursor.longitude, selected: true}], @@ -349,8 +350,8 @@ function AdaptiveExplore({ return ( - {truncate(title, width)} - {headerRows > 1 ? {truncate(status, width)} : null} + {a(truncate(title, width))} + {headerRows > 1 ? {a(truncate(status, width))} : null} {headerGap ? : null} {bodyRows > 0 ? layout.split ? ( @@ -403,7 +404,7 @@ function AdaptiveCosmoMap({ ascii: boolean; }): React.ReactElement { return ( - + {rows.map((row, rowIndex) => { const chunks: Array<{kind: CosmoMapCellKind; text: string}> = []; for (const cell of row.cells) { @@ -456,6 +457,7 @@ function AdaptiveFrame({ }): React.ReactElement { const accent = themeAccent(theme); const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const titlePrefix = mode === 'micro' ? 'RC / ' : 'RADIOCLI '; const titleText = truncate(`${titlePrefix}${title}${mode === 'micro' && status ? ` · ${status}` : ''}`, width); const ruleWidth = Math.max(0, width - displayWidth(titleText) - 1); @@ -463,10 +465,10 @@ function AdaptiveFrame({ return ( - - {titleText}{ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : ''} + + {a(titleText)}{ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : ''} - {mode === 'compact' && height >= 4 ? {truncate(status, width)} : null} + {mode === 'compact' && height >= 4 ? {a(truncate(status, width))} : null} {gapRows ? : null} {children} @@ -501,6 +503,8 @@ function AdaptiveHome({ library: LibraryState; }): React.ReactElement { const gapRows = height >= 5 ? 1 : 0; + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const showSummary = mode === 'compact' && height >= 10; const menuRows = Math.max(1, height - 1 - gapRows * 2 - (showSummary ? 1 : 0)); const selectedIndex = Math.min(Math.max(selected, 0), homeItems.length - 1); @@ -517,15 +521,15 @@ function AdaptiveHome({ const active = absoluteIndex === selectedIndex; const detail = mode === 'compact' && width >= 52 ? ` · ${item.detail}` : ''; return ( - - {active ? '> ' : ' '}{absoluteIndex + 1} {truncate(`${item.label}${detail}`, Math.max(1, width - 4))} + + {active ? '> ' : ' '}{absoluteIndex + 1} {a(truncate(`${item.label}${detail}`, Math.max(1, width - 4)))} ); })} {showSummary ? ( - {truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width)} + {a(truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width))} ) : null} {gapRows ? : null} @@ -562,7 +566,7 @@ function AdaptiveList({ const text = `${row.label}${detail}`; const favoriteWidth = row.favoriteGlyph ? 2 : 0; return ( - + {prefix} {rows.map(row => ( - - {truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)} + + {ascii ? toAsciiSafe(truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)) : truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)} {row.key === 'activity' ? : null} ))} @@ -614,15 +619,16 @@ function AdaptiveNowPlaying({ reduceMotion: boolean; }): React.ReactElement { const pulse = useReceiverPulse(); + const {screenReader} = useDisplay(); const accent = themeAccent(theme); const stationName = station?.name ?? 'No station tuned'; - const showMetadata = mode === 'compact' && height >= 9 && Boolean(metadata?.title); + const showMetadata = Boolean(metadata?.title) && (screenReader || mode === 'compact' && height >= 9); const headerRows = mode === 'compact' ? 2 : 1; const metadataRows = showMetadata ? 1 : 0; const gapRows = height >= 5 ? 1 : 0; const availableVisualRows = Math.max(1, height - headerRows - metadataRows - gapRows * 2); const visualHeight = visualizerHeight(receiverStyle, availableVisualRows, width); - const visualRows = buildVisualizer( + const visualRows = screenReader ? [] : buildVisualizer( receiverStyle, pulse, width, @@ -639,14 +645,14 @@ function AdaptiveNowPlaying({ return ( - + {mode === 'micro' ? : truncate(header, width)} - {mode === 'compact' ? {truncate(status, width)} : null} + {mode === 'compact' ? {ascii ? toAsciiSafe(truncate(status, width)) : truncate(status, width)} : null} {gapRows ? : null} - + {!screenReader ? {visualRows.map((row, index) => ( {row.segments @@ -656,9 +662,9 @@ function AdaptiveNowPlaying({ : row.text} ))} - + : null} {showMetadata ? ( - + ) : null} @@ -712,6 +718,7 @@ function AdaptiveSearch({ }): React.ReactElement { const accent = themeAccent(theme); const {panel: panelBackground} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const fieldText = query || 'station, genre, or place'; const prefix = loading ? (ascii ? '* ' : '⣾ ') : editing ? '› ' : '/ '; const fieldRows = height >= 4 ? 3 : 1; @@ -730,11 +737,11 @@ function AdaptiveSearch({ height={3} flexShrink={0} > - {prefix} - {truncate(fieldText, Math.max(1, width - 5))} + {a(prefix)} + {a(truncate(fieldText, Math.max(1, width - 5)))} ) : ( - {truncate(`[${prefix}${fieldText}]`, width)} + {a(truncate(`[${prefix}${fieldText}]`, width))} )} {gapRows ? : null} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8ec43ef..fe96462 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {fileURLToPath} from 'node:url'; -import {Box, Text, useApp, useStdin, useStdout, useWindowSize} from 'ink'; +import {Box, Text, useApp, useIsScreenReaderEnabled, useStdin, useStdout, useWindowSize} from 'ink'; import {ProviderManager} from '../providers/provider-manager.js'; import {PlayerController, type PlaybackControlResult} from '../player/player-controller.js'; import {playbackBackendInstallHint, playbackBackendLabel} from '../player/backend-install.js'; @@ -20,6 +20,7 @@ import {useAppInput} from './use-app-input.js'; import {useCommandExecutor} from './use-command-executor.js'; import {isAirPlayCodePromptActive} from './screens/AirPlayCodeScreen.js'; import {isAirPlayBackendAvailable} from './airplay-settings.js'; +import {networkPolicy} from '../platform/network.js'; import {audioOutputLabel, resolvedAudioOutput} from './audio-output.js'; import {copyToClipboard, openExternal} from './system-actions.js'; import {safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText} from '../safety.js'; @@ -100,6 +101,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const {stdin} = useStdin(); const {stdout} = useStdout(); const {columns, rows} = useWindowSize(); + const screenReader = useIsScreenReaderEnabled(); const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]); const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]); const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]); @@ -118,6 +120,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const [settingsPage, setSettingsPage] = useState('root'); const [message, setMessage] = useState(null); const [persistenceWarning, setPersistenceWarning] = useState(null); + const [presenceWarning, setPresenceWarning] = useState(null); const [footerMessage, setFooterMessage] = useState(null); const [countries, setCountries] = useState([]); const [countryFilter, setCountryFilter] = useState(''); @@ -197,8 +200,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const theme = library.settings.theme; const displayMode = useMemo( - () => resolveDisplayMode(library.settings), - [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion] + () => resolveDisplayMode(library.settings, process.env, {screenReader, isTTY: stdout.isTTY, colorDepth: stdout.getColorDepth?.()}), + [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion, screenReader, stdout] ); const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]); const diagnostics = player.diagnostics(); @@ -303,6 +306,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const layout = computeTerminalLayout(columns, rows, footerRows); const frameWidth = layout.frameWidth; const mouseReportingActive = + !displayMode.screenReader && + process.env.TERM?.toLowerCase() !== 'dumb' && !commandMode && !capturingTransportAction && !editingCountryFilter && @@ -314,7 +319,18 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ); useEffect(() => player.onChange(setPlayback), [player]); - useEffect(() => stdin.isTTY ? registerTuiPresence() : undefined, [stdin]); + useEffect(() => { + if (!stdin.isTTY) return; + try { + const unregister = registerTuiPresence(); + return () => { + try {unregister();} + catch {console.error('RadioCLI could not remove its alarm-control presence marker. Stale markers are checked on the next launch.');} + }; + } catch { + setPresenceWarning('Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.'); + } + }, [stdin]); const playingStationRef = useRef(null); playingStationRef.current = playingStation; @@ -386,7 +402,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe useEffect(() => { if ( footerPlayback.state !== 'loading' || - library.settings.reduceMotion || + displayMode.reduceMotion || process.env.RADIOCLI_DISABLE_ANIMATION === '1' || process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1' ) { @@ -396,7 +412,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS); return () => clearInterval(timer); - }, [footerPlayback.state, library.settings.reduceMotion]); + }, [footerPlayback.state, displayMode.reduceMotion]); useEffect(() => { if ( @@ -463,7 +479,10 @@ export function App({store: providedStore, providers: providedProviders, alarmSe useEffect(() => { const backends = player.refreshDetectedBackends(); setAvailableBackends(backends); - void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([])); + const network = networkPolicy(); + if (!network.offline && !network.lowBandwidth) { + void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([])); + } if (backends.length === 0) { setMessage(`No playback backend found. ${playbackBackendInstallHint()}`); } @@ -1382,6 +1401,10 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, [updateSettings]); const refreshAirPlayTargets = useCallback(async (announce = true): Promise => { + if (networkPolicy().offline) { + setMessage('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.'); + return []; + } try { const devices = await player.refreshAirPlayDevices(); setAvailableAirPlayDevices(devices); @@ -1831,10 +1854,12 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`); } if (command.type === 'airplay-list') { + if (networkPolicy().offline) return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []); const devices = await refreshAirPlayTargets(false); return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices); } if (command.type === 'airplay-select') { + if (networkPolicy().offline) return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false); const devices = await refreshAirPlayTargets(false); const device = devices.find(item => item.id === command.deviceId); if (!device) return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices); @@ -2024,7 +2049,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const hasActiveMicroPlayback = Boolean( footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused') ); - const statusMessage = message ?? persistenceWarning; + const statusMessage = message ?? persistenceWarning ?? presenceWarning; const microFooter = footerMessage ?? statusMessage ?? ( pageFooterOwnsCompactRow ? pageFooter @@ -2052,7 +2077,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe screen={screen} playback={playback} receiverStyle={library.settings.receiverStyle} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={displayMode.reduceMotion} > 'violet/stats' 1`] = ` exports[`receiver baseline / compact / ascii > braille-wave 1`] = ` "RADIOCLI Now playing -KEXP 90.3 FM playing · vol 70 +KEXP 90.3 FM playing . vol 70 @@ -970,7 +970,7 @@ Bjork - Joga exports[`receiver baseline / compact / ascii > gallop-fine 1`] = ` "RADIOCLI Now playing -KEXP 90.3 FM playing · vol 70 +KEXP 90.3 FM playing . vol 70 .:-:. @@ -986,14 +986,14 @@ Bjork - Joga exports[`receiver baseline / compact / ascii > manhattan 1`] = ` "RADIOCLI Now playing -KEXP 90.3 FM playing · vol 70 +KEXP 90.3 FM playing . vol 70 . o o | . oo | | | .o@ ### | | #### oo . . -# #.### ooo /◆\\ |.|o . # -# #.# # . ####### . . /◆\\ . . .| ||. .. . # - ... . . . . //◆\\\\ . . .| . .. . +# #.### ooo /*\\ |.|o . # +# #.# # . ####### . . /*\\ . . .| ||. .. . # + ... . . . . //*\\\\ . . .| . .. . oo o .o oo o.o -o .oo ..-.. - . . . ..- . . . .. Bjork - Joga @@ -1002,23 +1002,23 @@ Bjork - Joga exports[`receiver baseline / compact / ascii > pulse-grid 1`] = ` "RADIOCLI Now playing -KEXP 90.3 FM playing · vol 70 - -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o Bjork - Joga " `; exports[`receiver baseline / compact / ascii > ultracode 1`] = ` "RADIOCLI Now playing -KEXP 90.3 FM playing · vol 70 +KEXP 90.3 FM playing . vol 70 @@ -1177,10 +1177,10 @@ exports[`receiver baseline / full / ascii > manhattan 1`] = ` | . | . o | .. | | | | #### . oo . | | o o | |.|. .o@ | -| o | /◆\\ |*|. oo . | -| ooo ##### /◆\\ ###### #.## . ... | -| ### ####### . # # //◆\\\\ . . o| | || ### | | -| #.# *| # # ///◆\\\\\\ #*. |. # |.|| #.# #### | +| o | /*\\ |*|. oo . | +| ooo ##### /*\\ ###### #.## . ... | +| ### ####### . # # //*\\\\ . . o| | || ### | | +| #.# *| # # ///*\\\\\\ #*. |. # |.|| #.# #### | | # #. . o|. ### # #. ||.# #.#o#o # *#.#* . .| #.# #. # | | #.#. . ###### . #. *|. # # o#. ###|###. #. .| # *. || . . .|o #.# #### | | # #.### | . . .# | .# ### #. # o|.#..###.#. # . o#.##. . . # . | #.# # # .# | @@ -1202,22 +1202,22 @@ exports[`receiver baseline / full / ascii > pulse-grid 1`] = ` | | | KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | | | -| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | -| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | -| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | -| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | -| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | -| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o @ @ o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | -| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | -| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | -| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | -| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | -| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | -| . ∘ @ o o o @ ∘ . . . ∘ o @ o o o o @ o o ∘ ∘ ∘ ∘ o @ @ o o o o @ o . . . . o @ o o o o ∘ | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o @ @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| . o @ o o o @ o . . . o o @ o o o o @ o o o o o o o @ @ o o o o @ o . . . . o @ o o o o o | | | | Bjork - Joga * Favorite | | | @@ -3017,7 +3017,7 @@ exports[`receiver baseline / full / unicode > xor-texture 1`] = ` `; exports[`receiver baseline / micro / ascii > braille-wave 1`] = ` -"KEXP 90.3 FM · playing +"KEXP 90.3 FM . playing .--::--:. +.--..-:. :-: @@ -3027,7 +3027,7 @@ exports[`receiver baseline / micro / ascii > braille-wave 1`] = ` `; exports[`receiver baseline / micro / ascii > gallop-fine 1`] = ` -"KEXP 90.3 FM · playing +"KEXP 90.3 FM . playing .-+- :-::.:#: @@ -3037,27 +3037,27 @@ exports[`receiver baseline / micro / ascii > gallop-fine 1`] = ` `; exports[`receiver baseline / micro / ascii > manhattan 1`] = ` -"KEXP 90.3 FM · playing +"KEXP 90.3 FM . playing ## . oo o |o@o. #### #||#. /|.. ###@..## -#..* ##..../◆\\.o|.....## +#..* ##..../*\\.o|.....## -o. .oooo. oo o .o.o..o " `; exports[`receiver baseline / micro / ascii > pulse-grid 1`] = ` -"KEXP 90.3 FM · playing +"KEXP 90.3 FM . playing -. ∘ @ o o o o o o @ o . -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o o o o o @ o . +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . " `; exports[`receiver baseline / micro / ascii > ultracode 1`] = ` -"KEXP 90.3 FM · playing +"KEXP 90.3 FM . playing @@ -3134,9 +3134,9 @@ Code: 1234 exports[`screen baseline / compact / ascii > 'airplay-settings' 1`] = ` "RADIOCLI AirPlay ------------------------------ -1 receivers · mpv +1 receivers . mpv -> Living Room · speaker.local +> Living Room . speaker.local @@ -3283,7 +3283,7 @@ Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM Create alarm > @ Morning radio . 06:30 weekdays . KEXP 90.3 F - Verify alarm setup . scheduler, terminal, pow… + Verify alarm setup . scheduler, terminal, pow. @@ -3299,7 +3299,7 @@ Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM Create alarm > @ Morning radio . 06:30 weekdays . KEXP 90.3 F - Verify alarm setup . scheduler, terminal, pow… + Verify alarm setup . scheduler, terminal, pow. @@ -3314,7 +3314,7 @@ Scheduler: Ready No upcoming alarms. > Create alarm - Verify alarm setup . scheduler, terminal, pow… + Verify alarm setup . scheduler, terminal, pow. @@ -3326,7 +3326,7 @@ BETA . Keep a backup alarm for critical wakes exports[`screen baseline / compact / ascii > 'alarms/verification' 1`] = ` "Alarm setup verified with limitations --------- -Rehearsing “Morning radio” on this machine +Rehearsing "Morning radio" on this machine READY WITH LIMITATIONS . Review warnings before relying on wake behavior. @@ -3358,10 +3358,10 @@ exports[`screen baseline / compact / ascii > 'countries' 1`] = ` exports[`screen baseline / compact / ascii > 'explore' 1`] = ` "RADIOCLI Explore -48.9N, 2.4E · 2 stations +48.9N, 2.4E . 2 stations - . .:.:+** . .-. > KEXP 90.3 FM · * - ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM — … + . .:.:+** . .-. > KEXP 90.3 FM . * + ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM - . *@@@%- .@*%#%@@@@@%+ +#+ +@%#%%+%%@#:. ..-+. +#%@%: : ::. @@ -3377,14 +3377,14 @@ exports[`screen baseline / compact / ascii > 'help' 1`] = ` Scroll for keys and commands Navigation - ↑/↓ or n/p · Move selection - ←/→ or Tab · Switch tabs - [ / ] · Page selection by 10 - 1-9 · Jump to an Overview option - Enter · Open / tune the selection - b or Esc · Back to home - : · Command palette - ? · Toggle this help + ^/v or n/p . Move selection + or Tab . Switch tabs + [ / ] . Page selection by 10 + 1-9 . Jump to an Overview option + Enter . Open / tune the selection + b or Esc . Back to home + : . Command palette + ? . Toggle this help " `; @@ -3393,19 +3393,19 @@ exports[`screen baseline / compact / ascii > 'help/commands' 1`] = ` Scroll for keys and commands Commands - :search · Search stations (alias :s) - :country · Open a country (alias … - :codec · Filter by codec -> :language · Filter by language (al… - :bitrate · Minimum bitrate filter - :clear · Clear search filters - :volume <0-100> · Set volume (alias :vol) - :mute · Toggle mute + :search . Search stations (alias :s) + :country . Open a country (alias . + :codec . Filter by codec +> :language . Filter by language (al. + :bitrate . Minimum bitrate filter + :clear . Clear search filters + :volume <0-100> . Set volume (alias :vol) + :mute . Toggle mute " `; exports[`screen baseline / compact / ascii > 'home' 1`] = ` -"RADIOCLI █████████ +"RADIOCLI ######### > 1 Playing 2 Library @@ -3416,16 +3416,16 @@ exports[`screen baseline / compact / ascii > 'home' 1`] = ` 7 Stats 8 Alarms (beta) 9 Settings -1 recent · 1 favorites · 1 imported +1 recent . 1 favorites . 1 imported " `; exports[`screen baseline / compact / ascii > 'library' 1`] = ` "RADIOCLI Library ------------------------------ -2 stations · playing KEXP 90.3 FM +2 stations . playing KEXP 90.3 FM -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deliberately long station na… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. @@ -3438,7 +3438,7 @@ exports[`screen baseline / compact / ascii > 'library' 1`] = ` exports[`screen baseline / compact / ascii > 'library/empty' 1`] = ` "RADIOCLI Library ------------------------------ -0 stations · playing KEXP 90.3 FM +0 stations . playing KEXP 90.3 FM Your library is empty. Press f on a station to save it. @@ -3470,10 +3470,10 @@ exports[`screen baseline / compact / ascii > 'map' 1`] = ` exports[`screen baseline / compact / ascii > 'nearby' 1`] = ` "RADIOCLI Nearby ------------------------------- -2 stations · playing KEXP 90.3 FM +2 stations . playing KEXP 90.3 FM -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deliberately long station na… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. @@ -3486,39 +3486,39 @@ exports[`screen baseline / compact / ascii > 'nearby' 1`] = ` exports[`screen baseline / compact / ascii > 'now-playing' 1`] = ` "RADIOCLI Now playing -KEXP 90.3 FM playing · vol 70 - -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o Bjork - Joga " `; exports[`screen baseline / compact / ascii > 'now-playing/diagnostics' 1`] = ` "RADIOCLI Now playing -KEXP 90.3 FM playing · vol 70 - -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o Bjork - Joga " `; exports[`screen baseline / compact / ascii > 'now-playing/idle' 1`] = ` "RADIOCLI Now playing -No station tuned idle · vol 70 +No station tuned idle . vol 70 @@ -3535,11 +3535,11 @@ No station tuned idle · vol 70 exports[`screen baseline / compact / ascii > 'search' 1`] = ` "RADIOCLI Search +----------------------------------------------+ -|› tokyo jazz | +|> tokyo jazz | +----------------------------------------------+ -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deliberately long station na… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. @@ -3554,7 +3554,7 @@ exports[`screen baseline / compact / ascii > 'search/loading' 1`] = ` |* tokyo jazz | +----------------------------------------------+ -Searching station directories… +Searching station directories. @@ -3566,15 +3566,15 @@ Searching station directories… exports[`screen baseline / compact / ascii > 'settings' 1`] = ` "RADIOCLI Settings ----------------------------- -Choose a category · Updates are available here +Choose a category . Updates are available here -> Playback & audio 7 settings › - Appearance 6 settings › - Discovery & providers 4 settings › - Data & library 2 settings › - Agent control & MCP 4 settings › - Media keys 4 settings › - Updates 2 settings › +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > " @@ -3582,12 +3582,12 @@ Choose a category · Updates are available here exports[`screen baseline / compact / ascii > 'settings/agent' 1`] = ` "RADIOCLI Settings ----------------------------- -Agent control & MCP · Enter changes setting · b… +Agent control & MCP . Enter changes setting . b. -> Allow local agent con… off - Install or repair MCP… run repair - Open TUI for agent pl… on - Show Now Playing for … on +> Allow local agent con. off + Install or repair MCP. run repair + Open TUI for agent pl. on + Show Now Playing for . on @@ -3598,14 +3598,14 @@ Agent control & MCP · Enter changes setting · b… exports[`screen baseline / compact / ascii > 'settings/appearance' 1`] = ` "RADIOCLI Settings ----------------------------- -Appearance · Enter changes setting · b categori… +Appearance . Enter changes setting . b categori. > Display color green Receiver style pulse-grid Transparent background off ASCII-safe display on Reduce motion off - Mouse and trackpad sc… auto + Mouse and trackpad sc. auto @@ -3614,10 +3614,10 @@ Appearance · Enter changes setting · b categori… exports[`screen baseline / compact / ascii > 'settings/data' 1`] = ` "RADIOCLI Settings ----------------------------- -Data & library · Enter changes setting · b cate… +Data & library . Enter changes setting . b cate. -> Export preferences an… JSON backup - Import preferences an… restore JSON backup +> Export preferences an. JSON backup + Import preferences an. restore JSON backup @@ -3630,12 +3630,12 @@ Data & library · Enter changes setting · b cate… exports[`screen baseline / compact / ascii > 'settings/discovery' 1`] = ` "RADIOCLI Settings ----------------------------- -Discovery & providers · Enter changes setting ·… +Discovery & providers . Enter changes setting .. > Nearby location off Radio Garden adapter off - Share favorite votes … on - Refresh provider heal… + Share favorite votes . on + Refresh provider heal. @@ -3646,12 +3646,12 @@ Discovery & providers · Enter changes setting ·… exports[`screen baseline / compact / ascii > 'settings/media-keys' 1`] = ` "RADIOCLI Settings ----------------------------- -Media keys · Enter changes setting · b categori… +Media keys . Enter changes setting . b categori. -> Learn previous media … - Learn play/pause medi… +> Learn previous media . + Learn play/pause medi. Learn next media key - Reset learned media k… prev 0 · play 0 · ne… + Reset learned media k. prev 0 . play 0 . ne. @@ -3662,15 +3662,15 @@ Media keys · Enter changes setting · b categori… exports[`screen baseline / compact / ascii > 'settings/playback' 1`] = ` "RADIOCLI Settings ----------------------------- -Playback & audio · Enter changes setting · b ca… +Playback & audio . Enter changes setting . b ca. > Audio output Automatic - AirPlay receiver Living Room · 1 rece… + AirPlay receiver Living Room . 1 rece. Volume up Volume down Mute or unmute vol 70 Skip broken streams on - Resume last station o… on + Resume last station o. on " @@ -3678,9 +3678,9 @@ Playback & audio · Enter changes setting · b ca… exports[`screen baseline / compact / ascii > 'settings/updates' 1`] = ` "RADIOCLI Settings ----------------------------- -Updates · Enter changes setting · b categories +Updates . Enter changes setting . b categories -> Automatically check f… on +> Automatically check f. on Install update v0.3.0 available @@ -3694,10 +3694,10 @@ Updates · Enter changes setting · b categories exports[`screen baseline / compact / ascii > 'stations' 1`] = ` "RADIOCLI Stations ----------------------------- -2 stations · playing KEXP 90.3 FM +2 stations . playing KEXP 90.3 FM -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deliberately long station na… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. @@ -3712,12 +3712,12 @@ exports[`screen baseline / compact / ascii > 'stats' 1`] = ` "RADIOCLI Listening stats ---------------------- Stored locally on this machine -Listening · 1.5h -Favorite · KEXP 90.3 FM -Sessions · 2 -Stations (2+ min) · 2 -Streak · 2 current · 2 best -..........................## · 2 active days +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +Streak . 2 current . 2 best +..........................## . 2 active days @@ -3742,9 +3742,9 @@ Code: 1234 exports[`screen baseline / compact / ascii-no-color > 'airplay-settings' 1`] = ` "RADIOCLI AirPlay ------------------------------ -1 receivers · mpv +1 receivers . mpv -> Living Room · speaker.local +> Living Room . speaker.local @@ -3891,7 +3891,7 @@ Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM Create alarm > @ Morning radio . 06:30 weekdays . KEXP 90.3 F - Verify alarm setup . scheduler, terminal, pow… + Verify alarm setup . scheduler, terminal, pow. @@ -3907,7 +3907,7 @@ Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM Create alarm > @ Morning radio . 06:30 weekdays . KEXP 90.3 F - Verify alarm setup . scheduler, terminal, pow… + Verify alarm setup . scheduler, terminal, pow. @@ -3922,7 +3922,7 @@ Scheduler: Ready No upcoming alarms. > Create alarm - Verify alarm setup . scheduler, terminal, pow… + Verify alarm setup . scheduler, terminal, pow. @@ -3934,7 +3934,7 @@ BETA . Keep a backup alarm for critical wakes exports[`screen baseline / compact / ascii-no-color > 'alarms/verification' 1`] = ` "Alarm setup verified with limitations --------- -Rehearsing “Morning radio” on this machine +Rehearsing "Morning radio" on this machine READY WITH LIMITATIONS . Review warnings before relying on wake behavior. @@ -3966,10 +3966,10 @@ exports[`screen baseline / compact / ascii-no-color > 'countries' 1`] = ` exports[`screen baseline / compact / ascii-no-color > 'explore' 1`] = ` "RADIOCLI Explore -48.9N, 2.4E · 2 stations +48.9N, 2.4E . 2 stations - . .:.:+** . .-. > KEXP 90.3 FM · * - ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM — … + . .:.:+** . .-. > KEXP 90.3 FM . * + ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM - . *@@@%- .@*%#%@@@@@%+ +#+ +@%#%%+%%@#:. ..-+. +#%@%: : ::. @@ -3985,14 +3985,14 @@ exports[`screen baseline / compact / ascii-no-color > 'help' 1`] = ` Scroll for keys and commands Navigation - ↑/↓ or n/p · Move selection - ←/→ or Tab · Switch tabs - [ / ] · Page selection by 10 - 1-9 · Jump to an Overview option - Enter · Open / tune the selection - b or Esc · Back to home - : · Command palette - ? · Toggle this help + ^/v or n/p . Move selection + or Tab . Switch tabs + [ / ] . Page selection by 10 + 1-9 . Jump to an Overview option + Enter . Open / tune the selection + b or Esc . Back to home + : . Command palette + ? . Toggle this help " `; @@ -4001,19 +4001,19 @@ exports[`screen baseline / compact / ascii-no-color > 'help/commands' 1`] = ` Scroll for keys and commands Commands - :search · Search stations (alias :s) - :country · Open a country (alias … - :codec · Filter by codec -> :language · Filter by language (al… - :bitrate · Minimum bitrate filter - :clear · Clear search filters - :volume <0-100> · Set volume (alias :vol) - :mute · Toggle mute + :search . Search stations (alias :s) + :country . Open a country (alias . + :codec . Filter by codec +> :language . Filter by language (al. + :bitrate . Minimum bitrate filter + :clear . Clear search filters + :volume <0-100> . Set volume (alias :vol) + :mute . Toggle mute " `; exports[`screen baseline / compact / ascii-no-color > 'home' 1`] = ` -"RADIOCLI █████████ +"RADIOCLI ######### > 1 Playing 2 Library @@ -4024,16 +4024,16 @@ exports[`screen baseline / compact / ascii-no-color > 'home' 1`] = ` 7 Stats 8 Alarms (beta) 9 Settings -1 recent · 1 favorites · 1 imported +1 recent . 1 favorites . 1 imported " `; exports[`screen baseline / compact / ascii-no-color > 'library' 1`] = ` "RADIOCLI Library ------------------------------ -2 stations · playing KEXP 90.3 FM +2 stations . playing KEXP 90.3 FM -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deliberately long station na… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. @@ -4046,7 +4046,7 @@ exports[`screen baseline / compact / ascii-no-color > 'library' 1`] = ` exports[`screen baseline / compact / ascii-no-color > 'library/empty' 1`] = ` "RADIOCLI Library ------------------------------ -0 stations · playing KEXP 90.3 FM +0 stations . playing KEXP 90.3 FM Your library is empty. Press f on a station to save it. @@ -4078,10 +4078,10 @@ exports[`screen baseline / compact / ascii-no-color > 'map' 1`] = ` exports[`screen baseline / compact / ascii-no-color > 'nearby' 1`] = ` "RADIOCLI Nearby ------------------------------- -2 stations · playing KEXP 90.3 FM +2 stations . playing KEXP 90.3 FM -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deliberately long station na… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. @@ -4094,39 +4094,39 @@ exports[`screen baseline / compact / ascii-no-color > 'nearby' 1`] = ` exports[`screen baseline / compact / ascii-no-color > 'now-playing' 1`] = ` "RADIOCLI Now playing -KEXP 90.3 FM playing · vol 70 - -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o Bjork - Joga " `; exports[`screen baseline / compact / ascii-no-color > 'now-playing/diagnostics' 1`] = ` "RADIOCLI Now playing -KEXP 90.3 FM playing · vol 70 - -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o -o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o -o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o -o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o Bjork - Joga " `; exports[`screen baseline / compact / ascii-no-color > 'now-playing/idle' 1`] = ` "RADIOCLI Now playing -No station tuned idle · vol 70 +No station tuned idle . vol 70 @@ -4143,11 +4143,11 @@ No station tuned idle · vol 70 exports[`screen baseline / compact / ascii-no-color > 'search' 1`] = ` "RADIOCLI Search +----------------------------------------------+ -|› tokyo jazz | +|> tokyo jazz | +----------------------------------------------+ -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deliberately long station na… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. @@ -4162,7 +4162,7 @@ exports[`screen baseline / compact / ascii-no-color > 'search/loading' 1`] = ` |* tokyo jazz | +----------------------------------------------+ -Searching station directories… +Searching station directories. @@ -4174,15 +4174,15 @@ Searching station directories… exports[`screen baseline / compact / ascii-no-color > 'settings' 1`] = ` "RADIOCLI Settings ----------------------------- -Choose a category · Updates are available here +Choose a category . Updates are available here -> Playback & audio 7 settings › - Appearance 6 settings › - Discovery & providers 4 settings › - Data & library 2 settings › - Agent control & MCP 4 settings › - Media keys 4 settings › - Updates 2 settings › +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > " @@ -4190,12 +4190,12 @@ Choose a category · Updates are available here exports[`screen baseline / compact / ascii-no-color > 'settings/agent' 1`] = ` "RADIOCLI Settings ----------------------------- -Agent control & MCP · Enter changes setting · b… +Agent control & MCP . Enter changes setting . b. -> Allow local agent con… off - Install or repair MCP… run repair - Open TUI for agent pl… on - Show Now Playing for … on +> Allow local agent con. off + Install or repair MCP. run repair + Open TUI for agent pl. on + Show Now Playing for . on @@ -4206,14 +4206,14 @@ Agent control & MCP · Enter changes setting · b… exports[`screen baseline / compact / ascii-no-color > 'settings/appearance' 1`] = ` "RADIOCLI Settings ----------------------------- -Appearance · Enter changes setting · b categori… +Appearance . Enter changes setting . b categori. > Display color green Receiver style pulse-grid Transparent background off ASCII-safe display on Reduce motion off - Mouse and trackpad sc… auto + Mouse and trackpad sc. auto @@ -4222,10 +4222,10 @@ Appearance · Enter changes setting · b categori… exports[`screen baseline / compact / ascii-no-color > 'settings/data' 1`] = ` "RADIOCLI Settings ----------------------------- -Data & library · Enter changes setting · b cate… +Data & library . Enter changes setting . b cate. -> Export preferences an… JSON backup - Import preferences an… restore JSON backup +> Export preferences an. JSON backup + Import preferences an. restore JSON backup @@ -4238,12 +4238,12 @@ Data & library · Enter changes setting · b cate… exports[`screen baseline / compact / ascii-no-color > 'settings/discovery' 1`] = ` "RADIOCLI Settings ----------------------------- -Discovery & providers · Enter changes setting ·… +Discovery & providers . Enter changes setting .. > Nearby location off Radio Garden adapter off - Share favorite votes … on - Refresh provider heal… + Share favorite votes . on + Refresh provider heal. @@ -4254,12 +4254,12 @@ Discovery & providers · Enter changes setting ·… exports[`screen baseline / compact / ascii-no-color > 'settings/media-keys' 1`] = ` "RADIOCLI Settings ----------------------------- -Media keys · Enter changes setting · b categori… +Media keys . Enter changes setting . b categori. -> Learn previous media … - Learn play/pause medi… +> Learn previous media . + Learn play/pause medi. Learn next media key - Reset learned media k… prev 0 · play 0 · ne… + Reset learned media k. prev 0 . play 0 . ne. @@ -4270,15 +4270,15 @@ Media keys · Enter changes setting · b categori… exports[`screen baseline / compact / ascii-no-color > 'settings/playback' 1`] = ` "RADIOCLI Settings ----------------------------- -Playback & audio · Enter changes setting · b ca… +Playback & audio . Enter changes setting . b ca. > Audio output Automatic - AirPlay receiver Living Room · 1 rece… + AirPlay receiver Living Room . 1 rece. Volume up Volume down Mute or unmute vol 70 Skip broken streams on - Resume last station o… on + Resume last station o. on " @@ -4286,9 +4286,9 @@ Playback & audio · Enter changes setting · b ca… exports[`screen baseline / compact / ascii-no-color > 'settings/updates' 1`] = ` "RADIOCLI Settings ----------------------------- -Updates · Enter changes setting · b categories +Updates . Enter changes setting . b categories -> Automatically check f… on +> Automatically check f. on Install update v0.3.0 available @@ -4302,10 +4302,10 @@ Updates · Enter changes setting · b categories exports[`screen baseline / compact / ascii-no-color > 'stations' 1`] = ` "RADIOCLI Stations ----------------------------- -2 stations · playing KEXP 90.3 FM +2 stations . playing KEXP 90.3 FM -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deliberately long station na… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. @@ -4320,12 +4320,12 @@ exports[`screen baseline / compact / ascii-no-color > 'stats' 1`] = ` "RADIOCLI Listening stats ---------------------- Stored locally on this machine -Listening · 1.5h -Favorite · KEXP 90.3 FM -Sessions · 2 -Stations (2+ min) · 2 -Streak · 2 current · 2 best -..........................## · 2 active days +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +Streak . 2 current . 2 best +..........................## . 2 active days @@ -5558,7 +5558,7 @@ Tune a station with AirPlay first; this screen is used once the receiver asks fo Code **** -Enter submits · Backspace edits · Esc returns to AirPlay +Enter submits . Backspace edits . Esc returns to AirPlay @@ -5580,14 +5580,14 @@ exports[`screen baseline / full / ascii > 'airplay-settings' 1`] = ` "AirPlay ----------------------------------------------------------------------------------- Ready Choose where AirPlay playback should go -Audio output: Automatic · Streaming: ready · Receivers: 1 +Audio output: Automatic . Streaming: ready . Receivers: 1 1 receiver visible on this network. Selected: Living Room Code: if the receiver shows a code while tuning, RadioCLI will ask for it Receivers -> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver -Enter selects · c opens code entry · r refreshes receivers +> Living Room selected . speaker.local:7000 . AirPlay 2 . code shown on receiver +Enter selects . c opens code entry . r refreshes receivers @@ -5914,7 +5914,7 @@ Native scheduling is ready; the terminal does not need to stay open after save. exports[`screen baseline / full / ascii > 'alarms/verification' 1`] = ` "Alarm setup verified with limitations ----------------------------------------------------------- -Rehearsing “Morning radio” on this machine +Rehearsing "Morning radio" on this machine READY WITH LIMITATIONS . Review warnings before relying on wake behavior. @ Native scheduler @@ -5975,8 +5975,8 @@ Saved and discovered stations +-------------------------------------------------------------+ +--------------------------------+ | .::::.::::. | |Stations 2| | .:-::#*%-:-*@@@@@%. .-- .: :-#*++.. .: | | | -|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM ★ | -| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM — A deliberately…| +|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM * | +| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM - A deliberately.| | :#@@@@@@%@@#*. +@%@@@%%@@%@@@@@@@@@@@@@@+ . | | | | -@@@@@@@%: #*::-##*%%*@@@@@@@@@@%#+.+ | | | | -%@@###. .%@@%-*+#@%@@@@@@@@@@@% .: | | | @@ -6001,15 +6001,15 @@ exports[`screen baseline / full / ascii > 'help' 1`] = ` Keyboard shortcuts and : commands . b or Esc to close Navigation -↑/↓ or n/p Move selection -←/→ or Tab Switch tabs +^/v or n/p Move selection + or Tab Switch tabs [ / ] Page selection by 10 1-9 Jump to an Overview option Enter Open / tune the selection b or Esc Back to home : Command palette ? Toggle this help -q / Ctrl+C twi… Quit cleanly +q / Ctrl+C twi. Quit cleanly Playback space or F8 Pause / resume , / . or F7/F9 Previous / next station @@ -6021,7 +6021,7 @@ y Copy stream URL s Sleep timer (Now Playing) d Diagnostics + recent tracks (Now Playing) a Schedule the selected / playing station -Showing 1-21 of 67 · ↑/↓ scroll" +Showing 1-21 of 67 . ^/v scroll" `; exports[`screen baseline / full / ascii > 'help/commands' 1`] = ` @@ -6036,24 +6036,24 @@ WASD Move the Explore map cursor r Refresh provider health Commands (press : then type) :search Search stations (alias :s) -:country Minimum bitrate filter :clear Clear search filters :volume <0-100> Set volume (alias :vol) :mute Toggle mute -:sleep Skip broken streams -:location 'home' 1`] = ` -"RADIOCLI ██████████████████ +"RADIOCLI ################## Live public radio from around the world @@ -6067,7 +6067,7 @@ Live public radio from around the world 8 Alarms (beta) Wake to radio and schedule station playback 9 Settings Audio output, colors, providers -1 recent · 1 favorites · 1 imported +1 recent . 1 favorites . 1 imported @@ -6085,8 +6085,8 @@ exports[`screen baseline / full / ascii > 'library' 1`] = ` Saved and discovered stations 1-2 of 2 -> KEXP 90.3 FM ★ indie - KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps @@ -6169,8 +6169,8 @@ exports[`screen baseline / full / ascii > 'nearby' 1`] = ` Saved and discovered stations 1-2 of 2 -> KEXP 90.3 FM ★ indie - KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps @@ -6198,22 +6198,22 @@ exports[`screen baseline / full / ascii > 'now-playing' 1`] = ` | | | KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | | | -| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | -| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | -| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | -| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | -| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | -| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o @ @ o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | -| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | -| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | -| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | -| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | -| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | -| . ∘ @ o o o @ ∘ . . . ∘ o @ o o o o @ o o ∘ ∘ ∘ ∘ o @ @ o o o o @ o . . . . o @ o o o o ∘ | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o @ @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| . o @ o o o @ o . . . o o @ o o o o @ o o o o o o o @ @ o o o o @ o . . . . o @ o o o o o | | | | Bjork - Joga * Favorite | | | @@ -6226,15 +6226,15 @@ exports[`screen baseline / full / ascii > 'now-playing/diagnostics' 1`] = ` | | | KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | | | -| ∘ @ o o o o . . . ∘ o o o o @ o . . . . . ∘ ∘ ∘ ∘ . . . . ∘ o o o o @ o . . . ∘ @ o o o o | -| o @ o o @ o . . . ∘ @ o o o o ∘ . . . ∘ o @ @ @ o ∘ . . . . o @ o o o o ∘ . . ∘ o o o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . o @ o o o o o o o ∘ . . . o o o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o @ ∘ . . ∘ o o o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . o @ o o o o o o o ∘ . . . o o o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . . o @ o o @ o . . . ∘ o @ o o o o @ ∘ . . . ∘ o o o o @ ∘ . . . o o o o @ | -| ∘ @ o o o o . . . ∘ o o o o @ o . . . . . ∘ ∘ ∘ ∘ . . . . ∘ o o o o @ o . . . ∘ @ o o o o | -| ∘ @ o o o @ ∘ . . . o @ o o o @ ∘ . . . . . . . . . . . ∘ o @ o o o o ∘ . . . o @ o o @ o | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o o o . . . o o @ @ @ o o . . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . o o o o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . . o @ o o @ o . . . o o @ o o o o @ o . . . o o o o o @ o . . . o o o o @ | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o o @ o . . . o @ o o o @ o . . . . . . . . . . . o o @ o o o o o . . . o @ o o @ o | | | | Bjork - Joga * Favorite | | | @@ -6281,12 +6281,12 @@ exports[`screen baseline / full / ascii > 'search' 1`] = ` Radio Browser +----------------------------------------------------------------------------------------------+ -|› tokyo jazz | +|> tokyo jazz | +----------------------------------------------------------------------------------------------+ 1-2 of 2 -> KEXP 90.3 FM ★ indie - KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps @@ -6309,10 +6309,10 @@ exports[`screen baseline / full / ascii > 'search/loading' 1`] = ` Radio Browser +----------------------------------------------------------------------------------------------+ -|› tokyo jazz | +|> tokyo jazz | +----------------------------------------------------------------------------------------------+ -Searching station directories… +Searching station directories. @@ -6336,13 +6336,13 @@ exports[`screen baseline / full / ascii > 'settings' 1`] = ` "Settings ---------------------------------------------------------------------------------------- Choose a category -> Playback & audio 7 settings › - Appearance 6 settings › - Discovery & providers 4 settings › - Data & library 2 settings › - Agent control & MCP 4 settings › - Media keys 4 settings › - Updates 2 settings › +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > Selected Output, volume, AirPlay, and startup behavior @@ -6367,7 +6367,7 @@ Settings > Agent control & MCP > Allow local agent control off Install or repair MCP integrations run repair Open TUI for agent playback on - Show Now Playing for agent playba… on + Show Now Playing for agent playba. on Selected Press Enter to set up or remove agent and Codex Voice control across detected clients. @@ -6450,7 +6450,7 @@ Settings > Discovery & providers > Nearby location off Radio Garden adapter off - Share favorite votes with Radio B… on + Share favorite votes with Radio B. on Refresh provider health Selected @@ -6479,7 +6479,7 @@ Settings > Media keys > Learn previous media key Learn play/pause media key Learn next media key - Reset learned media keys prev 0 · play 0 · next 0 + Reset learned media keys prev 0 . play 0 . next 0 Selected Press Enter to change this setting. @@ -6505,7 +6505,7 @@ exports[`screen baseline / full / ascii > 'settings/playback' 1`] = ` Settings > Playback & audio > Audio output Automatic - AirPlay receiver Living Room · 1 receiver + AirPlay receiver Living Room . 1 receiver Volume up Volume down Mute or unmute vol 70 @@ -6513,7 +6513,7 @@ Settings > Playback & audio Resume last station on launch on Selected -Active: This device (mpv) · playing · volume 70 +Active: This device (mpv) . playing . volume 70 @@ -6561,8 +6561,8 @@ exports[`screen baseline / full / ascii > 'stations' 1`] = ` Saved and discovered stations 1-2 of 2 -> KEXP 90.3 FM ★ indie - KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps @@ -6589,7 +6589,7 @@ exports[`screen baseline / full / ascii > 'stats' 1`] = ` Local listening history . never leaves this machine +------------------------------------------------------------------------------------------------+ -|Activity — 2026 | +|Activity - 2026 | | Dec Jan Feb Mar Apr May Jun Jul Aug Sep | | | |Mon | @@ -6608,7 +6608,7 @@ Local listening history . never leaves this machine |Current streak: 2 days Stations listened: 2 | |Active days: 2/371 Stations counted after: 2 min | | | -|Less · More | +|Less . More | +------------------------------------------------------------------------------------------------+" `; @@ -6622,7 +6622,7 @@ Tune a station with AirPlay first; this screen is used once the receiver asks fo Code **** -Enter submits · Backspace edits · Esc returns to AirPlay +Enter submits . Backspace edits . Esc returns to AirPlay @@ -6644,14 +6644,14 @@ exports[`screen baseline / full / ascii-no-color > 'airplay-settings' 1`] = ` "AirPlay ----------------------------------------------------------------------------------- Ready Choose where AirPlay playback should go -Audio output: Automatic · Streaming: ready · Receivers: 1 +Audio output: Automatic . Streaming: ready . Receivers: 1 1 receiver visible on this network. Selected: Living Room Code: if the receiver shows a code while tuning, RadioCLI will ask for it Receivers -> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver -Enter selects · c opens code entry · r refreshes receivers +> Living Room selected . speaker.local:7000 . AirPlay 2 . code shown on receiver +Enter selects . c opens code entry . r refreshes receivers @@ -6978,7 +6978,7 @@ Native scheduling is ready; the terminal does not need to stay open after save. exports[`screen baseline / full / ascii-no-color > 'alarms/verification' 1`] = ` "Alarm setup verified with limitations ----------------------------------------------------------- -Rehearsing “Morning radio” on this machine +Rehearsing "Morning radio" on this machine READY WITH LIMITATIONS . Review warnings before relying on wake behavior. @ Native scheduler @@ -7039,8 +7039,8 @@ Saved and discovered stations +-------------------------------------------------------------+ +--------------------------------+ | .::::.::::. | |Stations 2| | .:-::#*%-:-*@@@@@%. .-- .: :-#*++.. .: | | | -|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM ★ | -| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM — A deliberately…| +|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM * | +| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM - A deliberately.| | :#@@@@@@%@@#*. +@%@@@%%@@%@@@@@@@@@@@@@@+ . | | | | -@@@@@@@%: #*::-##*%%*@@@@@@@@@@%#+.+ | | | | -%@@###. .%@@%-*+#@%@@@@@@@@@@@% .: | | | @@ -7065,15 +7065,15 @@ exports[`screen baseline / full / ascii-no-color > 'help' 1`] = ` Keyboard shortcuts and : commands . b or Esc to close Navigation -↑/↓ or n/p Move selection -←/→ or Tab Switch tabs +^/v or n/p Move selection + or Tab Switch tabs [ / ] Page selection by 10 1-9 Jump to an Overview option Enter Open / tune the selection b or Esc Back to home : Command palette ? Toggle this help -q / Ctrl+C twi… Quit cleanly +q / Ctrl+C twi. Quit cleanly Playback space or F8 Pause / resume , / . or F7/F9 Previous / next station @@ -7085,7 +7085,7 @@ y Copy stream URL s Sleep timer (Now Playing) d Diagnostics + recent tracks (Now Playing) a Schedule the selected / playing station -Showing 1-21 of 67 · ↑/↓ scroll" +Showing 1-21 of 67 . ^/v scroll" `; exports[`screen baseline / full / ascii-no-color > 'help/commands' 1`] = ` @@ -7100,24 +7100,24 @@ WASD Move the Explore map cursor r Refresh provider health Commands (press : then type) :search Search stations (alias :s) -:country Minimum bitrate filter :clear Clear search filters :volume <0-100> Set volume (alias :vol) :mute Toggle mute -:sleep Skip broken streams -:location 'home' 1`] = ` -"RADIOCLI ██████████████████ +"RADIOCLI ################## Live public radio from around the world @@ -7131,7 +7131,7 @@ Live public radio from around the world 8 Alarms (beta) Wake to radio and schedule station playback 9 Settings Audio output, colors, providers -1 recent · 1 favorites · 1 imported +1 recent . 1 favorites . 1 imported @@ -7149,8 +7149,8 @@ exports[`screen baseline / full / ascii-no-color > 'library' 1`] = ` Saved and discovered stations 1-2 of 2 -> KEXP 90.3 FM ★ indie - KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps @@ -7233,8 +7233,8 @@ exports[`screen baseline / full / ascii-no-color > 'nearby' 1`] = ` Saved and discovered stations 1-2 of 2 -> KEXP 90.3 FM ★ indie - KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps @@ -7262,22 +7262,22 @@ exports[`screen baseline / full / ascii-no-color > 'now-playing' 1`] = ` | | | KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | | | -| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | -| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | -| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | -| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | -| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | -| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o @ @ o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o @ ∘ . . . o @ o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . ∘ @ o o o o o @ o ∘ . . ∘ o o o o @ ∘ . . . o o o o @ | -| o @ o o @ ∘ . . . o @ o o @ o . . . . ∘ @ @ o @ @ o ∘ . . . ∘ @ o o o o ∘ . . ∘ o o o o @ | -| o @ o o @ o . . . ∘ @ o o o @ ∘ . . . . ∘ o o o ∘ ∘ . . . ∘ o @ o o @ o . . . ∘ @ o o o o | -| ∘ @ o o o o ∘ . . ∘ o o o o o o ∘ . . . . . . . . . . . . o @ o o o @ ∘ . . . ∘ @ o o o o | -| ∘ o o o o @ ∘ . . . ∘ @ o o o @ o ∘ . . . . . . . . . . ∘ @ o o o @ o ∘ . . ∘ o o o o @ o | -| . o @ o o @ o . . . ∘ o @ o o o @ o ∘ ∘ . . . . . . ∘ o @ o o o o @ ∘ . . . ∘ @ o o o @ ∘ | -| . ∘ @ o o o @ ∘ . . . ∘ o @ o o o o @ o o ∘ ∘ ∘ ∘ o @ @ o o o o @ o . . . . o @ o o o o ∘ | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o @ @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| . o @ o o o @ o . . . o o @ o o o o @ o o o o o o o @ @ o o o o @ o . . . . o @ o o o o o | | | | Bjork - Joga * Favorite | | | @@ -7290,15 +7290,15 @@ exports[`screen baseline / full / ascii-no-color > 'now-playing/diagnostics' 1`] | | | KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | | | -| ∘ @ o o o o . . . ∘ o o o o @ o . . . . . ∘ ∘ ∘ ∘ . . . . ∘ o o o o @ o . . . ∘ @ o o o o | -| o @ o o @ o . . . ∘ @ o o o o ∘ . . . ∘ o @ @ @ o ∘ . . . . o @ o o o o ∘ . . ∘ o o o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . o @ o o o o o o o ∘ . . . o o o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o @ ∘ . . ∘ o o o o o o o o @ ∘ . . . o @ o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . ∘ o o o o o ∘ . . ∘ o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | -| o o o o @ ∘ . . . o o o o @ ∘ . . . o @ o o o o o o o ∘ . . . o o o o @ ∘ . . . o @ o o @ | -| o o o o @ ∘ . . . o @ o o @ o . . . ∘ o @ o o o o @ ∘ . . . ∘ o o o o @ ∘ . . . o o o o @ | -| ∘ @ o o o o . . . ∘ o o o o @ o . . . . . ∘ ∘ ∘ ∘ . . . . ∘ o o o o @ o . . . ∘ @ o o o o | -| ∘ @ o o o @ ∘ . . . o @ o o o @ ∘ . . . . . . . . . . . ∘ o @ o o o o ∘ . . . o @ o o @ o | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o o o . . . o o @ @ @ o o . . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . o o o o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . . o @ o o @ o . . . o o @ o o o o @ o . . . o o o o o @ o . . . o o o o @ | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o o @ o . . . o @ o o o @ o . . . . . . . . . . . o o @ o o o o o . . . o @ o o @ o | | | | Bjork - Joga * Favorite | | | @@ -7345,12 +7345,12 @@ exports[`screen baseline / full / ascii-no-color > 'search' 1`] = ` Radio Browser +----------------------------------------------------------------------------------------------+ -|› tokyo jazz | +|> tokyo jazz | +----------------------------------------------------------------------------------------------+ 1-2 of 2 -> KEXP 90.3 FM ★ indie - KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps @@ -7373,10 +7373,10 @@ exports[`screen baseline / full / ascii-no-color > 'search/loading' 1`] = ` Radio Browser +----------------------------------------------------------------------------------------------+ -|› tokyo jazz | +|> tokyo jazz | +----------------------------------------------------------------------------------------------+ -Searching station directories… +Searching station directories. @@ -7400,13 +7400,13 @@ exports[`screen baseline / full / ascii-no-color > 'settings' 1`] = ` "Settings ---------------------------------------------------------------------------------------- Choose a category -> Playback & audio 7 settings › - Appearance 6 settings › - Discovery & providers 4 settings › - Data & library 2 settings › - Agent control & MCP 4 settings › - Media keys 4 settings › - Updates 2 settings › +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > Selected Output, volume, AirPlay, and startup behavior @@ -7431,7 +7431,7 @@ Settings > Agent control & MCP > Allow local agent control off Install or repair MCP integrations run repair Open TUI for agent playback on - Show Now Playing for agent playba… on + Show Now Playing for agent playba. on Selected Press Enter to set up or remove agent and Codex Voice control across detected clients. @@ -7514,7 +7514,7 @@ Settings > Discovery & providers > Nearby location off Radio Garden adapter off - Share favorite votes with Radio B… on + Share favorite votes with Radio B. on Refresh provider health Selected @@ -7543,7 +7543,7 @@ Settings > Media keys > Learn previous media key Learn play/pause media key Learn next media key - Reset learned media keys prev 0 · play 0 · next 0 + Reset learned media keys prev 0 . play 0 . next 0 Selected Press Enter to change this setting. @@ -7569,7 +7569,7 @@ exports[`screen baseline / full / ascii-no-color > 'settings/playback' 1`] = ` Settings > Playback & audio > Audio output Automatic - AirPlay receiver Living Room · 1 receiver + AirPlay receiver Living Room . 1 receiver Volume up Volume down Mute or unmute vol 70 @@ -7577,7 +7577,7 @@ Settings > Playback & audio Resume last station on launch on Selected -Active: This device (mpv) · playing · volume 70 +Active: This device (mpv) . playing . volume 70 @@ -7625,8 +7625,8 @@ exports[`screen baseline / full / ascii-no-color > 'stations' 1`] = ` Saved and discovered stations 1-2 of 2 -> KEXP 90.3 FM ★ indie - KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps @@ -7653,7 +7653,7 @@ exports[`screen baseline / full / ascii-no-color > 'stats' 1`] = ` Local listening history . never leaves this machine +------------------------------------------------------------------------------------------------+ -|Activity — 2026 | +|Activity - 2026 | | Dec Jan Feb Mar Apr May Jun Jul Aug Sep | | | |Mon | @@ -7672,7 +7672,7 @@ Local listening history . never leaves this machine |Current streak: 2 days Stations listened: 2 | |Active days: 2/371 Stations counted after: 2 min | | | -|Less · More | +|Less . More | +------------------------------------------------------------------------------------------------+" `; @@ -9805,7 +9805,7 @@ Local listening history · never leaves this machine `; exports[`screen baseline / micro / ascii > 'airplay-code' 1`] = ` -"RC / AirPlay code · KEX… +"RC / AirPlay code . KEX. Code: 1234 @@ -9815,9 +9815,9 @@ Code: 1234 `; exports[`screen baseline / micro / ascii > 'airplay-settings' 1`] = ` -"RC / AirPlay · 1 receiv… +"RC / AirPlay . 1 receiv. -> Living Room · speaker… +> Living Room . speaker. @@ -9936,7 +9936,7 @@ BETA . Keep backup exports[`screen baseline / micro / ascii > 'alarms/verification' 1`] = ` "Alarm setup verifie. -- -Rehearsing “Morning rad. +Rehearsing "Morning rad. READY WITH LIMITATIONS . Review warnings before Disposable job regis. @@ -9945,9 +9945,9 @@ return to Alarms. Run" `; exports[`screen baseline / micro / ascii > 'countries' 1`] = ` -"RC / Countries · 2 coun… +"RC / Countries . 2 coun. -> United St… 12,345 +> United St. 12,345 Japan (JP) 456 @@ -9955,37 +9955,37 @@ exports[`screen baseline / micro / ascii > 'countries' 1`] = ` `; exports[`screen baseline / micro / ascii > 'explore' 1`] = ` -"RC / Explore · 48.9N, 2… +"RC / Explore . 48.9N, 2. .:+#+:: @++*#*:. .+:. +%%-+#-. :#: -- --. .::--:-+++++++- -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deli…" +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli." `; exports[`screen baseline / micro / ascii > 'help' 1`] = ` -"RC / Help · Scroll for … +"RC / Help . Scroll for . Navigation - ↑/↓ or n/p · Move sel… - ←/→ or Tab · Switch t… - [ / ] · Page selectio… + ^/v or n/p . Move sel. + or Tab . Switch t. + [ / ] . Page selectio. " `; exports[`screen baseline / micro / ascii > 'help/commands' 1`] = ` -"RC / Help · Scroll for … +"RC / Help . Scroll for . - :country … - :codec · … -> :language … - :bitrate · Min… + :country . + :codec . . +> :language . + :bitrate . Min. " `; exports[`screen baseline / micro / ascii > 'home' 1`] = ` -"RADIOCLI █████████ +"RADIOCLI ######### > 1 Playing 2 Library @@ -9995,29 +9995,29 @@ exports[`screen baseline / micro / ascii > 'home' 1`] = ` `; exports[`screen baseline / micro / ascii > 'library' 1`] = ` -"RC / Library · 2 statio… +"RC / Library . 2 statio. -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deli… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. " `; exports[`screen baseline / micro / ascii > 'library/empty' 1`] = ` -"RC / Library · 0 statio… +"RC / Library . 0 statio. Your library is empty. -Press f on a station to… +Press f on a station to. " `; exports[`screen baseline / micro / ascii > 'map' 1`] = ` -"RC / World map · 2 coun… +"RC / World map . 2 coun. -> United St… 12,345 +> United St. 12,345 Japan (JP) 456 @@ -10025,37 +10025,37 @@ exports[`screen baseline / micro / ascii > 'map' 1`] = ` `; exports[`screen baseline / micro / ascii > 'nearby' 1`] = ` -"RC / Nearby · 2 station… +"RC / Nearby . 2 station. -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deli… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. " `; exports[`screen baseline / micro / ascii > 'now-playing' 1`] = ` -"KEXP 90.3 FM · playing +"KEXP 90.3 FM . playing -. ∘ @ o o o o o o @ o . -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o o o o o @ o . +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . " `; exports[`screen baseline / micro / ascii > 'now-playing/diagnostics' 1`] = ` -"KEXP 90.3 FM · playing +"KEXP 90.3 FM . playing -. ∘ @ o o o o o o @ o . -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o o o o o @ o . +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . " `; exports[`screen baseline / micro / ascii > 'now-playing/idle' 1`] = ` -"No station tuned · idle +"No station tuned . idle @@ -10067,10 +10067,10 @@ exports[`screen baseline / micro / ascii > 'now-playing/idle' 1`] = ` exports[`screen baseline / micro / ascii > 'search' 1`] = ` "SEARCH +----------------------+ -|› tokyo jazz | +|> tokyo jazz | +----------------------+ -> KEXP 90.3 FM · indie * +> KEXP 90.3 FM . indie * " `; @@ -10080,112 +10080,112 @@ exports[`screen baseline / micro / ascii > 'search/loading' 1`] = ` |* tokyo jazz | +----------------------+ -Searching station direc… +Searching station direc. " `; exports[`screen baseline / micro / ascii > 'settings' 1`] = ` -"RC / Settings · Choose … +"RC / Settings . Choose . -> Playback … 7 settin… - Appearance 6 settin… - Discovery… 4 settin… - Data & li… 2 settin… +> Playback . 7 settin. + Appearance 6 settin. + Discovery. 4 settin. + Data & li. 2 settin. " `; exports[`screen baseline / micro / ascii > 'settings/agent' 1`] = ` -"RC / Settings · Agent c… +"RC / Settings . Agent c. -> Allow loc… off - Install o… run repa… - Open TUI … on - Show Now … on +> Allow loc. off + Install o. run repa. + Open TUI . on + Show Now . on " `; exports[`screen baseline / micro / ascii > 'settings/appearance' 1`] = ` -"RC / Settings · Appeara… +"RC / Settings . Appeara. -> Display c… green - Receiver … pulse-gr… - Transpare… off - ASCII-saf… on +> Display c. green + Receiver . pulse-gr. + Transpare. off + ASCII-saf. on " `; exports[`screen baseline / micro / ascii > 'settings/data' 1`] = ` -"RC / Settings · Data & … +"RC / Settings . Data & . -> Export pr… JSON bac… - Import pr… restore … +> Export pr. JSON bac. + Import pr. restore . " `; exports[`screen baseline / micro / ascii > 'settings/discovery' 1`] = ` -"RC / Settings · Discove… +"RC / Settings . Discove. -> Nearby lo… off - Radio Gar… off - Share fav… on - Refresh p… +> Nearby lo. off + Radio Gar. off + Share fav. on + Refresh p. " `; exports[`screen baseline / micro / ascii > 'settings/media-keys' 1`] = ` -"RC / Settings · Media k… +"RC / Settings . Media k. -> Learn pre… - Learn pla… - Learn nex… - Reset lea… prev 0 ·… +> Learn pre. + Learn pla. + Learn nex. + Reset lea. prev 0 .. " `; exports[`screen baseline / micro / ascii > 'settings/playback' 1`] = ` -"RC / Settings · Playbac… +"RC / Settings . Playbac. -> Audio out… Automatic - AirPlay r… Living R… +> Audio out. Automatic + AirPlay r. Living R. Volume up - Volume do… + Volume do. " `; exports[`screen baseline / micro / ascii > 'settings/updates' 1`] = ` -"RC / Settings · Updates… +"RC / Settings . Updates. -> Automatic… on - Install u… v0.3.0 a… +> Automatic. on + Install u. v0.3.0 a. " `; exports[`screen baseline / micro / ascii > 'stations' 1`] = ` -"RC / Stations · 2 stati… +"RC / Stations . 2 stati. -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deli… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. " `; exports[`screen baseline / micro / ascii > 'stats' 1`] = ` -"RC / Listening stats · … +"RC / Listening stats . . -Listening · 1.5h -Favorite · KEXP 90.3 FM -Sessions · 2 -Stations (2+ min) · 2 +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 " `; exports[`screen baseline / micro / ascii-no-color > 'airplay-code' 1`] = ` -"RC / AirPlay code · KEX… +"RC / AirPlay code . KEX. Code: 1234 @@ -10195,9 +10195,9 @@ Code: 1234 `; exports[`screen baseline / micro / ascii-no-color > 'airplay-settings' 1`] = ` -"RC / AirPlay · 1 receiv… +"RC / AirPlay . 1 receiv. -> Living Room · speaker… +> Living Room . speaker. @@ -10316,7 +10316,7 @@ BETA . Keep backup exports[`screen baseline / micro / ascii-no-color > 'alarms/verification' 1`] = ` "Alarm setup verifie. -- -Rehearsing “Morning rad. +Rehearsing "Morning rad. READY WITH LIMITATIONS . Review warnings before Disposable job regis. @@ -10325,9 +10325,9 @@ return to Alarms. Run" `; exports[`screen baseline / micro / ascii-no-color > 'countries' 1`] = ` -"RC / Countries · 2 coun… +"RC / Countries . 2 coun. -> United St… 12,345 +> United St. 12,345 Japan (JP) 456 @@ -10335,37 +10335,37 @@ exports[`screen baseline / micro / ascii-no-color > 'countries' 1`] = ` `; exports[`screen baseline / micro / ascii-no-color > 'explore' 1`] = ` -"RC / Explore · 48.9N, 2… +"RC / Explore . 48.9N, 2. .:+#+:: @++*#*:. .+:. +%%-+#-. :#: -- --. .::--:-+++++++- -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deli…" +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli." `; exports[`screen baseline / micro / ascii-no-color > 'help' 1`] = ` -"RC / Help · Scroll for … +"RC / Help . Scroll for . Navigation - ↑/↓ or n/p · Move sel… - ←/→ or Tab · Switch t… - [ / ] · Page selectio… + ^/v or n/p . Move sel. + or Tab . Switch t. + [ / ] . Page selectio. " `; exports[`screen baseline / micro / ascii-no-color > 'help/commands' 1`] = ` -"RC / Help · Scroll for … +"RC / Help . Scroll for . - :country … - :codec · … -> :language … - :bitrate · Min… + :country . + :codec . . +> :language . + :bitrate . Min. " `; exports[`screen baseline / micro / ascii-no-color > 'home' 1`] = ` -"RADIOCLI █████████ +"RADIOCLI ######### > 1 Playing 2 Library @@ -10375,29 +10375,29 @@ exports[`screen baseline / micro / ascii-no-color > 'home' 1`] = ` `; exports[`screen baseline / micro / ascii-no-color > 'library' 1`] = ` -"RC / Library · 2 statio… +"RC / Library . 2 statio. -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deli… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. " `; exports[`screen baseline / micro / ascii-no-color > 'library/empty' 1`] = ` -"RC / Library · 0 statio… +"RC / Library . 0 statio. Your library is empty. -Press f on a station to… +Press f on a station to. " `; exports[`screen baseline / micro / ascii-no-color > 'map' 1`] = ` -"RC / World map · 2 coun… +"RC / World map . 2 coun. -> United St… 12,345 +> United St. 12,345 Japan (JP) 456 @@ -10405,37 +10405,37 @@ exports[`screen baseline / micro / ascii-no-color > 'map' 1`] = ` `; exports[`screen baseline / micro / ascii-no-color > 'nearby' 1`] = ` -"RC / Nearby · 2 station… +"RC / Nearby . 2 station. -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deli… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. " `; exports[`screen baseline / micro / ascii-no-color > 'now-playing' 1`] = ` -"KEXP 90.3 FM · playing +"KEXP 90.3 FM . playing -. ∘ @ o o o o o o @ o . -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o o o o o @ o . +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . " `; exports[`screen baseline / micro / ascii-no-color > 'now-playing/diagnostics' 1`] = ` -"KEXP 90.3 FM · playing +"KEXP 90.3 FM . playing -. ∘ @ o o o o o o @ o . -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o @ @ o o o o ∘ -. ∘ @ o o o o o o @ o . +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . " `; exports[`screen baseline / micro / ascii-no-color > 'now-playing/idle' 1`] = ` -"No station tuned · idle +"No station tuned . idle @@ -10447,10 +10447,10 @@ exports[`screen baseline / micro / ascii-no-color > 'now-playing/idle' 1`] = ` exports[`screen baseline / micro / ascii-no-color > 'search' 1`] = ` "SEARCH +----------------------+ -|› tokyo jazz | +|> tokyo jazz | +----------------------+ -> KEXP 90.3 FM · indie * +> KEXP 90.3 FM . indie * " `; @@ -10460,107 +10460,107 @@ exports[`screen baseline / micro / ascii-no-color > 'search/loading' 1`] = ` |* tokyo jazz | +----------------------+ -Searching station direc… +Searching station direc. " `; exports[`screen baseline / micro / ascii-no-color > 'settings' 1`] = ` -"RC / Settings · Choose … +"RC / Settings . Choose . -> Playback … 7 settin… - Appearance 6 settin… - Discovery… 4 settin… - Data & li… 2 settin… +> Playback . 7 settin. + Appearance 6 settin. + Discovery. 4 settin. + Data & li. 2 settin. " `; exports[`screen baseline / micro / ascii-no-color > 'settings/agent' 1`] = ` -"RC / Settings · Agent c… +"RC / Settings . Agent c. -> Allow loc… off - Install o… run repa… - Open TUI … on - Show Now … on +> Allow loc. off + Install o. run repa. + Open TUI . on + Show Now . on " `; exports[`screen baseline / micro / ascii-no-color > 'settings/appearance' 1`] = ` -"RC / Settings · Appeara… +"RC / Settings . Appeara. -> Display c… green - Receiver … pulse-gr… - Transpare… off - ASCII-saf… on +> Display c. green + Receiver . pulse-gr. + Transpare. off + ASCII-saf. on " `; exports[`screen baseline / micro / ascii-no-color > 'settings/data' 1`] = ` -"RC / Settings · Data & … +"RC / Settings . Data & . -> Export pr… JSON bac… - Import pr… restore … +> Export pr. JSON bac. + Import pr. restore . " `; exports[`screen baseline / micro / ascii-no-color > 'settings/discovery' 1`] = ` -"RC / Settings · Discove… +"RC / Settings . Discove. -> Nearby lo… off - Radio Gar… off - Share fav… on - Refresh p… +> Nearby lo. off + Radio Gar. off + Share fav. on + Refresh p. " `; exports[`screen baseline / micro / ascii-no-color > 'settings/media-keys' 1`] = ` -"RC / Settings · Media k… +"RC / Settings . Media k. -> Learn pre… - Learn pla… - Learn nex… - Reset lea… prev 0 ·… +> Learn pre. + Learn pla. + Learn nex. + Reset lea. prev 0 .. " `; exports[`screen baseline / micro / ascii-no-color > 'settings/playback' 1`] = ` -"RC / Settings · Playbac… +"RC / Settings . Playbac. -> Audio out… Automatic - AirPlay r… Living R… +> Audio out. Automatic + AirPlay r. Living R. Volume up - Volume do… + Volume do. " `; exports[`screen baseline / micro / ascii-no-color > 'settings/updates' 1`] = ` -"RC / Settings · Updates… +"RC / Settings . Updates. -> Automatic… on - Install u… v0.3.0 a… +> Automatic. on + Install u. v0.3.0 a. " `; exports[`screen baseline / micro / ascii-no-color > 'stations' 1`] = ` -"RC / Stations · 2 stati… +"RC / Stations . 2 stati. -> KEXP 90.3 FM · indie * - KCRW 89.9 FM — A deli… +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. " `; exports[`screen baseline / micro / ascii-no-color > 'stats' 1`] = ` -"RC / Listening stats · … +"RC / Listening stats . . -Listening · 1.5h -Favorite · KEXP 90.3 FM -Sessions · 2 -Stations (2+ min) · 2 +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 " `; diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index 6164919..7844df6 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -29,6 +29,10 @@ beforeEach(() => { // fixture size so every layout avoids host terminal commands such as tput. vi.stubEnv('COLUMNS', '100'); vi.stubEnv('LINES', '24'); + vi.stubEnv('LC_ALL', 'en_US.UTF-8'); + vi.stubEnv('TERM', 'xterm-256color'); + vi.stubEnv('RADIOCLI_ASCII', undefined); + vi.stubEnv('RADIOCLI_UNICODE', undefined); const testHome = mkdtempSync(join(tmpdir(), 'radiocli-alarm-tui-home-')); directories.push(testHome); vi.stubEnv('RADIOCLI_HOME', testHome); diff --git a/src/ui/ascii.test.ts b/src/ui/ascii.test.ts index 49b49bc..80d6842 100644 --- a/src/ui/ascii.test.ts +++ b/src/ui/ascii.test.ts @@ -29,6 +29,7 @@ describe('toAsciiSafe', () => { expect(toAsciiSafe('·•●')).toBe('.o@'); expect(toAsciiSafe('a · b')).toBe('a . b'); expect(toAsciiSafe('Loading…')).toBe('Loading.'); + expect(toAsciiSafe('∘ ◆ “radio” ‘track’')).toBe('o * "radio" \'track\''); }); it('preserves direction in visualizer geometry', () => { @@ -37,5 +38,6 @@ describe('toAsciiSafe', () => { it('preserves international station names', () => { expect(toAsciiSafe('Rádio 東京')).toBe('Rádio 東京'); + expect(toAsciiSafe('Cafe\u0301 / Český / हिन्दी / العربية')).toBe('Cafe\u0301 / Český / हिन्दी / العربية'); }); }); diff --git a/src/ui/ascii.ts b/src/ui/ascii.ts index d5ea19f..eed9cd0 100644 --- a/src/ui/ascii.ts +++ b/src/ui/ascii.ts @@ -82,6 +82,11 @@ function asciiGlyph(code: number): string { return 'o'; // ◉ ◒ } + if (code === 0x2218) return 'o'; // ring operator used by receiver patterns + if (code === 0x25c6 || code === 0x25c7) return '*'; // decorative diamonds + if (code === 0x201c || code === 0x201d) return '"'; + if (code === 0x2018 || code === 0x2019) return "'"; + if (code === 0x25b2) { return '^'; // ▲ } @@ -131,8 +136,9 @@ function asciiGlyph(code: number): string { if (code === 0x2191) return '^'; if (code === 0x2193) return 'v'; - // Drop zero-width and combining marks entirely so they do not skew widths. - if (code === 0x200b || code === 0xfeff || (code >= 0x0300 && code <= 0x036f)) { + // Drop invisible separators, but retain combining accents in station data. + // Width measurement already accounts for combining marks as zero columns. + if (code === 0x200b || code === 0xfeff) { return ''; } diff --git a/src/ui/components/AdaptiveMarquee.tsx b/src/ui/components/AdaptiveMarquee.tsx index 369bb0e..02c1af0 100644 --- a/src/ui/components/AdaptiveMarquee.tsx +++ b/src/ui/components/AdaptiveMarquee.tsx @@ -1,6 +1,8 @@ import React, {useEffect, useState} from 'react'; import {Text} from 'ink'; import {displayWidth, sliceDisplay, truncate} from '../format.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type AdaptiveMarqueeProps = { text: string; @@ -13,14 +15,15 @@ const edgePauseMs = 1000; const stepMs = 180; export function AdaptiveMarquee({text, width, active, reduceMotion}: AdaptiveMarqueeProps): React.ReactElement { + const {ascii, reduceMotion: terminalReduceMotion} = useDisplay(); const safeWidth = Math.max(0, width); const maxOffset = Math.max(0, displayWidth(text) - safeWidth); - const animated = active && !reduceMotion && maxOffset > 0; + const animated = active && !reduceMotion && !terminalReduceMotion && maxOffset > 0; const [offset, setOffset] = useState(0); useEffect(() => { setOffset(0); - }, [active, reduceMotion, safeWidth, text]); + }, [active, reduceMotion, terminalReduceMotion, safeWidth, text]); useEffect(() => { if (!animated) return; @@ -34,5 +37,5 @@ export function AdaptiveMarquee({text, width, active, reduceMotion}: AdaptiveMar const visible = animated ? sliceDisplay(text, Math.min(offset, maxOffset), safeWidth) : truncate(text, safeWidth); - return {visible}; + return {ascii ? toAsciiSafe(visible) : visible}; } diff --git a/src/ui/components/Logo.tsx b/src/ui/components/Logo.tsx index 9269693..9e68a69 100644 --- a/src/ui/components/Logo.tsx +++ b/src/ui/components/Logo.tsx @@ -1,15 +1,17 @@ import React from 'react'; import {Box, Text} from 'ink'; import {textMuted} from '../theme.js'; +import {useDisplay} from '../display-context.js'; const logoSpectrumColors =['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc']; function LogoSpectrum(): React.ReactElement { + const {ascii} = useDisplay(); return ( <> {logoSpectrumColors.map(color => ( - - ██ + + {ascii ? '##' : '██'} ))} @@ -17,6 +19,7 @@ function LogoSpectrum(): React.ReactElement { } export function Logo({compact = false, width = 80}: {compact?: boolean; width?: number} = {}): React.ReactElement { + const {ascii} = useDisplay(); if (compact) { const spectrumColumns = Math.max(1, width - 10); const colors = logoSpectrumColors.slice(0, Math.min(logoSpectrumColors.length, spectrumColumns)); @@ -24,7 +27,7 @@ export function Logo({compact = false, width = 80}: {compact?: boolean; width?: RADIOCLI - {colors.map(color => )} + {colors.map(color => {ascii ? '#' : '█'})} ); } diff --git a/src/ui/components/Menu.tsx b/src/ui/components/Menu.tsx index 4467b2f..57f075c 100644 --- a/src/ui/components/Menu.tsx +++ b/src/ui/components/Menu.tsx @@ -12,9 +12,9 @@ export function Menu({items, selected, keyFor, render}: MenuProps): React. const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1; return ( - + {items.map((item, position) => ( - + {render(item, position, position === activeIndex)} ))} @@ -23,7 +23,7 @@ export function Menu({items, selected, keyFor, render}: MenuProps): React. } export function Pointer({active}: {active: boolean}): React.ReactElement { - return {active ? '> ' : ' '}; + return {active ? '> ' : ' '}; } function defaultMenuKey(item: unknown): React.Key { diff --git a/src/ui/components/ScreenHeader.tsx b/src/ui/components/ScreenHeader.tsx index d28ebc2..057ff13 100644 --- a/src/ui/components/ScreenHeader.tsx +++ b/src/ui/components/ScreenHeader.tsx @@ -32,7 +32,7 @@ export function ScreenHeader({title, width, theme, subtitle, right}: ScreenHeade {a(safeTitle)} - {rule} + {rule} {rightText ? {a(rightText)} : null} {subtitle ? {a(truncate(subtitle, width))} : null} diff --git a/src/ui/components/StationList.tsx b/src/ui/components/StationList.tsx index 2985fd4..1f88681 100644 --- a/src/ui/components/StationList.tsx +++ b/src/ui/components/StationList.tsx @@ -7,6 +7,7 @@ import {Menu, Pointer} from './Menu.js'; import {visibleWindow} from '../list-window.js'; import {useDisplay} from '../display-context.js'; import {AdaptiveMarquee} from './AdaptiveMarquee.js'; +import {toAsciiSafe} from '../ascii.js'; type StationListProps = { stations: Station[]; @@ -31,12 +32,13 @@ export function StationList({ emptyHint = 'Try another view or clear active filters.', showCount = true }: StationListProps): React.ReactElement { - const {reduceMotion} = useDisplay(); + const {reduceMotion, ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; if (stations.length === 0) { return ( - {emptyTitle} - {emptyHint} + {a(emptyTitle)} + {a(emptyHint)} ); } @@ -72,7 +74,7 @@ export function StationList({ reduceMotion={reduceMotion} /> - {favorite ? : null} + {favorite ? {a(' ★')} : null} ); } @@ -90,10 +92,10 @@ export function StationList({ - {favorite ? : null} + {favorite ? {a(' ★')} : null} - {truncate(active ? selectedMetadata : standardMetadata, metaWidth)} + {a(truncate(active ? selectedMetadata : standardMetadata, metaWidth))} ); diff --git a/src/ui/components/TopTabs.tsx b/src/ui/components/TopTabs.tsx index 4420e41..1934db3 100644 --- a/src/ui/components/TopTabs.tsx +++ b/src/ui/components/TopTabs.tsx @@ -38,7 +38,7 @@ export function TopTabs({tabs, active, theme, width, backendLabel}: TopTabsProps const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightWidth : 0)); return ( - + `${tab.label}${tab.screen === active ? ' (selected)' : ''}`).join(', ')}. Audio output: ${backendLabel}.`}> {box.tl} @@ -51,7 +51,7 @@ export function TopTabs({tabs, active, theme, width, backendLabel}: TopTabsProps {visibleTabs.map((item, index) => ( {item.type === 'overflow' ? ( - + {ascii ? '.' : '…'} ) : ( {item.tab.label} diff --git a/src/ui/display-context.test.ts b/src/ui/display-context.test.ts index 2ff920d..f5ecbfb 100644 --- a/src/ui/display-context.test.ts +++ b/src/ui/display-context.test.ts @@ -14,7 +14,9 @@ describe('display mode', () => { app: appBackground, panel: panelBackground, ascii: false, - reduceMotion: false + reduceMotion: false, + screenReader: false, + colorLevel: 3 }); }); @@ -27,6 +29,22 @@ describe('display mode', () => { expect(resolveDisplayMode({asciiMode: true, reduceMotion: true}, {})).toMatchObject({ascii: true, reduceMotion: true}); }); + it('applies terminal overrides without changing persisted preferences', () => { + const settings = Object.freeze({asciiMode: false, reduceMotion: false, transparentBackground: false}); + expect(resolveDisplayMode(settings, {TERM: 'dumb'})).toMatchObject({ascii: true, reduceMotion: true, colorLevel: 0, app: undefined, panel: undefined}); + expect(settings).toEqual({asciiMode: false, reduceMotion: false, transparentBackground: false}); + expect(resolveDisplayMode({asciiMode: true}, {RADIOCLI_UNICODE: '1'}).ascii).toBe(false); + }); + + it('drops forced dark backgrounds on sixteen-color terminals', () => { + expect(resolveDisplayMode({}, {TERM: 'xterm'})).toMatchObject({colorLevel: 1, app: undefined, panel: undefined}); + expect(resolveDisplayMode({}, {TERM: 'xterm-256color'})).toMatchObject({colorLevel: 2, app: appBackground, panel: panelBackground}); + }); + + it.each([{INK_SCREEN_READER: 'true'}, {RADIOCLI_SCREEN_READER: '1'}])('marks %j as static screen-reader output', env => { + expect(resolveDisplayMode({}, env)).toMatchObject({screenReader: true, reduceMotion: true, app: undefined, panel: undefined}); + }); + it('switches panel borders to ASCII classic in ascii mode', () => { expect(panelBorderStyle(false)).toBe('round'); expect(panelBorderStyle(false, 'single')).toBe('single'); diff --git a/src/ui/display-context.ts b/src/ui/display-context.ts index a05a430..996ab5a 100644 --- a/src/ui/display-context.ts +++ b/src/ui/display-context.ts @@ -1,4 +1,5 @@ import {createContext, useContext} from 'react'; +import {resolveTerminalCapabilities, type TerminalColorLevel, type TerminalEvidence} from '../platform/terminal.js'; import {appBackground, panelBackground} from './theme.js'; type DisplayBackgrounds = { @@ -9,6 +10,8 @@ type DisplayBackgrounds = { export type DisplayMode = DisplayBackgrounds & { ascii: boolean; reduceMotion: boolean; + screenReader: boolean; + colorLevel: TerminalColorLevel; }; type DisplaySettings = { @@ -26,14 +29,15 @@ export function noColorRequested(env: NodeJS.ProcessEnv = process.env): boolean return typeof env.NO_COLOR === 'string' && env.NO_COLOR.length > 0; } -// Resolve the effective display mode from the user's settings and the NO_COLOR -// environment so light terminals keep their own background. -export function resolveDisplayMode(settings: DisplaySettings, env: NodeJS.ProcessEnv = process.env): DisplayMode { - const backgrounds = settings.transparentBackground || noColorRequested(env) ? transparentBackgrounds : opaqueBackgrounds; - return {...backgrounds, ascii: Boolean(settings.asciiMode), reduceMotion: Boolean(settings.reduceMotion)}; +// Sixteen-color palettes cannot safely reproduce our dark fills. Let those +// terminals keep their own background while Ink quantizes the foregrounds. +export function resolveDisplayMode(settings: DisplaySettings, env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): DisplayMode { + const terminal = resolveTerminalCapabilities(env, {...settings, ...evidence}); + const backgrounds = settings.transparentBackground || terminal.colorLevel < 2 ? transparentBackgrounds : opaqueBackgrounds; + return {...backgrounds, ascii: !terminal.unicode, reduceMotion: terminal.reduceMotion, screenReader: terminal.screenReader, colorLevel: terminal.colorLevel}; } -const defaultMode: DisplayMode = {...opaqueBackgrounds, ascii: false, reduceMotion: false}; +const defaultMode: DisplayMode = resolveDisplayMode({}, {}); export const DisplayContext = createContext(defaultMode); diff --git a/src/ui/portable-app.test.tsx b/src/ui/portable-app.test.tsx new file mode 100644 index 0000000..90da41b --- /dev/null +++ b/src/ui/portable-app.test.tsx @@ -0,0 +1,149 @@ +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {PassThrough, Writable} from 'node:stream'; +import {stripVTControlCharacters} from 'node:util'; +import {act} from 'react'; +import {render} from 'ink'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {PlayerController} from '../player/player-controller.js'; +import * as backendInstall from '../player/backend-install.js'; +import * as airplayDiscovery from '../player/airplay-discovery.js'; +import * as session from '../agent/session.js'; +import * as presence from '../alarms/tui-presence.js'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import type {PlaybackState} from '../types.js'; +import {App} from './App.js'; +import type {AlarmTuiService} from './alarm-tui-service.js'; +import * as visualizers from './visualizers/receiver-visualizers.js'; + +const directories: string[] = []; +const instances: ReturnType[] = []; +const runtime = {capabilities: {supported: true, exactWake: false, catchUpAfterWake: true, message: 'ready'}, degradedAlarmIds: new Set(), message: 'Native scheduler ready.'}; +const service: AlarmTuiService = { + sync: async () => null, syncAll: async () => [], remove: async () => undefined, + runtimeStatus: async () => runtime, activeAlarms: async () => [], prepareTerminalAccess: async () => undefined, + verifySetup: async () => ({state: 'passed', startedAt: '2026-09-07T12:00:00.000Z', steps: []}) +}; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + for (const name of ['TERM', 'NO_COLOR', 'RADIOCLI_ASCII', 'RADIOCLI_UNICODE', 'RADIOCLI_SCREEN_READER', 'RADIOCLI_DISABLE_ANIMATION', 'RADIO_ATLAS_DISABLE_ANIMATION', 'RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH']) vi.stubEnv(name, undefined); + vi.stubEnv('INK_SCREEN_READER', 'false'); + vi.stubEnv('LC_ALL', 'en_US.UTF-8'); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '1'); + vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv']); + vi.spyOn(airplayDiscovery, 'discoverAirPlayDevices').mockResolvedValue([]); + vi.spyOn(ProviderManager.prototype, 'health').mockResolvedValue({}); + vi.spyOn(session, 'startRadioSession').mockResolvedValue({close: async () => undefined}); + vi.spyOn(presence, 'registerTuiPresence').mockReturnValue(() => undefined); + vi.spyOn(PlayerController.prototype, 'stop').mockResolvedValue(undefined); +}); +afterEach(() => { + act(() => { for (const instance of instances.splice(0)) { instance.unmount(); instance.cleanup(); } }); + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + for (const directory of directories.splice(0)) rmSync(directory, {recursive: true, force: true}); +}); + +describe('App terminal integration', () => { + it('keeps the TUI usable when its optional alarm presence directory is read-only', async () => { + vi.mocked(presence.registerTuiPresence).mockImplementation(() => {throw new Error('EACCES: private runtime directory');}); + const {input, lastFrame} = await openApp(false); + expect(lastFrame()).toContain('Alarm controls cannot register'); + await input('\u001B[B'); + await input('\r'); + expect(lastFrame()).toContain('Portable Radio'); + }); + + it('finishes unmount when optional alarm presence cleanup fails', async () => { + const warning = vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.mocked(presence.registerTuiPresence).mockReturnValue(() => {throw new Error('EACCES: private runtime directory');}); + await openApp(false); + expect(() => act(() => {const instance = instances.pop()!;instance.unmount();instance.cleanup();})).not.toThrow(); + expect(warning).toHaveBeenCalledWith(expect.stringContaining('could not remove its alarm-control presence marker')); + }); + + it.each(['RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH'])('skips automatic receiver discovery with %s while keeping library navigation', async variable => { + vi.stubEnv(variable, '1'); + const {input, lastFrame} = await openApp(false); + expect(airplayDiscovery.discoverAirPlayDevices).not.toHaveBeenCalled(); + await input('\u001B[B'); + await input('\r'); + expect(lastFrame()).toContain('Portable Radio'); + }); + + it('uses Ink accessibility state, keeps keyboard navigation, and leaves saved preferences intact', async () => { + setPlayback('playing'); + const visualizer = vi.spyOn(visualizers, 'buildVisualizer'); + const {store, input, frames, lastFrame} = await openApp(true); + const saved = store.snapshot().settings; + await input('\r'); + expect(lastFrame()).toContain('Now playing'); + expect(lastFrame()).toContain('Station: No station tuned'); + expect(visualizer).not.toHaveBeenCalled(); + const frameCount = frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + expect(frames).toHaveLength(frameCount); + await input('b'); + expect(lastFrame()).toContain('Live public radio from around the world'); + expect(lastFrame()).toContain('(selected)'); + expect(store.snapshot().settings).toEqual(saved); + expect(saved).toMatchObject({asciiMode: false, reduceMotion: false, transparentBackground: false}); + }); + + it.each([ + {env: {TERM: 'dumb'}, animated: false}, + {env: {RADIOCLI_SCREEN_READER: '1'}, animated: false}, + {env: {}, animated: true} + ])('keeps the loading spinner animated=$animated for $env without changing settings', async ({env, animated}) => { + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value); + setPlayback('playing'); + vi.spyOn(ProviderManager.prototype, 'resolve').mockResolvedValue({url: 'https://example.test/portable'}); + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(() => new Promise(() => undefined)); + const {store, input, frames} = await openApp(false); + await input('\u001B[B'); + await input('\r'); + await input('\r'); + expect(play).toHaveBeenCalled(); + const frameCount = frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + if (animated) expect(frames.length).toBeGreaterThan(frameCount); + else expect(frames).toHaveLength(frameCount); + expect(store.snapshot().settings).toMatchObject({asciiMode: false, reduceMotion: false, transparentBackground: false}); + }); +}); + +function setPlayback(state: 'playing' | 'loading'): void { + const playback: PlaybackState = {backend: 'mpv', state, volume: 70, muted: false, ready: state === 'playing'}; + vi.spyOn(PlayerController.prototype, 'getState').mockReturnValue(playback); + vi.spyOn(PlayerController.prototype, 'onChange').mockImplementation(listener => { listener(playback); return () => undefined; }); +} + +async function openApp(screenReader: boolean) { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-portable-app-')); + directories.push(directory); + vi.stubEnv('RADIOCLI_HOME', directory); + const store = new JsonLibraryStore(join(directory, 'library.json')); + store.updateSettings({resumeOnLaunch: false, automaticUpdateChecks: false, asciiMode: false, reduceMotion: false, transparentBackground: false}); + store.toggleFavorite({id: 'portable', provider: 'radio-browser', name: 'Portable Radio', tags: [], streamUrl: 'https://example.test/portable'}); + const frames: string[] = []; + const stdout = Object.assign(new Writable({write(chunk, _encoding, done) { frames.push(String(chunk)); done(); }}), {isTTY: true, columns: 100, rows: 30, getColorDepth: () => 24}) as NodeJS.WriteStream; + const errors: string[] = []; + const stderr = new Writable({write(chunk, _encoding, done) { errors.push(String(chunk)); done(); }}) as NodeJS.WriteStream; + const stdin = Object.assign(new PassThrough(), {isTTY: true, setRawMode: () => undefined, ref: () => undefined, unref: () => undefined}) as unknown as NodeJS.ReadStream; + await act(async () => { + instances.push(render(, {stdout, stderr, stdin, debug: true, patchConsole: false, exitOnCtrlC: false, isScreenReaderEnabled: screenReader, kittyKeyboard: {mode: 'disabled'}})); + }); + expect(errors.join('')).toBe(''); + return { + store, frames, + lastFrame: () => stripVTControlCharacters(frames.slice().reverse().find(frame => frame.toLowerCase().includes('radiocli')) ?? ''), + input: async (data: string) => { await act(async () => { (stdin as unknown as PassThrough).write(data); }); } + }; +} diff --git a/src/ui/portable-render.test.tsx b/src/ui/portable-render.test.tsx new file mode 100644 index 0000000..6ca8c94 --- /dev/null +++ b/src/ui/portable-render.test.tsx @@ -0,0 +1,144 @@ +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {stripVTControlCharacters} from 'node:util'; +import {act, type ComponentProps} from 'react'; +import {Box, renderToString} from 'ink'; +import {cleanup, render} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {LibraryState, Screen, Station} from '../types.js'; +import {AppContent} from './AppContent.js'; +import {defaultExploreCursor} from './app-state.js'; +import {defaultAlarmDraft} from './alarm-editor.js'; +import {DisplayContext, resolveDisplayMode} from './display-context.js'; +import {computeTerminalLayout} from './layout.js'; +import * as visualizers from './visualizers/receiver-visualizers.js'; + +type ContentProps = ComponentProps; +const station: Station = {id: 'world', provider: 'radio-browser', name: '東京 FM / Café', country: 'Japan', countryCode: 'JP', city: '東京', tags: ['jazz'], codec: 'MP3', bitrate: 128}; +const library: LibraryState = { + favorites: [station], recent: [], imported: [], activity: {sessions: []}, trackHistory: [], searchHistory: [], alarms: [], + settings: {theme: 'green', receiverStyle: 'pulse-grid', volume: 70, enableRadioGarden: false, enableNearbyLocation: false, shareDirectoryVotes: true, preferredBackend: 'auto', tuneTimeoutSeconds: 12, skipBrokenStreams: true, mediaKeys: {previous: [], playPause: [], next: []}, resumeOnLaunch: false, asciiMode: false, reduceMotion: false, transparentBackground: false} +}; +const inkRequire = createRequire(import.meta.resolve('ink')); +const inkChalk = (await import(pathToFileURL(inkRequire.resolve('chalk')).href)).default as {level: number}; +const originalColorLevel = inkChalk.level; +const sizes = [{name: 'full', columns: 100, rows: 30}, {name: 'compact', columns: 50, rows: 16}, {name: 'micro', columns: 24, rows: 8}]; +const routes: Screen[] = ['home', 'now-playing', 'library', 'search', 'countries', 'explore', 'map', 'nearby', 'stations', 'stats', 'settings', 'help', 'airplay-code', 'airplay-settings', 'alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing']; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubEnv('INK_SCREEN_READER', 'false'); +}); +afterEach(() => { + act(() => cleanup()); + inkChalk.level = originalColorLevel; + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('portable terminal rendering', () => { + it.each(sizes)('preserves multilingual station and track data with ASCII decoration in $name', size => { + const props = contentProps('now-playing', size); + const display = resolveDisplayMode(library.settings, {LC_ALL: 'C', NO_COLOR: '1'}); + inkChalk.level = display.colorLevel; + const frame = staticFrame(, size.columns); + expect(frame).toContain('東京 FM / Café'); + if (size.name !== 'micro') expect(frame).toContain('音楽 / Café'); + expect(frame).not.toMatch(/[\u2500-\u259f\u2800-\u28ff]/u); + expect(frame).not.toContain('\u001B['); + }); + + it.each(sizes)('uses ASCII decoration across every route in $name', size => { + const display = resolveDisplayMode(library.settings, {TERM: 'dumb'}); + inkChalk.level = display.colorLevel; + for (const screen of routes) { + const frame = staticFrame(, size.columns); + expect(frame, screen).not.toMatch(/[\u2190-\u2193\u2500-\u259f\u2800-\u28ff·…›★☆∘◆“”]/u); + expect(frame, screen).not.toContain('\u001B['); + } + }); + + it.each(sizes)('hides receiver generation and renders readable screen-reader content in $name', async size => { + const visualizer = vi.spyOn(visualizers, 'buildVisualizer'); + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode(library.settings, {INK_SCREEN_READER: 'true'}); + const props = contentProps('now-playing', size); + let view!: ReturnType; + act(() => { view = render(); }); + const frame = stripVTControlCharacters(view.lastFrame() ?? ''); + expect(visualizer).not.toHaveBeenCalled(); + expect(frame).toContain('東京 FM / Café'); + expect(frame).toContain('音楽 / Café'); + expect(frame.toLowerCase()).toContain('playing'); + expect(frame).not.toMatch(/[\u2500-\u259f\u2800-\u28ff]/u); + const frames = view.frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(2400); }); + expect(view.frames).toHaveLength(frames); + }); + + it.each(['home', 'library', 'search', 'countries', 'explore', 'map', 'stats', 'settings', 'help', 'alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing'] as const)('retains the %s route in accessible output', screen => { + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode(library.settings, {INK_SCREEN_READER: 'true'}); + let view!: ReturnType; + act(() => { view = render(); }); + const frame = stripVTControlCharacters(view.lastFrame() ?? ''); + expect(frame.trim()).not.toBe(''); + expect(frame).not.toContain('Unknown screen.'); + if (screen === 'home') { + expect(frame).toContain('Playing'); + expect(frame).toContain('Settings'); + expect(frame).not.toContain('█'); + } + }); + + it.each([{TERM: 'dumb'}, {RADIOCLI_SCREEN_READER: '1'}])('stops compact station marquees with %j', async env => { + const props = contentProps('library', sizes[2]!); + props.displayStations = [{...station, name: `${station.name} — A long international station name that would scroll`}]; + const display = resolveDisplayMode(library.settings, env); + let view!: ReturnType; + act(() => { view = render(); }); + const frames = view.frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(2400); }); + expect(view.frames).toHaveLength(frames); + }); + + it.each([{name: '16-color', env: {TERM: 'xterm'}, maximumLevel: 1}, {name: '256-color', env: {TERM: 'xterm-256color'}, maximumLevel: 2}])('keeps actual Ink colors within the $name terminal palette', ({env, maximumLevel}) => { + const display = resolveDisplayMode(library.settings, env); + inkChalk.level = display.colorLevel; + const frame = staticFrame(, 100); + expect(frame).toContain('\u001B['); + expect(frame).not.toMatch(/\u001B\[(?:38|48);2;/u); + if (maximumLevel === 1) expect(frame).not.toMatch(/\u001B\[(?:38|48);5;/u); + expect(stripVTControlCharacters(frame)).toContain('東京 FM / Café'); + }); +}); + +function staticFrame(tree: Parameters[0], columns: number): string { + let frame = ''; + act(() => { frame = renderToString(tree, {columns}); }); + return frame; +} + +function contentProps(screen: Screen, size: {columns: number; rows: number}): ContentProps { + const layout = computeTerminalLayout(size.columns, size.rows); + return { + airPlayDevices: [], airPlayCode: '', appVersion: '0.2.3', backends: ['mpv'], countryFilter: '', + diagnostics: {backend: 'mpv', availableBackends: ['mpv'], preferredBackend: 'auto', active: true, volume: 70, muted: false, ready: true}, + displayStations: [station], editingCountryFilter: false, editingSearch: true, + exploreCursor: defaultExploreCursor, favoriteKeys: new Set(['radio-browser:world']), filterLabel: 'none', + filteredCountries: [{name: 'Japan', code: 'JP', stationCount: 456}], frameWidth: layout.frameWidth, layout, library, + loadingCountries: false, loadingStations: false, nowPlaying: {title: '音楽 / Café', updatedAt: '2026-09-07T12:00:00.000Z'}, + playback: {backend: 'mpv', state: 'playing', volume: 70, muted: false, ready: true}, playingStation: station, providerHealth: {}, + searchQuery: '東京', screen, settingsPage: 'root', selected: 0, showDiagnostics: false, sleepLabel: 'Sleep off', + stationContext: {title: 'Library', subtitle: 'Saved stations', stations: [station]}, stationFavorite: true, stationTime: '12:00', storePath: '/fixture/radiocli.json', theme: 'green', + alarmTui: { + draft: defaultAlarmDraft(station), editorField: 'label', editingField: false, editorControl: null, timeSegment: 'hour', weekdayIndex: 0, + validationError: null, saving: false, pickerChoices: [], pickerFallback: false, runtime: null, verification: null, activeAlarms: [], activeSelected: 0, + snoozeMinutes: 10, busyAlarmIds: new Set(), itemCount: () => undefined, handleInput: () => false, openForStation: () => undefined, openActive: () => undefined + } + }; +} diff --git a/src/ui/screens/AirPlayCodeScreen.tsx b/src/ui/screens/AirPlayCodeScreen.tsx index dca3b5c..09af02c 100644 --- a/src/ui/screens/AirPlayCodeScreen.tsx +++ b/src/ui/screens/AirPlayCodeScreen.tsx @@ -3,6 +3,8 @@ import {Box, Text} from 'ink'; import type {AirPlayDevice, PlaybackState, ThemeName} from '../../types.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; import {textDim, textMuted, themeAccent} from '../theme.js'; type AirPlayCodeScreenProps = { @@ -20,6 +22,8 @@ export function AirPlayCodeScreen({ theme, width }: AirPlayCodeScreenProps): React.ReactElement { + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const lineWidth = Math.max(24, width - 4); const promptActive = isAirPlayCodePromptActive(playback); @@ -38,7 +42,7 @@ export function AirPlayCodeScreen({ - Receiver: {truncate(receiverName, Math.max(8, lineWidth - 10))} + Receiver: {a(truncate(receiverName, Math.max(8, lineWidth - 10)))} Playback: {playback.backend} / {playback.state} @@ -55,7 +59,7 @@ export function AirPlayCodeScreen({ Code {code ? masked : 'type the code from the receiver'} - Enter submits · Backspace edits · Esc returns to AirPlay + {a('Enter submits · Backspace edits · Esc returns to AirPlay')} ); diff --git a/src/ui/screens/AirPlaySettingsScreen.tsx b/src/ui/screens/AirPlaySettingsScreen.tsx index fb242d7..13bacf3 100644 --- a/src/ui/screens/AirPlaySettingsScreen.tsx +++ b/src/ui/screens/AirPlaySettingsScreen.tsx @@ -12,6 +12,8 @@ import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; import {textDim, textMuted, themeAccent} from '../theme.js'; import {visibleWindow} from '../list-window.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type AirPlaySettingsScreenProps = { selected: number; @@ -32,6 +34,8 @@ export function AirPlaySettingsScreen({ width, height }: AirPlaySettingsScreenProps): React.ReactElement { + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const availability = airPlayAvailability(backends, devices); const selectedDevice = selectedAirPlayDevice(settings, devices); @@ -53,13 +57,13 @@ export function AirPlaySettingsScreen({ - Audio output: {preferredOutput} · Streaming:{' '} - {availability.ready ? 'ready' : 'unavailable'} · Receivers:{' '} + Audio output: {preferredOutput}{a(' · Streaming: ')} + {availability.ready ? 'ready' : 'unavailable'}{a(' · Receivers: ')} {devices.length} - {truncate(availability.detail, lineWidth)} + {a(truncate(availability.detail, lineWidth))} - Selected: {truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10))} + Selected: {a(truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)))} {availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? ( @@ -68,7 +72,7 @@ export function AirPlaySettingsScreen({ ) : null} {selectedMissing ? ( - Saved receiver is not visible: {truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))} + Saved receiver is not visible: {a(truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31)))} ) : null} @@ -91,10 +95,10 @@ export function AirPlaySettingsScreen({ - {name} + {a(name)} {isSelected ? {selectedLabel} : null} - · {detail} + {a(` · ${detail}`)} ); }} @@ -103,7 +107,7 @@ export function AirPlaySettingsScreen({ No receivers discovered. )} - {canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers'} + {a(canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers')} diff --git a/src/ui/screens/ExploreScreen.tsx b/src/ui/screens/ExploreScreen.tsx index af83256..1401ccd 100644 --- a/src/ui/screens/ExploreScreen.tsx +++ b/src/ui/screens/ExploreScreen.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {Box, Text} from 'ink'; import type {Station, ThemeName} from '../../types.js'; import type {ExploreCursor} from '../app-state.js'; +import {formatExploreCursor} from '../app-state.js'; import {StationList} from '../components/StationList.js'; import {buildCosmoWorldMap, type CosmoMapCellKind, type CosmoMapRow} from '../cosmo-world-map.js'; import {computeExploreMapLayout} from '../explore-map-layout.js'; @@ -71,6 +72,7 @@ export function ExploreScreen({ width={mapPanelWidth} height={split ? bodyRows : mapRows + 2} flexDirection="column" + aria-label={`World map cursor: ${formatExploreCursor(cursor)}`} > {map.map((row, index) => ( diff --git a/src/ui/screens/HelpScreen.tsx b/src/ui/screens/HelpScreen.tsx index b09650b..7a8c679 100644 --- a/src/ui/screens/HelpScreen.tsx +++ b/src/ui/screens/HelpScreen.tsx @@ -6,6 +6,8 @@ import {ScreenHeader} from '../components/ScreenHeader.js'; import {commandHelp, keyHelpSections} from '../help-content.js'; import {padDisplayEnd, truncate} from '../format.js'; import {visibleWindow} from '../list-window.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type HelpScreenProps = { theme: ThemeName; @@ -17,6 +19,8 @@ type HelpScreenProps = { type HelpRow = {key: string; heading?: string; keys?: string; description?: string}; export function HelpScreen({theme, width, height, selected}: HelpScreenProps): React.ReactElement { + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const keyColumnWidth = 16; const lineWidth = Math.max(28, width - 2); @@ -47,12 +51,12 @@ export function HelpScreen({theme, width, height, selected}: HelpScreenProps): R {row.heading} ) : ( - {padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth)} - {truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth))} + {a(padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth))} + {a(truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth)))} ))} - Showing {window.start + 1}-{window.end} of {rows.length} · ↑/↓ scroll + {a(`Showing ${window.start + 1}-${window.end} of ${rows.length} · ↑/↓ scroll`)} ); } diff --git a/src/ui/screens/HomeScreen.tsx b/src/ui/screens/HomeScreen.tsx index a8d52df..ea2313b 100644 --- a/src/ui/screens/HomeScreen.tsx +++ b/src/ui/screens/HomeScreen.tsx @@ -6,6 +6,8 @@ import {textMuted, themeAccent} from '../theme.js'; import {homeItems} from '../screen-items.js'; import type {LibraryState, ThemeName} from '../../types.js'; import {displayWidth} from '../format.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type HomeProps = { selected: number; @@ -14,6 +16,8 @@ type HomeProps = { }; export function HomeScreen({selected, theme, library}: HomeProps): React.ReactElement { + const {ascii} = useDisplay(); + const summary = `${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`; const titleWidth = Math.max(...homeItems.map(item => displayWidth(item.label))) + 2; return ( @@ -39,7 +43,7 @@ export function HomeScreen({selected, theme, library}: HomeProps): React.ReactEl - {library.recent.length} recent · {library.favorites.length} favorites · {library.imported.length} imported + {ascii ? toAsciiSafe(summary) : summary} diff --git a/src/ui/screens/MapScreen.tsx b/src/ui/screens/MapScreen.tsx index 694e9ed..bd1b964 100644 --- a/src/ui/screens/MapScreen.tsx +++ b/src/ui/screens/MapScreen.tsx @@ -53,7 +53,7 @@ export function MapScreen({ right={filter ? `filter: ${filter}` : undefined} /> {loading ? {asciify('Loading country density…')} : null} - + {graph.rows.map((row, index) => ( ))} diff --git a/src/ui/screens/NowPlayingScreen.tsx b/src/ui/screens/NowPlayingScreen.tsx index 9b4610a..822aed0 100644 --- a/src/ui/screens/NowPlayingScreen.tsx +++ b/src/ui/screens/NowPlayingScreen.tsx @@ -53,7 +53,7 @@ export function NowPlayingScreen({ height }: NowPlayingProps): React.ReactElement { const pulse = useReceiverPulse(); - const {panel: panelBackground, ascii} = useDisplay(); + const {panel: panelBackground, ascii, screenReader} = useDisplay(); // In ASCII mode route every rendered string through the glyph mapper so no // braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal. const a = (value: string): string => (ascii ? toAsciiSafe(value) : value); @@ -71,7 +71,7 @@ export function NowPlayingScreen({ panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth ); - const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme); + const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme); const receiverState = playback.state.toUpperCase(); const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2); const stationIdentity = receiverStationIdentity(station, identityWidth); @@ -105,13 +105,13 @@ export function NowPlayingScreen({ height={panelHeight} > - + {a(stationIdentity.name)} {stationIdentity.location ? {a(` · ${stationIdentity.location}`)} : null} - {receiverState} + {receiverState} - + {!screenReader ? {renderRows.map((row, index) => ( {row.segments @@ -119,10 +119,10 @@ export function NowPlayingScreen({ : row.text} ))} - + : null} - {a(metadataLine)} - {a(favoriteText)} + {a(metadataLine)} + {a(favoriteText)} {showDiagnostics ? ( diff --git a/src/ui/screens/SearchScreen.tsx b/src/ui/screens/SearchScreen.tsx index df0e391..0529314 100644 --- a/src/ui/screens/SearchScreen.tsx +++ b/src/ui/screens/SearchScreen.tsx @@ -6,6 +6,7 @@ import {ScreenHeader} from '../components/ScreenHeader.js'; import {panelBorder, textMuted, themeAccent} from '../theme.js'; import {panelBorderStyle, useDisplay} from '../display-context.js'; import {truncate} from '../format.js'; +import {toAsciiSafe} from '../ascii.js'; type SearchScreenProps = { query: string; @@ -35,6 +36,7 @@ export function SearchScreen({ width }: SearchScreenProps): React.ReactElement { const {panel: panelBackground, ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const contentWidth = Math.max(40, width); const inputWidth = Math.max(34, Math.min(contentWidth, 96)); const inputTextWidth = Math.max(8, inputWidth - 8); @@ -59,10 +61,10 @@ export function SearchScreen({ marginBottom={1} flexShrink={0} > - {editing ? '› ' : ' '} - {truncate(query || 'Search stations, genres, languages, places…', inputTextWidth)} + {a(editing ? '› ' : ' ')} + {a(truncate(query || 'Search stations, genres, languages, places…', inputTextWidth))} - {loading ? Searching station directories… : null} + {loading ? {a('Searching station directories…')} : null} {!loading || stations.length > 0 ? ( ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const lineWidth = Math.max(32, width - 4); const group = settingsGroup(page); @@ -89,12 +93,12 @@ export function SettingsScreen({ - {truncate(label, labelWidth)} + {a(truncate(label, labelWidth))} - {truncate(value ?? '', valueWidth)} + {a(truncate(value ?? '', valueWidth))} ); @@ -103,7 +107,7 @@ export function SettingsScreen({ Selected - {truncate(detail, lineWidth)} + {a(truncate(detail, lineWidth))} ); diff --git a/src/ui/screens/StatsScreen.tsx b/src/ui/screens/StatsScreen.tsx index f3413d9..0d29de5 100644 --- a/src/ui/screens/StatsScreen.tsx +++ b/src/ui/screens/StatsScreen.tsx @@ -6,6 +6,7 @@ import {panelBorder, textMuted, themeAccent, themeContributionColors} from '../t import {panelBorderStyle, useDisplay} from '../display-context.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; +import {toAsciiSafe} from '../ascii.js'; type StatsScreenProps = { library: LibraryState; @@ -40,6 +41,7 @@ type ContributionGraph = { export function StatsScreen({library, theme, width, height}: StatsScreenProps): React.ReactElement { const {panel: panelBackground, ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const stats = computeListeningStats(library.activity.sessions); const contentWidth = Math.max(20, width - 4); const graph = buildContributionGraph(stats.days, contentWidth, height); @@ -70,10 +72,10 @@ export function StatsScreen({library, theme, width, height}: StatsScreenProps): > - Activity — {graph.year} + {a(`Activity — ${graph.year}`)} - + {' '.repeat(dayLabelWidth)}{graph.months} {graph.rows.map(row => ( @@ -101,13 +103,13 @@ export function StatsScreen({library, theme, width, height}: StatsScreenProps): Summary - {metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme)} + {metricPair('Favorite station', a(truncate(favorite, favoriteWidth)), 'Total hours listened', formatHours(totalHours), metricWidth, theme)} {metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme)} {metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme)} {metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Stations counted after', '2 min', metricWidth, theme)} - - Less · + + {a('Less · ')} {graphColors.map(color => ( {renderLegendCell(color, graph.cellText, graph.cellGap)} diff --git a/src/ui/terminal-renderer.ts b/src/ui/terminal-renderer.ts new file mode 100644 index 0000000..3a5ac7c --- /dev/null +++ b/src/ui/terminal-renderer.ts @@ -0,0 +1,17 @@ +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {resolveTerminalCapabilities, type TerminalCapabilities, type TerminalEvidence} from '../platform/terminal.js'; + +/** + * Ink uses Chalk's shared instance for both text and panel fills. FORCE_COLOR + * is a minimum in Chalk, so setting it cannot cap a Windows/CI/truecolor host + * at 16 or 256 colors. Set the instance's level before the first Ink render. + * Resolve from Ink itself so nested dependency installations work as well. + */ +export async function configureTerminalRenderer(env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): Promise { + const terminal = resolveTerminalCapabilities(env, evidence); + const inkRequire = createRequire(import.meta.resolve('ink')); + const {default: chalk} = await import(pathToFileURL(inkRequire.resolve('chalk')).href) as {default: {level: number}}; + chalk.level = terminal.colorLevel; + return terminal; +} diff --git a/src/update-check.test.ts b/src/update-check.test.ts index ae66a5e..bee193d 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -13,6 +13,7 @@ import { describe('update checks', () => { afterEach(() => { vi.unstubAllEnvs(); + vi.useRealTimers(); }); it('compares semantic versions', () => { @@ -53,6 +54,33 @@ describe('update checks', () => { expect(updateCheck.error).toContain('503'); }); + it('does not make automatic or explicit registry requests while offline', async () => { + vi.stubEnv('CI', 'false'); + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetchImpl = vi.fn(async () => new Response('{"version":"99.0.0"}')); + expect(shouldCheckForUpdate(undefined)).toBe(false); + expect(automaticUpdateChecksAllowed(true)).toBe(false); + const result = await checkForUpdate({fetchImpl}); + expect(result.updateAvailable).toBe(false); + expect(result.error).toMatch(/offline/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('bounds the registry response body after headers arrive', async () => { + vi.useFakeTimers(); + let body!: ReadableStreamDefaultController; + const fetchImpl = async () => new Response(new ReadableStream({start(controller) {body = controller;}})); + let result: Awaited> | undefined; + const checking = checkForUpdate({fetchImpl, timeoutMs: 100}).then(value => {result = value;}); + try { + await vi.advanceTimersByTimeAsync(101); + expect(result?.error).toMatch(/timed out/i); + } finally { + body.error(new Error('test cleanup')); + await checking; + } + }); + it('uses a 24 hour cache and honors disable flags', () => { vi.stubEnv('CI', 'false'); diff --git a/src/update-check.ts b/src/update-check.ts index a141b64..1bb9a24 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -2,6 +2,7 @@ import {realpathSync} from 'node:fs'; import {spawn} from 'node:child_process'; import {appVersion} from './version.js'; import type {UpdateCheckState} from './types.js'; +import {networkPolicy, withExternalResponse} from './platform/network.js'; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const UPDATE_PACKAGE_NAME = '@ciphore/radiocli'; @@ -42,12 +43,10 @@ export async function checkForUpdate({ timeoutMs = 3000 }: CheckForUpdateOptions = {}): Promise { try { - const response = await fetchWithTimeout(registryLatestUrl(packageName), fetchImpl, timeoutMs); - if (!response.ok) { - throw new Error(`npm registry returned ${response.status}`); - } - - const parsed = await response.json() as {version?: unknown}; + const parsed = await withExternalResponse(registryLatestUrl(packageName), {fetchImpl, timeoutMs}, async response => { + if (!response.ok) throw new Error(`npm registry returned ${response.status}`); + return await response.json() as {version?: unknown}; + }); const latestVersion = typeof parsed.version === 'string' ? parsed.version : undefined; if (!latestVersion) { throw new Error('npm registry response did not include a version'); @@ -70,7 +69,7 @@ export async function checkForUpdate({ } export function shouldCheckForUpdate(updateCheck: UpdateCheckState | undefined, now = Date.now()): boolean { - if (process.env.RADIOCLI_DISABLE_UPDATE_CHECK === '1' || process.env.CI === 'true') { + if (!automaticUpdateChecksAllowed()) { return false; } @@ -83,7 +82,7 @@ export function shouldCheckForUpdate(updateCheck: UpdateCheckState | undefined, } export function automaticUpdateChecksAllowed(enabled = true): boolean { - return enabled && process.env.RADIOCLI_DISABLE_UPDATE_CHECK !== '1' && process.env.CI !== 'true'; + return enabled && !networkPolicy().offline && process.env.RADIOCLI_DISABLE_UPDATE_CHECK !== '1' && process.env.CI !== 'true'; } export function updateAvailableForVersion( @@ -179,16 +178,6 @@ function registryLatestUrl(packageName: string): string { return `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`; } -async function fetchWithTimeout(url: string, fetchImpl: FetchLike, timeoutMs: number): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetchImpl(url, {signal: controller.signal}); - } finally { - clearTimeout(timer); - } -} - function semverParts(version: string): [number, number, number] { const normalized = version.trim().replace(/^v/i, '').split(/[+-]/)[0] ?? ''; const [major = '0', minor = '0', patch = '0'] = normalized.split('.'); From b035d99224a9c755ad1f188885148f73925c4939 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:42:18 -0500 Subject: [PATCH 11/25] fix(playback): isolate background history persistence failures --- src/agent/headless-host.test.ts | 234 ++++++++++++++++++++++++++++++++ src/agent/headless-host.ts | 51 ++++--- src/agent/session.ts | 1 + src/alarms/runner.test.ts | 53 ++++++++ src/alarms/runner.ts | 21 ++- 5 files changed, 338 insertions(+), 22 deletions(-) create mode 100644 src/agent/headless-host.test.ts diff --git a/src/agent/headless-host.test.ts b/src/agent/headless-host.test.ts new file mode 100644 index 0000000..20c1210 --- /dev/null +++ b/src/agent/headless-host.test.ts @@ -0,0 +1,234 @@ +import {chmodSync, existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {PlayerController} from '../player/player-controller.js'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import type {PlaybackState, Station} from '../types.js'; +import {runHeadlessAgentHost} from './headless-host.js'; +import {connectRadioSession, type RadioSessionClient, type RadioSessionCommand} from './session.js'; + +const first: Station = {id: 'first', provider: 'playlist', name: 'First Radio', tags: [], streamUrl: 'https://radio.example/first'}; +const second: Station = {...first, id: 'second', name: 'Second Radio', streamUrl: 'https://radio.example/second'}; +const historyMethods = ['addRecent', 'startListeningSession', 'finishActiveListeningSession', 'checkpointActiveListeningSession'] as const; +const signalNames = ['SIGTERM', 'SIGINT'] as const; +let root: string; +let playback: PlaybackState; +let originalListeners: Map>; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'radiocli headless 音楽-')); + vi.stubEnv('RADIOCLI_HOME', root); + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']}); + originalListeners = new Map(signalNames.map(signal => [signal, new Set(process.listeners(signal))])); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.spyOn(process, 'kill').mockReturnValue(true); + playback = {backend: 'mpv', state: 'idle', volume: 70, muted: false, ready: false}; + vi.spyOn(PlayerController.prototype, 'refreshDetectedBackends').mockReturnValue(['mpv']); + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...playback})); + vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (station, url) => { + playback = {...playback, state: 'playing', stationName: station.name, streamUrl: url, ready: true}; + }); + vi.spyOn(PlayerController.prototype, 'stop').mockImplementation(async () => { + playback = {backend: 'none', state: 'stopped', volume: playback.volume, muted: false, ready: false}; + }); + vi.spyOn(PlayerController.prototype, 'pause').mockImplementation(async () => { + playback = {...playback, state: 'paused'}; + return {ok: true}; + }); + vi.spyOn(PlayerController.prototype, 'resume').mockImplementation(async () => { + playback = {...playback, state: 'playing'}; + return {ok: true}; + }); + vi.spyOn(PlayerController.prototype, 'setVolume').mockImplementation(async volume => { + playback = {...playback, volume}; + return {ok: true}; + }); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async station => ({url: station.streamUrl!, station})); + vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(true); +}); + +afterEach(async () => { + // Restore storage access before cleanup so a failing regression cannot leak a + // real loopback listener. Player and process mocks stay in place until closed. + for (const method of [...historyMethods, 'updateSettings', 'toggleFavorite'] as const) { + if (vi.isMockFunction(JsonLibraryStore.prototype[method])) vi.mocked(JsonLibraryStore.prototype[method]).mockRestore(); + } + const libraryPath = join(root, 'radiocli.json'); + if (existsSync(libraryPath)) chmodSync(libraryPath, 0o600); + const shutdown = hostSignalHandler('SIGTERM'); + if (shutdown) { + shutdown('SIGTERM'); + await vi.waitFor(() => expect(existsSync(join(root, 'runtime', 'agent-session.json'))).toBe(false)); + } + for (const signal of signalNames) { + for (const listener of process.listeners(signal)) { + if (!originalListeners.get(signal)?.has(listener)) process.removeListener(signal, listener); + } + } + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + rmSync(root, {recursive: true, force: true}); +}); + +function hostSignalHandler(signal: NodeJS.Signals): NodeJS.SignalsListener | undefined { + return process.listeners(signal).find(listener => !originalListeners.get(signal)?.has(listener)); +} + +async function startHost(): Promise<{client: RadioSessionClient; libraryPath: string; sessionPath: string}> { + new JsonLibraryStore().updateSettings({shareDirectoryVotes: false}); + let startupError: unknown; + void runHeadlessAgentHost().catch(error => {startupError = error;}); + await vi.waitFor(() => { + if (startupError) throw startupError; + expect(hostSignalHandler('SIGTERM')).toBeDefined(); + }); + const sessionPath = join(root, 'runtime', 'agent-session.json'); + const client = await connectRadioSession(sessionPath); + expect(client).not.toBeNull(); + return {client: client!, libraryPath: join(root, 'radiocli.json'), sessionPath}; +} + +function storageFailure(code = 'ENOSPC'): Error { + return Object.assign(new Error(`${code}: write failed at ${join(root, 'private library.json')}\u001b[31m`), {code}); +} + +function failHistory(method: typeof historyMethods[number], code = 'ENOSPC'): void { + vi.spyOn(JsonLibraryStore.prototype, method).mockImplementation(() => {throw storageFailure(code);}); +} + +describe('headless host optional persistence', () => { + it('stops through the authenticated session after its real library becomes read-only', async context => { + if (process.platform === 'win32') context.skip('POSIX file permissions are not enforced by Windows ACLs.'); + if (process.getuid?.() === 0) context.skip('Root bypasses POSIX file write permissions.'); + const {client, libraryPath, sessionPath} = await startHost(); + await client.call({type: 'play', station: first}); + const saved = readFileSync(libraryPath, 'utf8'); + chmodSync(libraryPath, 0o400); + + const stopped = await client.call({type: 'stop'}); + + expect(stopped).toMatchObject({ok: true, message: 'Stopped RadioCLI.', status: { + station: null, queue: [], playback: {state: 'stopped'}, persistenceWarning: expect.stringContaining('RADIOCLI_HOME') + }}); + expect(stopped.status.persistenceWarning).toContain('EACCES'); + expect(stopped.status.persistenceWarning).not.toContain(root); + expect(readFileSync(libraryPath, 'utf8')).toBe(saved); + expect(await client.status()).toMatchObject({playback: {state: 'stopped'}}); + vi.advanceTimersByTime(50); + expect(process.kill).toHaveBeenCalledWith(process.pid, 'SIGTERM'); + hostSignalHandler('SIGTERM')!('SIGTERM'); + await vi.waitFor(() => expect(existsSync(sessionPath)).toBe(false)); + expect(existsSync(`${sessionPath}.owner`)).toBe(false); + }); + + it.each(['addRecent', 'startListeningSession'] as const)('keeps a successful tune when %s fails', async method => { + const {client} = await startHost(); + failHistory(method); + + const played = await client.call({type: 'play', station: first}); + + expect(played).toMatchObject({ok: true, message: 'Playing First Radio.', status: { + station: first, playback: {state: 'playing', ready: true}, persistenceWarning: expect.stringContaining('ENOSPC') + }}); + expect(played.status.persistenceWarning).toContain('RADIOCLI_HOME'); + expect(played.status.persistenceWarning).not.toContain(root); + expect(played.status.persistenceWarning).not.toContain('\u001b'); + }); + + it('retunes the queue when finishing the previous history entry fails', async () => { + const {client} = await startHost(); + await client.call({type: 'play', station: first, queue: [first, second]}); + failHistory('finishActiveListeningSession'); + + const next = await client.call({type: 'next'}); + + expect(next).toMatchObject({ok: true, status: {station: second, playback: {state: 'playing'}, persistenceWarning: expect.any(String)}}); + }); + + it('yields playback to an alarm when finishing history fails', async () => { + const {client} = await startHost(); + await client.call({type: 'play', station: first}); + failHistory('finishActiveListeningSession'); + + const yielded = await client.call({type: 'alarm-preempt'}); + + expect(yielded).toMatchObject({ok: true, status: {station: null, queue: [], playback: {state: 'stopped'}, persistenceWarning: expect.any(String)}}); + expect(await client.status()).toMatchObject({station: null, playback: {state: 'stopped'}}); + expect(process.kill).not.toHaveBeenCalledWith(process.pid, 'SIGTERM'); + }); + + it.each(['pause', 'resume'] as const)('keeps acknowledged %s controls successful after a history failure', async type => { + const {client} = await startHost(); + await client.call({type: 'play', station: first}); + if (type === 'resume') await client.call({type: 'pause'}); + failHistory(type === 'pause' ? 'finishActiveListeningSession' : 'startListeningSession'); + + const controlled = await client.call({type}); + + expect(controlled).toMatchObject({ok: true, status: { + playback: {state: type === 'pause' ? 'paused' : 'playing'}, persistenceWarning: expect.any(String) + }}); + }); + + it('contains heartbeat write failures and keeps the authenticated owner responsive', async () => { + const {client} = await startHost(); + await client.call({type: 'play', station: first}); + failHistory('checkpointActiveListeningSession'); + + expect(() => vi.advanceTimersByTime(30_000)).not.toThrow(); + + expect(await client.status()).toMatchObject({station: first, playback: {state: 'playing'}, persistenceWarning: expect.stringContaining('ENOSPC')}); + }); + + it.each(signalNames)('stops playback and removes session ownership on %s despite a history failure', async signal => { + const {client, sessionPath} = await startHost(); + await client.call({type: 'play', station: first}); + failHistory('finishActiveListeningSession'); + + hostSignalHandler(signal)!(signal); + + await vi.waitFor(() => expect(existsSync(sessionPath)).toBe(false)); + expect(existsSync(`${sessionPath}.owner`)).toBe(false); + expect(playback.state).toBe('stopped'); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('retains the warning through optional saves and clears it after an explicit save succeeds', async () => { + const {client} = await startHost(); + failHistory('addRecent'); + await client.call({type: 'play', station: first}); + vi.advanceTimersByTime(30_000); + expect((await client.status()).persistenceWarning).toContain('RADIOCLI_HOME'); + + const saved = await client.call({type: 'update-settings', settings: {theme: 'amber'}}); + + expect(saved.ok).toBe(true); + expect(saved.status.persistenceWarning).toBeUndefined(); + expect(new JsonLibraryStore().snapshot().settings.theme).toBe('amber'); + }); + + const explicitCommands: RadioSessionCommand[] = [ + {type: 'update-settings', settings: {theme: 'amber'}}, + {type: 'set-volume', volume: 42}, + {type: 'set-favorite', favorite: true} + ]; + it.each(explicitCommands)('does not claim success for a failed explicit $type save', async command => { + const {client, libraryPath} = await startHost(); + await client.call({type: 'play', station: first}); + const saved = readFileSync(libraryPath, 'utf8'); + const method = command.type === 'set-favorite' ? 'toggleFavorite' : 'updateSettings'; + vi.spyOn(JsonLibraryStore.prototype, method).mockImplementation(() => { + throw new Error('Unable to write the RadioCLI library (EACCES). Set RADIOCLI_HOME to a private writable directory.'); + }); + + await expect(client.call(command)).rejects.toThrow('RADIOCLI_HOME'); + + expect(readFileSync(libraryPath, 'utf8')).toBe(saved); + expect(new JsonLibraryStore().snapshot()).toMatchObject({favorites: [], settings: {volume: 70, theme: 'green'}}); + expect(ProviderManager.prototype.vote).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agent/headless-host.ts b/src/agent/headless-host.ts index bfcdb9e..70edb53 100644 --- a/src/agent/headless-host.ts +++ b/src/agent/headless-host.ts @@ -12,21 +12,38 @@ export async function runHeadlessAgentHost(): Promise { player.refreshDetectedBackends(); let station: Station | null = null; let queue: Station[] = []; + let persistenceWarning: string | undefined; - const status = (): RadioSessionStatus => ({owner: 'headless', playback: player.getState(), station, queue, output: { + const persistHistory = (write: () => unknown): void => { + try { + write(); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : ''; + persistenceWarning = `Listening history was not saved${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`; + } + }; + const saveLibrary = (write: () => unknown): void => { + write(); + // Optional writes can be no-ops, so only an explicit successful save clears + // a prior warning. Explicit failures still reject the control request. + persistenceWarning = undefined; + }; + + const status = (): RadioSessionStatus => ({owner: 'headless', playback: player.getState(), station, queue, persistenceWarning, output: { preferredBackend: store.snapshot().settings.preferredBackend, preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice }}); const result = (message: string, ok = true, data?: RadioSessionResult['data']): RadioSessionResult => ({ok, message, status: status(), ...(data ? {data} : {})}); const play = async (next: Station, nextQueue: Station[] = [next]): Promise => { - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); const resolved = await providers.resolve(next); await player.play(next, resolved.url); station = next; queue = nextQueue.length ? nextQueue : [next]; - store.addRecent(next); - store.startListeningSession(next); + persistHistory(() => store.addRecent(next)); + persistHistory(() => store.startListeningSession(next)); return result(`Playing ${next.name}.`); }; @@ -38,16 +55,16 @@ export async function runHeadlessAgentHost(): Promise { } if (command.type === 'pause') { const control = await player.pause(); - if (control.ok) store.finishActiveListeningSession(); + if (control.ok) persistHistory(() => store.finishActiveListeningSession()); return result(control.message ?? 'Paused.', control.ok); } if (command.type === 'resume') { const control = await player.resume(); - if (control.ok && station) store.startListeningSession(station); + if (control.ok && station) persistHistory(() => store.startListeningSession(station!)); return result(control.message ?? 'Resumed.', control.ok); } if (command.type === 'stop') { - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); station = null; queue = []; @@ -55,7 +72,7 @@ export async function runHeadlessAgentHost(): Promise { return result('Stopped RadioCLI.'); } if (command.type === 'alarm-preempt') { - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); station = null; queue = []; @@ -63,7 +80,7 @@ export async function runHeadlessAgentHost(): Promise { } if (command.type === 'set-volume') { const control = await player.setVolume(command.volume); - if (control.ok) store.updateSettings({volume: player.getState().volume}); + if (control.ok) saveLibrary(() => store.updateSettings({volume: player.getState().volume})); return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok); } if (command.type === 'set-muted') { const control = await player.setMuted(command.muted); return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok); } @@ -71,7 +88,7 @@ export async function runHeadlessAgentHost(): Promise { const target = command.station ?? station; if (!target) return result('No active station to favorite.', false); const current = store.isFavorite(target); - if (current !== command.favorite) store.toggleFavorite(target); + if (current !== command.favorite) saveLibrary(() => store.toggleFavorite(target)); if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes) void providers.vote(target); return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`); } @@ -85,8 +102,10 @@ export async function runHeadlessAgentHost(): Promise { if (!device) return result('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices); if (device.local) return switchToLocal(); if (!detectPlaybackBackends().includes('airplay')) return result('AirPlay playback is unavailable; run radiocli doctor.', false); - store.updateSettings({preferredAirPlayDevice: device.id}); - store.updateSettings({preferredBackend: 'airplay'}); + saveLibrary(() => { + store.updateSettings({preferredAirPlayDevice: device.id}); + store.updateSettings({preferredBackend: 'airplay'}); + }); if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue); return result(`Audio output set to AirPlay receiver ${device.name}.`); } @@ -96,7 +115,7 @@ export async function runHeadlessAgentHost(): Promise { return result(control.message ?? 'AirPlay code sent.', control.ok); } if (command.type === 'update-settings') { - store.updateSettings(command.settings); + saveLibrary(() => store.updateSettings(command.settings)); return result('RadioCLI settings updated.'); } const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station!)) : -1; @@ -109,18 +128,18 @@ export async function runHeadlessAgentHost(): Promise { const backends = detectPlaybackBackends(); const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null; if (!backend) return result('No local playback backend is available. Run radiocli setup to install mpv.', false); - store.updateSettings({preferredBackend: backend}); + saveLibrary(() => store.updateSettings({preferredBackend: backend})); if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue); return result(`Audio output set to this device (${backend}).`); }; const session = await startRadioSession(handle); const checkpoint = setInterval(() => { - if (player.getState().state === 'playing') store.checkpointActiveListeningSession(); + if (player.getState().state === 'playing') persistHistory(() => store.checkpointActiveListeningSession()); }, 30_000); const shutdown = async () => { clearInterval(checkpoint); - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); await session.close(); }; diff --git a/src/agent/session.ts b/src/agent/session.ts index d4d0803..ba12cfa 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -7,6 +7,7 @@ import type {AirPlayDevice, AppSettings, PlaybackState, Station} from '../types. import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; export type RadioSessionStatus = { + persistenceWarning?: string; owner: 'tui' | 'headless'; playback: PlaybackState; station: Station | null; diff --git a/src/alarms/runner.test.ts b/src/alarms/runner.test.ts index 4ad6822..429837a 100644 --- a/src/alarms/runner.test.ts +++ b/src/alarms/runner.test.ts @@ -37,6 +37,59 @@ describe('alarm runner',()=>{ it('keeps playing when automatic terminal controls cannot open',async()=>{const record=vi.fn();const d=deps({openControls:vi.fn(async()=>{throw new Error('no terminal');}),health:{record} as never});expect((await runAlarm('a',scheduledAt,d)).status).toBe('dismissed');await Promise.resolve();expect(record).toHaveBeenCalledWith(expect.objectContaining({component:'runner',healthy:false,message:expect.stringMatching(/controls could not open.*no terminal/i)}));}); + it.each([ + 'Requested RadioCLI alarm controls; TUI startup is unverified.', + 'Requested RadioCLI alarm controls, but the TUI did not become ready before verification timed out.' + ])('preserves an unsuccessful controls result while playback continues: %s',async message=>{ + const record=vi.fn();const update=vi.fn(); + const d=deps({createSession:vi.fn(async()=>({update,close:vi.fn(async()=>{})})),openControls:vi.fn(async()=>({opened:false,requested:true,terminal:'darwin:apple-terminal',message})),health:{record} as never}); + expect((await runAlarm('a',scheduledAt,d)).status).toBe('played'); + expect(update).toHaveBeenCalledWith(expect.objectContaining({state:'playing'}));expect(d.wait).toHaveBeenCalledWith(60_000); + expect(record).toHaveBeenCalledWith(expect.objectContaining({alarmId:'a',occurrenceAt:'2030-01-01T08:00:00.000Z',component:'runner',healthy:false,message:expect.stringContaining(message)})); + }); + + it.each([ + {opened:false,terminal:'existing-tui',message:'An existing RadioCLI TUI will show the ringing controls.'}, + {opened:true,requested:true,terminal:'darwin:apple-terminal',message:'RadioCLI alarm controls are ready in the saved terminal.'} + ])('keeps confirmed or existing controls healthy: $terminal',async result=>{ + const record=vi.fn();const d=deps({openControls:vi.fn(async()=>result),health:{record} as never}); + expect((await runAlarm('a',scheduledAt,d)).status).toBe('dismissed'); + expect(record).not.toHaveBeenCalledWith(expect.objectContaining({component:'runner',healthy:false})); + }); + + it.each(['addRecent','startListeningSession','checkpointActiveListeningSession','finishActiveListeningSession'] as const)('continues published playback after optional %s history writes fail',async operation=>{ + const base=deps();const events:string[]=[];const record=vi.fn(); + const d=deps({ + store:{...base.store,[operation]:vi.fn(()=>{throw new Error(`${operation}: EACCES`);})}, + player:{...base.player,play:vi.fn(async()=>{events.push('play');}),stop:vi.fn(async()=>{events.push('stop');})}, + createSession:vi.fn(async()=>({update:vi.fn(status=>{if(status.state==='playing')events.push('published');}),close:vi.fn(async()=>{})})), + wait:vi.fn(async()=>{events.push('wait');}),health:{record} as never + }); + expect((await runAlarm('a',scheduledAt,d)).status).toBe('played'); + expect(events).toEqual(['play','published','wait','stop']); + expect(record).toHaveBeenCalledWith(expect.objectContaining({alarmId:'a',occurrenceAt:'2030-01-01T08:00:00.000Z',component:'runner',healthy:false,message:expect.stringContaining(`${operation}: EACCES`)})); + expect(d.store.recordAlarmOutcome).toHaveBeenCalledWith('a',expect.objectContaining({status:'played'}),{clearNextOverride:true}); + if(operation==='addRecent')expect(d.store.startListeningSession).toHaveBeenCalled(); + if(operation==='startListeningSession'){expect(d.store.checkpointActiveListeningSession).not.toHaveBeenCalled();expect(d.store.finishActiveListeningSession).not.toHaveBeenCalled();} + if(operation==='checkpointActiveListeningSession')expect(d.store.finishActiveListeningSession).toHaveBeenCalled(); + }); + + it.each(['recordAlarmOutcome','toggleAlarm'] as const)('reports required %s persistence failures separately from optional history',async operation=>{ + const base=deps();const record=vi.fn();const d=deps({store:{...base.store,[operation]:vi.fn(()=>{throw new Error(`${operation}: EACCES`);})},health:{record} as never}); + await runAlarm('a',scheduledAt,d); + expect(record).toHaveBeenCalledWith(expect.objectContaining({component:'runner',healthy:false,active:false,message:expect.stringMatching(new RegExp(`alarm.*sav.*${operation}: EACCES`,'i'))})); + if(operation==='recordAlarmOutcome')expect(d.store.toggleAlarm).not.toHaveBeenCalled(); + }); + + it('retains an unverified controls warning when optional history finalization also fails',async()=>{ + const base=deps();const record=vi.fn();const message='Requested controls; TUI startup is unverified.'; + const d=deps({store:{...base.store,finishActiveListeningSession:vi.fn(()=>{throw new Error('history EACCES');})},openControls:vi.fn(async()=>({opened:false,requested:true,terminal:'darwin:apple-terminal',message})),health:{record} as never}); + await runAlarm('a',scheduledAt,d); + const lastRunnerEntry=record.mock.calls.map(([entry])=>entry).filter(entry=>entry.component==='runner').at(-1); + expect(lastRunnerEntry).toMatchObject({healthy:false,message:expect.stringContaining(message)}); + expect(lastRunnerEntry.message).toContain('history EACCES'); + }); + it('retries primary once then uses only the explicit fallback',async()=>{ const play=vi.fn().mockRejectedValueOnce(new Error('bad')).mockRejectedValueOnce(new Error('bad')).mockResolvedValue(undefined); const d=deps({player:{play,stop:vi.fn(async()=>{}),setVolume:vi.fn(async()=>({ok:true})),getState:vi.fn(()=>({backend:'mpv'}))}}); diff --git a/src/alarms/runner.ts b/src/alarms/runner.ts index dd0a13d..9fd615b 100644 --- a/src/alarms/runner.ts +++ b/src/alarms/runner.ts @@ -9,6 +9,7 @@ import {assessScheduledOccurrence,NATIVE_DISPATCH_TOLERANCE_MS,nextOccurrenceFor import {startActiveAlarmSession,type ActiveAlarmHandlers,type ActiveAlarmServer,type ActiveAlarmStatus} from './active-session.js'; import type {AlarmRuntimeHealthStore} from './runtime-health.js'; import type {SystemVolumeController,SystemVolumeLease} from './system-volume.js'; +import type {AlarmTerminalLaunchResult} from './terminal-launcher.js'; type RunnerStore={ getAlarm(id:string):Alarm|undefined; @@ -27,7 +28,7 @@ export type AlarmRunnerDeps={ now():Date; store:RunnerStore; providers:RunnerProvider; player:RunnerPlayer; scheduler:RunnerScheduler; inhibitor:PowerInhibitor; acquireLock(alarmId:string,scheduledAt:string):((completed?:boolean)=>void)|null; createSession(status:ActiveAlarmStatus,handlers:ActiveAlarmHandlers):Promise; - openControls?(status:ActiveAlarmStatus):Promise; + openControls?(status:ActiveAlarmStatus):Promise; preemptInteractivePlayback?():Promise; wait(milliseconds:number):Promise; subscribeSignals?(handler:()=>void):()=>void; @@ -40,7 +41,9 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(!/(?:Z|[+-]\d{2}:\d{2})$/.test(scheduledAtText))throw new Error('Scheduled alarm occurrence must be an absolute date.'); const scheduledAt=new Date(scheduledAtText);if(!Number.isFinite(scheduledAt.getTime()))throw new Error('Scheduled alarm occurrence must be an absolute date.'); const releaseLock=deps.acquireLock(alarmId,scheduledAt.toISOString());if(!releaseLock){const current=deps.store.getAlarm(alarmId);if(current)try{await deps.scheduler.sync(current);}catch{}return{duplicate:true,message:'This alarm occurrence is already running or completed.'};} - let alarm=deps.store.getAlarm(alarmId);let outcome:AlarmRunRecord|undefined;let session:ActiveAlarmServer|undefined;let lease:Awaited>|undefined;let systemVolumeLease:SystemVolumeLease|undefined;let listening=false;let firedAt:Date|undefined; + let alarm=deps.store.getAlarm(alarmId);let outcome:AlarmRunRecord|undefined;let session:ActiveAlarmServer|undefined;let lease:Awaited>|undefined;let systemVolumeLease:SystemVolumeLease|undefined;let listening=false;let historyStarted=false;let firedAt:Date|undefined; + const runnerWarnings=new Set(); + const recordRunnerWarning=(message:string)=>{runnerWarnings.add(message);try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:listening,message:[...runnerWarnings].join(' ')});}catch{}}; let preserveNextOverride=false; let preserveSystemVolume=false; let validTerminalOccurrence=false; @@ -74,10 +77,12 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(lastError)throw lastError; firedAt=deps.now(); try{const acquiring=deps.inhibitor.acquire('RadioCLI alarm playback');void acquiring.then(acquired=>{if(signalReceived)void acquired.release().catch(()=>undefined);}).catch(()=>{});const acquired=await Promise.race([acquiring.then(value=>({value})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in acquired){outcome=finish('dismissed','Alarm interrupted while sleep protection was starting.');return{status:'dismissed',message:outcome.message};}lease=acquired.value;deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:true,active:true,message:'Sleep inhibition active while playing.'});void lease.unexpectedExit?.then(error=>{try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:false,message:`Playback continues after sleep protection exited: ${error.message}`});}catch{}}).catch(()=>{});}catch(error){deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:false,message:`Playback continues without sleep protection: ${errorMessage(error)}`});} - const startedAt=deps.now();deps.store.addRecent(resolvedStation);deps.store.startListeningSession(resolvedStation,startedAt);listening=true; + const startedAt=deps.now();listening=true; + try{deps.store.addRecent(resolvedStation);}catch(error){recordRunnerWarning(`Recent listening history could not be saved: ${errorMessage(error)}`);} + try{deps.store.startListeningSession(resolvedStation,startedAt);historyStarted=true;}catch(error){recordRunnerWarning(`Listening history could not be started: ${errorMessage(error)}`);} const activeStatus:ActiveAlarmStatus={alarmId,scheduledAt:scheduledAt.toISOString(),stationName:resolvedStation.name,station:resolvedStation,startedAt:startedAt.toISOString(),state:'playing'}; session.update(activeStatus); - void deps.openControls?.(activeStatus).catch(error=>{try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:true,message:`Alarm is playing, but RadioCLI controls could not open automatically: ${errorMessage(error)}`});}catch{}}); + void deps.openControls?.(activeStatus).then(result=>{if(result&&!result.opened&&result.terminal!=='existing-tui')recordRunnerWarning(`RadioCLI controls are unavailable or unverified: ${result.message}`);}).catch(error=>{recordRunnerWarning(`RadioCLI controls could not open automatically: ${errorMessage(error)}`);}); onPlaybackSignal=()=>{action='signal';resolveAction('signal');};if(signalReceived)onPlaybackSignal(); { const supportsFade=deps.player.getState().backend==='mpv'; @@ -89,13 +94,17 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR return{status:outcome.status,message:outcome.message}; }catch(error){outcome=finish('failed',errorMessage(error));return{status:'failed',message:outcome.message};} finally{ + listening=false; unsubscribeSignals?.(); try{await deps.player.stop();}catch{} if(!preserveSystemVolume)try{await systemVolumeLease?.release();}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Previous system output volume could not be restored: ${errorMessage(error)}`});}catch{}} - if(listening){try{deps.store.checkpointActiveListeningSession(deps.now());deps.store.finishActiveListeningSession(deps.now());}catch{}} + if(historyStarted){ + try{deps.store.checkpointActiveListeningSession(deps.now());}catch(error){recordRunnerWarning(`Listening history could not be checkpointed: ${errorMessage(error)}`);} + try{deps.store.finishActiveListeningSession(deps.now());}catch(error){recordRunnerWarning(`Listening history could not be finished: ${errorMessage(error)}`);} + } try{await lease?.release();if(lease)deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:true,active:false,message:'Playback sleep inhibition released.'});}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:true,message:`Unable to verify inhibitor release: ${errorMessage(error)}`});}catch{}} try{await session?.close();}catch{} - if(alarm&&outcome){try{deps.store.recordAlarmOutcome(alarmId,outcome,{clearNextOverride:validTerminalOccurrence&&!preserveNextOverride});const latest=deps.store.getAlarm(alarmId);if(alarm.schedule.type==='once'&&latest?.schedule.type==='once'&&latest.schedule.at===alarm.schedule.at&&validTerminalOccurrence&&!preserveNextOverride)deps.store.toggleAlarm(alarmId,false);}catch{}} + if(alarm&&outcome){try{deps.store.recordAlarmOutcome(alarmId,outcome,{clearNextOverride:validTerminalOccurrence&&!preserveNextOverride});const latest=deps.store.getAlarm(alarmId);if(alarm.schedule.type==='once'&&latest?.schedule.type==='once'&&latest.schedule.at===alarm.schedule.at&&validTerminalOccurrence&&!preserveNextOverride)deps.store.toggleAlarm(alarmId,false);}catch(error){recordRunnerWarning(`Alarm outcome or completion state could not be saved: ${errorMessage(error)}`);}} alarm=deps.store.getAlarm(alarmId);if(alarm)try{if(deps.scheduler.syncClaimed)await deps.scheduler.syncClaimed(alarm,scheduledAt);else await deps.scheduler.sync(alarm);}catch{} try{releaseLock(completeLock);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Occurrence lock cleanup failed: ${errorMessage(error)}`});}catch{}} if(completeLock)try{await deps.scheduler.completeOccurrence?.(alarmId,scheduledAt);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'scheduler',healthy:false,message:`Completed launch job cleanup failed: ${errorMessage(error)}`});}catch{}} From 94f4ad1a1e9a7807d4076860e5b30ef28c24d775 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:44:03 -0500 Subject: [PATCH 12/25] feat(platform): add bounded Termux and Unix installation paths --- src/cli.tsx | 7 +- src/platform/desktop.test.ts | 18 +++++ src/platform/desktop.ts | 9 ++- src/platform/executables.test.ts | 31 +++++++- src/platform/executables.ts | 12 +++- src/platform/packages.test.ts | 64 ++++++++++++++++- src/platform/packages.ts | 83 +++++++++++++++++----- src/setup.test.ts | 118 +++++++++++++++++++++++++++++++ src/setup.ts | 43 ++++++++--- 9 files changed, 347 insertions(+), 38 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index f794fa9..b0a524f 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -288,7 +288,9 @@ Usage: radiocli setup --mcp --headless-agent Opt out of external terminal windows radiocli setup --no-mcp Disable and remove agent MCP entries radiocli setup --package-manager Use brew, winget, scoop, choco, - apt, dnf, pacman, apk, or zypper + apt, dnf, pacman, apk, zypper, + pkg (FreeBSD), pkg_add, pkgin, + termux-pkg (Termux), or pkgman (Haiku) `); } @@ -315,7 +317,8 @@ async function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic const integrationCommands = [ 'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit', 'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer', - 'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel' + 'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel', + 'termux-open-url', 'termux-clipboard-set' ].filter(command => resolveCommand(command) !== null); const capabilities = platformCapabilities(host, { backends, diff --git a/src/platform/desktop.test.ts b/src/platform/desktop.test.ts index 3d0490b..5046942 100644 --- a/src/platform/desktop.test.ts +++ b/src/platform/desktop.test.ts @@ -14,5 +14,23 @@ describe('portable desktop command plans', () => { const host = identifyPlatform({platform: 'futureos', env: {}}); expect(browserCommands(host)).toEqual([]); expect(clipboardCandidates(host)).toEqual([]); + expect(hasGraphicalSession(host, {DISPLAY: ':0'})).toBe(false); + }); + + it.each(['android', 'linux'])('plans native Termux browser and stdin clipboard helpers on %s', platform => { + const env = {TERMUX_VERSION: '0.118.3'}; + const host = identifyPlatform({platform, env}); + expect(browserCommands(host)).toEqual([{command: 'termux-open-url', args: []}]); + expect(clipboardCandidates(host)).toEqual([{command: 'termux-clipboard-set', args: []}]); + expect(hasGraphicalSession(host, env)).toBe(true); + expect(hasGraphicalSession(host, {...env, SSH_CONNECTION: 'remote'})).toBe(false); + expect(hasGraphicalSession(host, {...env, SSH_TTY: '/dev/pts/1', DISPLAY: ':0'})).toBe(false); + }); + + it.each(['android', 'haiku', 'sunos', 'aix'])('does not infer desktop integration for %s from a DISPLAY value', platform => { + const host = identifyPlatform({platform, env: {}}); + expect(browserCommands(host)).toEqual([]); + expect(clipboardCandidates(host)).toEqual([]); + expect(hasGraphicalSession(host, {DISPLAY: ':0'})).toBe(false); }); }); diff --git a/src/platform/desktop.ts b/src/platform/desktop.ts index 5da7419..284d69a 100644 --- a/src/platform/desktop.ts +++ b/src/platform/desktop.ts @@ -4,6 +4,8 @@ export type SystemCommand = {command: string; args: string[]}; /** Command plans only. Callers must check the session and observe execution. */ export function browserCommands(host: PlatformProfile = identifyPlatform()): SystemCommand[] { + // termux-tools opens one URL through Android's VIEW intent. + if (host.id === 'termux') return [{command: 'termux-open-url', args: []}]; if (host.id === 'darwin') return [{command: 'open', args: []}]; if (host.id === 'win32') return [{command: 'explorer', args: []}]; if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id)) return [{command: 'xdg-open', args: []}]; @@ -11,6 +13,9 @@ export function browserCommands(host: PlatformProfile = identifyPlatform()): Sys } export function clipboardCandidates(host: PlatformProfile = identifyPlatform()): SystemCommand[] { + // No arguments makes termux-clipboard-set read stdin. Its matching Android + // API app and permissions are still required; the caller checks execution. + if (host.id === 'termux') return [{command: 'termux-clipboard-set', args: []}]; if (host.id === 'darwin') return [{command: 'pbcopy', args: []}]; if (host.id === 'win32') return [{command: 'clip', args: []}]; if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id)) return [ @@ -22,6 +27,6 @@ export function clipboardCandidates(host: PlatformProfile = identifyPlatform()): } export function hasGraphicalSession(host: PlatformProfile, env: NodeJS.ProcessEnv = process.env): boolean { - if (host.id === 'darwin' || host.id === 'win32') return !env.SSH_CONNECTION && !env.SSH_TTY; - return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); + if (host.id === 'darwin' || host.id === 'win32' || host.id === 'termux') return !env.SSH_CONNECTION && !env.SSH_TTY; + return ['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id) && Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); } diff --git a/src/platform/executables.test.ts b/src/platform/executables.test.ts index d74823d..8849880 100644 --- a/src/platform/executables.test.ts +++ b/src/platform/executables.test.ts @@ -13,9 +13,36 @@ afterEach(() => { describe('safe executable configuration and permissions', () => { it.each([ - ['freebsd', '/usr/local/bin/mpv'], ['openbsd', '/usr/local/bin/mpv'], ['netbsd', '/usr/pkg/bin/mpv'] + ['freebsd', '/usr/local/bin/mpv'], ['openbsd', '/usr/local/bin/mpv'], ['netbsd', '/usr/pkg/bin/mpv'], + ['sunos', '/opt/local/bin/mpv'], ['sunos', '/usr/pkg/bin/mpv'], ['haiku', '/boot/system/bin/mpv'], + ['haiku', '/boot/home/config/bin/mpv'], ['haiku', '/boot/home/config/non-packaged/bin/mpv'] ] as const)('finds the %s package prefix when an inherited PATH is incomplete', (platform, path) => { - expect(resolveCommandDetails('mpv', {platform, env: {PATH: '/bin'}, isRunnable: candidate => candidate === path})).toMatchObject({path}); + expect(resolveCommandDetails('mpv', {platform, env: {PATH: '/bin'}, home: '/boot/home', isRunnable: candidate => candidate === path})).toMatchObject({path, discovery: 'package-manager-shim'}); + }); + + it.each(['android', 'linux'] as const)('finds native Termux commands using PREFIX on %s', platform => { + const prefix = '/data/user/10/com.termux/files/usr'; + const path = `${prefix}/bin/mpv`; + expect(resolveCommandDetails('mpv', {platform, env: {PATH: '/system/bin', PREFIX: prefix}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'package-manager-shim'}); + }); + + it('prefers the inherited PATH to the Termux prefix', () => { + const path = '/private/player/bin/mpv'; + expect(resolveCommandDetails('mpv', {platform: 'android', env: {PATH: '/private/player/bin', TERMUX_VERSION: '0.118.3', PREFIX: '/data/data/com.termux/files/usr'}, + isRunnable: candidate => [path, '/data/data/com.termux/files/usr/bin/mpv'].includes(candidate)})).toEqual({path, discovery: 'path'}); + }); + + it.each([undefined, 'relative/usr', '/data/data/com.termux/files/usr\n'])('does not guess a Termux prefix from %s or fall back to glibc paths', prefix => { + expect(resolveCommandDetails('mpv', {platform: 'android', env: {TERMUX_VERSION: '0.118.3', PREFIX: prefix}, isRunnable: () => true})).toEqual({path: null, discovery: 'missing'}); + }); + + it('ignores an unrelated PREFIX on ordinary Linux', () => { + expect(resolveCommandDetails('mpv', {platform: 'linux', env: {PREFIX: '/opt/custom'}, isRunnable: candidate => candidate === '/opt/custom/bin/mpv'})).toEqual({path: null, discovery: 'missing'}); + }); + + it('finds an installed AIX Toolbox executable without inferring playback package availability', () => { + const path = '/opt/freeware/bin/python3'; + expect(resolveCommandDetails('python3', {platform: 'aix', env: {PATH: '/usr/bin'}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'package-manager-shim'}); }); it('treats a configured path with spaces and Unicode as a single executable', () => { const path = '/opt/Radio 日本/player mpv'; diff --git a/src/platform/executables.ts b/src/platform/executables.ts index aefa94d..622f7ab 100644 --- a/src/platform/executables.ts +++ b/src/platform/executables.ts @@ -2,6 +2,7 @@ import {spawnSync} from 'node:child_process'; import {accessSync, constants, statSync} from 'node:fs'; import {homedir} from 'node:os'; import {posix, win32} from 'node:path'; +import {identifyPlatform} from './runtime.js'; export type CommandDiscovery = 'path' | 'configured-path' | 'package-manager-shim' | 'application-directory' | 'windows-registry' | 'missing'; @@ -35,7 +36,7 @@ export function resolveCommandDetails( overrides?: Partial ): CommandResolution { const variable = overrideVariable(command); - const cacheKey = [command, process.env.PATH, process.env.PATHEXT, variable ? process.env[variable] : undefined].join('\0'); + const cacheKey = [command, process.env.PATH, process.env.PATHEXT, process.env.PREFIX, process.env.TERMUX_VERSION, variable ? process.env[variable] : undefined].join('\0'); if (!overrides) { const cached = commandCache.get(cacheKey); if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs) return cached.resolution; @@ -142,12 +143,21 @@ function isRunnable(path: string, platform: NodeJS.Platform): boolean { } function knownBinaryDirs({platform, env, home}: CommandResolutionOptions): string[] { + if (identifyPlatform({platform, env}).id === 'termux') { + // Use the running app's prefix, including alternate Android user installs. + // Generic /usr paths can refer to a different libc inside a proot environment. + const prefix = env.PREFIX; + return prefix && posix.isAbsolute(prefix) && !/[\0\r\n]/.test(prefix) ? [posix.join(prefix, 'bin')] : []; + } if (platform === 'darwin') { return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', posix.join(home, '.local', 'bin')]; } if (platform === 'freebsd' || platform === 'openbsd') return ['/usr/local/bin', '/usr/bin']; if (platform === 'netbsd') return ['/usr/pkg/bin', '/usr/local/bin', '/usr/bin']; + if (platform === 'sunos') return ['/opt/local/bin', '/usr/pkg/bin', '/usr/local/bin', '/usr/bin']; + if (platform === 'haiku') return [posix.join(home, 'config', 'non-packaged', 'bin'), posix.join(home, 'config', 'bin'), '/boot/system/non-packaged/bin', '/boot/system/bin']; + if (platform === 'aix') return ['/opt/freeware/bin', '/usr/local/bin', '/usr/bin']; if (platform === 'win32') { const dirs: string[] = []; diff --git a/src/platform/packages.test.ts b/src/platform/packages.test.ts index aba3577..f3f7847 100644 --- a/src/platform/packages.test.ts +++ b/src/platform/packages.test.ts @@ -1,9 +1,10 @@ import {describe, expect, it} from 'vitest'; -import {detectPackageManager, packageCommandInvocation, packageInstallCommand} from './packages.js'; +import {detectPackageManager, ffplayInstallCommand, mpvInstallCommand, packageCommandInvocation, packageInstallCommand, packageManagerNeedsRoot, packageManagerNotes, platformLabel} from './packages.js'; +import {identifyPlatform} from './runtime.js'; describe('native package plans', () => { it.each([ - ['freebsd', 'pkg'], ['openbsd', 'pkg_add'], ['netbsd', 'pkgin'] + ['freebsd', 'pkg'], ['openbsd', 'pkg_add'], ['netbsd', 'pkgin'], ['sunos', 'pkgin'], ['haiku', 'pkgman'] ] as const)('selects %s package tools without confusing other Unix commands', (platform, expected) => { expect(detectPackageManager(platform, '', () => true)).toBe(expected); expect(detectPackageManager(platform, '', () => false)).toBeNull(); @@ -13,6 +14,53 @@ describe('native package plans', () => { expect(detectPackageManager('sunos', '', command => command === 'pkg')).toBeNull(); }); + it.each(['android', 'linux'] as const)('keeps Termux pkg distinct on %s and never falls back to a Linux installer', platform => { + const env = {TERMUX_VERSION: '0.118.3', PREFIX: '/data/data/com.termux/files/usr'}; + expect(detectPackageManager(platform, 'ID=debian', () => true, env)).toBe('termux-pkg'); + expect(detectPackageManager(platform, 'ID=debian', command => command === 'apt', env)).toBeNull(); + expect(platformLabel(platform, '', env)).toBe('Android / Termux'); + }); + + it('recognizes the shared Termux prefix without accepting pkg on ordinary Linux or Android', () => { + expect(detectPackageManager('linux', '', command => command === 'pkg', {PREFIX: '/data/user/10/com.termux/files/usr'})).toBe('termux-pkg'); + expect(detectPackageManager('linux', '', command => command === 'pkg', {})).toBeNull(); + expect(detectPackageManager('android', '', () => true, {})).toBeNull(); + expect(detectPackageManager('freebsd', '', () => true, {TERMUX_VERSION: '0.118.3'})).toBe('pkg'); + }); + + it('requires a manual AIX playback installation even when DNF is installed', () => { + expect(detectPackageManager('aix', '', () => true, {})).toBeNull(); + expect(mpvInstallCommand('aix', '', {})).toContain('no verified AIX'); + expect(ffplayInstallCommand('aix', '', {})).toContain('no verified AIX'); + expect(packageManagerNotes(null, identifyPlatform({platform: 'aix', env: {}})).join(' ')).toContain('RADIOCLI_MPV_PATH'); + }); + + it.each(['sudo', 'doas', null] as const)('does not elevate Termux or Haiku package commands with %s', elevation => { + expect(packageInstallCommand('termux-pkg', 'mpv', {elevation})).toMatchObject({program: 'pkg', args: ['install', '-y', 'mpv']}); + expect(packageInstallCommand('pkgman', 'mpv', {elevation})).toMatchObject({program: 'pkgman', args: ['install', '-y', 'mpv']}); + expect(packageManagerNeedsRoot('termux-pkg')).toBe(false); + expect(packageManagerNeedsRoot('pkgman')).toBe(false); + }); + + it('plans only verified Termux and Haiku components', () => { + expect(packageInstallCommand('termux-pkg', 'ffmpeg')).toBeNull(); + expect(packageInstallCommand('termux-pkg', 'vlc')).toBeNull(); + expect(packageInstallCommand('pkgman', 'ffmpeg')).toMatchObject({program: 'pkgman', args: ['install', '-y', 'ffmpeg8_tools']}); + expect(packageInstallCommand('pkgman', 'vlc')).toBeNull(); + expect(packageManagerNotes('termux-pkg').join(' ')).toContain('x11-repo'); + expect(packageManagerNotes('pkgman').join(' ')).toMatch(/Node 20.*Node 22/); + }); + + it('keeps the printed installation hints consistent with each verified route', () => { + const termux = {TERMUX_VERSION: '0.118.3'}; + expect(mpvInstallCommand('android', '', termux)).toBe('pkg install -y mpv'); + expect(ffplayInstallCommand('linux', '', termux)).toContain('x11-repo'); + expect(mpvInstallCommand('haiku', '', {})).toBe('pkgman install -y mpv'); + expect(ffplayInstallCommand('haiku', '', {})).toBe('pkgman install -y ffmpeg8_tools'); + expect(mpvInstallCommand('sunos', '', {})).toContain('pkgin -y install mpv'); + expect(ffplayInstallCommand('sunos', '', {})).toContain('matching pkgsrc'); + }); + it('uses verified noninteractive BSD syntax', () => { expect(packageInstallCommand('pkg', 'mpv')).toMatchObject({program: 'sudo', args: ['pkg', 'install', '-y', 'mpv']}); expect(packageInstallCommand('pkg_add', 'mpv', {elevation: 'doas'})).toMatchObject({program: 'doas', args: ['pkg_add', '-I', 'mpv']}); @@ -25,6 +73,18 @@ describe('native package plans', () => { expect(packageInstallCommand('apt', 'mpv', {elevation: null})).toMatchObject({program: 'apt-get', args: ['install', '-y', 'mpv']}); }); + it('executes a discovered package manager path through the selected privilege helper', () => { + const plan = packageInstallCommand('pkgin', 'mpv', {elevation: 'doas'})!; + const paths: Record = {pkgin: '/opt/local/bin/pkgin', doas: '/usr/local/bin/doas'}; + expect(packageCommandInvocation(plan, 'sunos', command => paths[command] ?? null)).toEqual({ + program: '/usr/local/bin/doas', args: ['/opt/local/bin/pkgin', '-y', 'install', 'mpv'] + }); + expect(plan.display).toBe('doas pkgin -y install mpv'); + expect(packageCommandInvocation(packageInstallCommand('termux-pkg', 'mpv')!, 'android', command => command === 'pkg' ? '/data/data/com.termux/files/usr/bin/pkg' : null)).toEqual({ + program: '/data/data/com.termux/files/usr/bin/pkg', args: ['install', '-y', 'mpv'] + }); + }); + it('uses an encoded PowerShell transport for Windows script shims', () => { const plan = packageInstallCommand('scoop', 'mpv')!; const windows = packageCommandInvocation(plan, 'win32'); diff --git a/src/platform/packages.ts b/src/platform/packages.ts index f4556b7..01b531c 100644 --- a/src/platform/packages.ts +++ b/src/platform/packages.ts @@ -1,9 +1,10 @@ import {existsSync, readFileSync} from 'node:fs'; import {commandExists} from './executables.js'; import {powershellCommand} from './shell.js'; +import {identifyPlatform, type PlatformProfile} from './runtime.js'; export type SetupComponent = 'mpv' | 'ffmpeg' | 'vlc'; -export type SetupPackageManager = 'brew' | 'winget' | 'scoop' | 'choco' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper' | 'pkg' | 'pkg_add' | 'pkgin'; +export type SetupPackageManager = 'brew' | 'winget' | 'scoop' | 'choco' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper' | 'pkg' | 'pkg_add' | 'pkgin' | 'termux-pkg' | 'pkgman'; export type SetupCommand = { component: SetupComponent | null; @@ -13,25 +14,43 @@ export type SetupCommand = { display: string; }; -export const packageManagers: SetupPackageManager[] = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper', 'pkg', 'pkg_add', 'pkgin']; +export const packageManagers: SetupPackageManager[] = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper', 'pkg', 'pkg_add', 'pkgin', 'termux-pkg', 'pkgman']; + +/** FreeBSD pkg and Termux pkg have distinct recipes and privilege models. */ +export function packageManagerProgram(manager: SetupPackageManager): string { + return manager === 'termux-pkg' ? 'pkg' : manager; +} /** Package names/options are selected from the recipes below, never shell input. */ -export function packageCommandInvocation(command: SetupCommand, platform: NodeJS.Platform): {program: string; args: string[]} { +export function packageCommandInvocation( + command: SetupCommand, + platform: NodeJS.Platform, + resolve: (command: string) => string | null = () => null +): {program: string; args: string[]} { + const program = resolve(command.program) ?? command.program; + const args = (command.program === 'sudo' || command.program === 'doas') && command.args[0] + ? [resolve(command.args[0]) ?? command.args[0], ...command.args.slice(1)] + : command.args; return platform === 'win32' - ? {program: 'powershell.exe', args: powershellCommand([command.program, ...command.args])} - : {program: command.program, args: command.args}; + ? {program: resolve('powershell.exe') ?? 'powershell.exe', args: powershellCommand([program, ...args])} + : {program, args}; } export function detectPackageManager( platform: NodeJS.Platform, osRelease: string, - hasCommand: (command: string) => boolean = commandExists + hasCommand: (command: string) => boolean = commandExists, + env: NodeJS.ProcessEnv = process.env ): SetupPackageManager | null { + const host = identifyPlatform({platform, osRelease, env}); + if (host.id === 'termux') return hasCommand('pkg') ? 'termux-pkg' : null; if (platform === 'darwin') return hasCommand('brew') ? 'brew' : null; if (platform === 'win32') return firstAvailable(['winget', 'scoop', 'choco'], hasCommand); if (platform === 'freebsd') return hasCommand('pkg') ? 'pkg' : null; if (platform === 'openbsd') return hasCommand('pkg_add') ? 'pkg_add' : null; - if (platform === 'netbsd') return hasCommand('pkgin') ? 'pkgin' : null; + // pkg on Solaris/illumos is IPS, not the FreeBSD installer. + if (platform === 'netbsd' || platform === 'sunos') return hasCommand('pkgin') ? 'pkgin' : null; + if (platform === 'haiku') return hasCommand('pkgman') ? 'pkgman' : null; if (platform !== 'linux') return null; const ids = linuxReleaseIds(osRelease); @@ -68,11 +87,15 @@ export function packageInstallCommand( pkg_add: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, // pkgsrc FFmpeg/ffplay packages and executables carry a major version. // Choose an available matching pair manually instead of guessing a catalog. - pkgin: {mpv: 'mpv', vlc: 'vlc'} + pkgin: {mpv: 'mpv', vlc: 'vlc'}, + // Termux's main ffmpeg package omits ffplay. Enabling x11-repo and its + // display/audio services is an explicit manual step, not a setup side effect. + 'termux-pkg': {mpv: 'mpv'}, + pkgman: {mpv: 'mpv', ffmpeg: 'ffmpeg8_tools'} }; const packageName = packages[manager][component]; if (!packageName) return null; - let program: string = manager; + let program = packageManagerProgram(manager); let args: string[]; if (manager === 'brew') args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])]; @@ -84,7 +107,7 @@ export function packageInstallCommand( else if (manager === 'pacman') args = ['-S', '--needed', '--noconfirm', packageName]; else if (manager === 'apk') args = ['add', packageName]; else if (manager === 'zypper') args = ['--non-interactive', 'install', packageName]; - else if (manager === 'pkg') args = ['install', '-y', packageName]; + else if (manager === 'pkg' || manager === 'termux-pkg' || manager === 'pkgman') args = ['install', '-y', packageName]; else if (manager === 'pkg_add') args = ['-I', packageName]; else args = ['-y', 'install', packageName]; @@ -96,18 +119,36 @@ export function packageInstallCommand( } export function packageManagerNeedsRoot(manager: SetupPackageManager): boolean { - return !['brew', 'winget', 'scoop', 'choco'].includes(manager); + return !['brew', 'winget', 'scoop', 'choco', 'termux-pkg', 'pkgman'].includes(manager); } -export function packageManagerNotes(manager: SetupPackageManager | null): string[] { +export function packageManagerNotes(manager: SetupPackageManager | null, host: PlatformProfile = identifyPlatform()): string[] { + if (manager === 'termux-pkg' || (!manager && host.id === 'termux')) return [ + 'Termux pkg runs as the normal app user, without sudo or root. Use --package-manager=termux-pkg to select this recipe explicitly.', + 'The Termux ffmpeg package does not include ffplay. The separate ffplay package needs x11-repo and a configured display/audio environment; mpv is the recommended audio player.', + 'Android clipboard integration requires the termux-api package and a compatible Termux:API app; helper execution can still fail if Android denies access.' + ]; + if (manager === 'pkgman' || (!manager && host.id === 'haiku')) return [ + 'The verified HaikuPorts runtime recipe provides Node 20, below RadioCLI\'s Node 22 minimum. These playback recipes do not resolve that runtime blocker; a separate working Node 22+ port is required.', + 'Haiku ffmpeg8_tools provides ffmpeg and ffplay. A secondary-architecture install may require explicit RADIOCLI_FFMPEG_PATH and RADIOCLI_FFPLAY_PATH values.' + ]; + if (host.id === 'aix') return ['There is no verified AIX mpv or ffplay package recipe. Provision a working native player manually and set RADIOCLI_MPV_PATH or RADIOCLI_FFPLAY_PATH; IBM Toolbox DNF availability does not establish a playback package.']; if (manager === 'pkg') return ['FreeBSD ffmpeg provides ffplay only when built with SDL; verify the executable separately.']; - if (manager === 'pkgin') return ['pkgsrc uses versioned FFmpeg/ffplay packages. Choose a matching available pair and set RADIOCLI_FFPLAY_PATH to the absolute ffplay executable.']; + if (manager === 'pkgin' || host.id === 'sunos') return [ + ...(host.id === 'sunos' ? ['Solaris/illumos setup uses an existing pkgsrc/pkgin installation. IPS pkg is a different tool and is not used by this recipe.'] : []), + 'pkgsrc uses versioned FFmpeg/ffplay packages. Choose a matching available pair and set RADIOCLI_FFPLAY_PATH to the absolute ffplay executable.' + ]; return []; } -export function platformLabel(platform: NodeJS.Platform, osRelease: string): string { +export function platformLabel(platform: NodeJS.Platform, osRelease: string, env: NodeJS.ProcessEnv = process.env): string { + if (identifyPlatform({platform, osRelease, env}).id === 'termux') return 'Android / Termux'; if (platform === 'darwin') return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`; if (platform === 'win32') return 'Windows'; + if (platform === 'haiku') return 'Haiku'; + if (platform === 'sunos') return 'Solaris / illumos'; + if (platform === 'aix') return 'AIX'; + if (platform === 'android') return 'Android'; if (platform === 'linux') return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux'; return platform; } @@ -150,7 +191,8 @@ function hasAny(values: Set, candidates: string[]): boolean { return candidates.some(candidate => values.has(candidate)); } -export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { +export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env): string { + if (identifyPlatform({platform, osRelease, env}).id === 'termux') return 'pkg install -y mpv'; if (platform === 'darwin') { return 'brew install mpv'; } @@ -161,7 +203,9 @@ export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, if (platform === 'freebsd') return 'pkg install -y mpv (as root, or use sudo/doas)'; if (platform === 'openbsd') return 'doas pkg_add -I mpv'; - if (platform === 'netbsd') return 'pkgin -y install mpv (as root, or use sudo/doas)'; + if (platform === 'netbsd' || platform === 'sunos') return 'pkgin -y install mpv (with pkgsrc configured, as root or using sudo/doas)'; + if (platform === 'haiku') return 'pkgman install -y mpv'; + if (platform === 'aix') return 'manual native player required; no verified AIX mpv package recipe (set RADIOCLI_MPV_PATH)'; if (platform !== 'linux') { return 'install mpv with your system package manager'; @@ -191,7 +235,8 @@ export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, return 'install mpv with your system package manager'; } -export function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { +export function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env): string { + if (identifyPlatform({platform, osRelease, env}).id === 'termux') return 'enable x11-repo manually, then pkg install -y ffplay (requires configured display/audio); use mpv for audio'; if (platform === 'darwin') { return 'brew install ffmpeg'; } @@ -202,7 +247,9 @@ export function ffplayInstallCommand(platform: NodeJS.Platform = process.platfor if (platform === 'freebsd') return 'pkg install -y ffmpeg; ffplay requires the SDL build option'; if (platform === 'openbsd') return 'doas pkg_add -I ffmpeg'; - if (platform === 'netbsd') return 'install a matching pkgsrc ffmpegN/ffplayN pair; set RADIOCLI_FFPLAY_PATH'; + if (platform === 'netbsd' || platform === 'sunos') return 'install a matching pkgsrc ffmpegN/ffplayN pair; set RADIOCLI_FFPLAY_PATH'; + if (platform === 'haiku') return 'pkgman install -y ffmpeg8_tools'; + if (platform === 'aix') return 'manual native player required; no verified AIX ffplay package recipe (set RADIOCLI_FFPLAY_PATH)'; if (platform !== 'linux') { return 'install FFmpeg with your system package manager'; diff --git a/src/setup.test.ts b/src/setup.test.ts index c89e9b9..6e0c233 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -188,6 +188,124 @@ describe('RadioCLI setup', () => { expect(execute).not.toHaveBeenCalled(); }); + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg', label: 'Android / Termux', expected: 'pkg install -y mpv'}, + {platform: 'linux' as const, env: {PREFIX: '/data/data/com.termux/files/usr'}, manager: 'pkg', label: 'Android / Termux', expected: 'pkg install -y mpv'}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', label: 'Haiku', expected: 'pkgman install -y mpv'}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', label: 'Solaris / illumos', expected: 'pkgin -y install mpv'} + ])('previews the verified $platform installation without running it', async ({platform, env, manager, label, expected}) => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => undefined); + await runSetup({platform, env, args: ['--dry-run', '--only=mpv'], input: new PassThrough(), output, + hasCommand: command => command === manager, getUid: () => 0, runCommand: execute}); + expect(text).toContain(label); + expect(text).toContain(expected); + expect(text).toContain('Dry run complete'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'termux-pkg', executable: 'pkg', uid: 10123, expected: 'pkg install -y mpv'}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', executable: 'pkgman', uid: 1000, expected: 'pkgman install -y mpv'}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', executable: 'pkgin', uid: 0, expected: 'pkgin -y install mpv'} + ])('runs the explicitly approved $manager command with its native privilege model', async ({platform, env, manager, executable, uid, expected}) => { + const execute = vi.fn(async (_command: {display: string}) => undefined); + await runSetup({platform, env, args: ['--yes', '--only=mpv', `--package-manager=${manager}`], input: new PassThrough(), output: new PassThrough(), + hasCommand: command => command === executable, getUid: () => uid, runCommand: execute}); + expect(execute).toHaveBeenCalledOnce(); + expect(execute.mock.calls[0]?.[0]).toMatchObject({display: expected}); + }); + + it('requires approval before running Termux pkg and rejects root execution', async () => { + const execute = vi.fn(async () => undefined); + const options = {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, input: new PassThrough(), output: new PassThrough(), + hasCommand: (command: string) => command === 'pkg', runCommand: execute}; + await expect(runSetup({...options, args: ['--only=mpv'], getUid: () => 10123})).rejects.toThrow('Use --yes'); + await expect(runSetup({...options, args: ['--yes', '--only=mpv'], getUid: () => 0})).rejects.toThrow('normal Termux app user'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('checks the native pkg executable for an explicit Termux manager selection', async () => { + const execute = vi.fn(async () => undefined); + await expect(runSetup({platform: 'android', env: {TERMUX_VERSION: '0.118.3'}, args: ['--yes', '--only=mpv', '--package-manager=termux-pkg'], + input: new PassThrough(), output: new PassThrough(), hasCommand: () => false, getUid: () => 10123, runCommand: execute + })).rejects.toThrow('Requested package manager is not available: termux-pkg (pkg)'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg'}, + {platform: 'freebsd' as const, env: {}, manager: 'termux-pkg'}, + {platform: 'sunos' as const, env: {}, manager: 'pkg'}, + {platform: 'aix' as const, env: {}, manager: 'dnf'} + ])('rejects the wrong native recipe for $manager on $platform before even previewing an install', async ({platform, env, manager}) => { + const execute = vi.fn(async () => undefined); + await expect(runSetup({platform, env, args: ['--dry-run', '--only=mpv', `--package-manager=${manager}`], + input: new PassThrough(), output: new PassThrough(), hasCommand: () => true, getUid: () => 0, runCommand: execute + })).rejects.toThrow('No verified'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg', only: 'ffmpeg', note: 'x11-repo'}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', only: 'vlc', note: 'Node 20'}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', only: 'ffmpeg', note: 'RADIOCLI_FFPLAY_PATH'} + ])('keeps unverified $platform components manual', async ({platform, env, manager, only, note}) => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => undefined); + const options = {platform, env, input: new PassThrough(), output, hasCommand: (command: string) => command === manager, getUid: () => 1000, runCommand: execute}; + await runSetup({...options, args: ['--dry-run', `--only=${only}`]}); + expect(text).toContain('manual installation required'); + expect(text).toContain(note); + await expect(runSetup({...options, args: ['--yes', `--only=${only}`]})).rejects.toThrow('No verified automatic installation command'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('shows the Haiku tools package and current Node catalog blocker together', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + await runSetup({platform: 'haiku', env: {}, args: ['--dry-run', '--only=ffmpeg'], input: new PassThrough(), output, + hasCommand: command => command === 'pkgman'}); + expect(text).toContain('pkgman install -y ffmpeg8_tools'); + expect(text).toMatch(/Node 20.*Node 22/); + expect(text).not.toContain('sudo pkgman'); + }); + + it('prints actionable manual AIX guidance even with a DNF command on PATH', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => undefined); + const options = {platform: 'aix' as const, env: {}, input: new PassThrough(), output, + hasCommand: (command: string) => command === 'dnf', runCommand: execute}; + await runSetup({...options, args: ['--dry-run', '--only=mpv']}); + expect(text).toContain('RADIOCLI_MPV_PATH'); + expect(text).not.toContain('dnf install'); + await expect(runSetup({...options, args: ['--yes', '--only=mpv']})).rejects.toThrow('No supported system package manager'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg', uid: 10123}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', uid: 1000}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', uid: 0} + ])('reports a native $manager failure without claiming setup completed', async ({platform, env, manager, uid}) => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => {throw new Error(`${manager}: package catalog unavailable`);}); + await expect(runSetup({platform, env, args: ['--yes', '--only=mpv'], input: new PassThrough(), output, + hasCommand: command => command === manager, getUid: () => uid, runCommand: execute + })).rejects.toThrow('package catalog unavailable'); + expect(execute).toHaveBeenCalledOnce(); + expect(text).not.toContain('Setup complete'); + }); + it('runs selected installers sequentially and prints final verification', async () => { const output = new PassThrough(); let text = ''; diff --git a/src/setup.ts b/src/setup.ts index 4f14ae9..5a4e9a3 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,16 +1,17 @@ -import {componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageManagerNeedsRoot, packageManagerNotes, packageManagers, platformLabel, readLinuxOsRelease, type SetupCommand, type SetupComponent, type SetupPackageManager} from './platform/packages.js'; +import {componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageManagerNeedsRoot, packageManagerNotes, packageManagerProgram, packageManagers, platformLabel, readLinuxOsRelease, type SetupCommand, type SetupComponent, type SetupPackageManager} from './platform/packages.js'; export {detectPackageManager}; export type {SetupComponent}; import {spawn, type SpawnOptions} from 'node:child_process'; import {realpathSync} from 'node:fs'; import {createInterface} from 'node:readline/promises'; import type {Readable, Writable} from 'node:stream'; -import {clearCommandCache, commandExists} from './player/command.js'; +import {clearCommandCache, commandExists, resolveCommand} from './player/command.js'; import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backend-install.js'; import {configureMcpIntegrations} from './agent/mcp-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; import {resolveTerminalCapabilities} from './platform/terminal.js'; +import {identifyPlatform, type PlatformProfile} from './platform/runtime.js'; export type SetupPlan = { platform: NodeJS.Platform; @@ -24,6 +25,7 @@ export type SetupPlan = { type SetupOptions = { platform?: NodeJS.Platform; osRelease?: string; + env?: NodeJS.ProcessEnv; args?: string[]; input?: Readable; output?: Writable; @@ -47,15 +49,18 @@ const components: SetupComponent[] = ['mpv', 'ffmpeg', 'vlc']; export async function runSetup(options: SetupOptions = {}): Promise { const platform = options.platform ?? process.platform; const osRelease = options.osRelease ?? readLinuxOsRelease(platform); + const env = options.env ?? process.env; + const host = identifyPlatform({platform, osRelease, env}); const input = options.input ?? process.stdin; const output = options.output ?? process.stdout; const hasCommand = options.hasCommand ?? commandExists; const parsed = parseSetupArgs(options.args ?? []); + if (parsed.packageManager) validateNativePackageManager(parsed.packageManager, host); const installed = detectInstalledComponents(hasCommand); - const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand); + const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand, env); writeHeader(output); - output.write(`System ${platformLabel(platform, osRelease)} ${separator(output)} Node ${process.version}\n`); + output.write(`System ${platformLabel(platform, osRelease, env)} ${separator(output)} Node ${process.version}\n`); output.write(`Manager ${packageManager ?? 'not detected'}\n\n`); let selected = parsed.only ?? defaultComponents(platform, parsed.all); @@ -76,9 +81,9 @@ export async function runSetup(options: SetupOptions = {}): Promise { const isRoot = (options.getUid ?? process.getuid)?.() === 0; const elevation = isRoot ? null : hasCommand('sudo') ? 'sudo' : hasCommand('doas') ? 'doas' : 'sudo'; - const plan = createSetupPlan({platform, osRelease, packageManager, installed, selected, elevation}); + const plan = createSetupPlan({platform, osRelease, env, packageManager, installed, selected, elevation}); printPlan(plan, output); - for (const note of packageManagerNotes(packageManager)) output.write(` Note: ${note}\n`); + for (const note of packageManagerNotes(packageManager, host)) output.write(` Note: ${note}\n`); const missing = plan.selected.filter(component => !plan.installed[component]); @@ -106,12 +111,14 @@ export async function runSetup(options: SetupOptions = {}): Promise { const manual = missing.filter(component => !plan.commands.some(command => command.component === component)); if (manual.length) throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`); + if (plan.packageManager === 'termux-pkg' && isRoot) throw new Error('Run setup as the normal Termux app user. Termux pkg refuses root execution.'); if (packageManagerNeedsRoot(plan.packageManager) && !isRoot && !hasCommand(elevation!)) { throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.'); } - if (parsed.packageManager && !hasCommand(parsed.packageManager)) { - throw new Error(`Requested package manager is not available: ${parsed.packageManager}.`); + if (parsed.packageManager && !hasCommand(packageManagerProgram(parsed.packageManager))) { + const program = packageManagerProgram(parsed.packageManager); + throw new Error(`Requested package manager is not available: ${parsed.packageManager}${program !== parsed.packageManager ? ` (${program})` : ''}.`); } if (!parsed.yes && isInteractive(input, output)) { @@ -126,7 +133,7 @@ export async function runSetup(options: SetupOptions = {}): Promise { if (plan.commands.some(command => command.program === 'sudo')) { output.write('\nRadioCLI needs administrator approval for the system package manager.\n'); - await runVisibleCommand('sudo', ['-v']); + await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']); } const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform)); @@ -144,6 +151,7 @@ export async function runSetup(options: SetupOptions = {}): Promise { export function createSetupPlan({ platform, osRelease = '', + env = process.env, packageManager, installed, selected, @@ -151,11 +159,13 @@ export function createSetupPlan({ }: { platform: NodeJS.Platform; osRelease?: string; + env?: NodeJS.ProcessEnv; packageManager: SetupPackageManager | null; installed: Record; selected: SetupComponent[]; elevation?: 'sudo' | 'doas' | null; }): SetupPlan { + if (packageManager) validateNativePackageManager(packageManager, identifyPlatform({platform, osRelease, env})); const uniqueSelected = components.filter(component => selected.includes(component)); const missing = uniqueSelected.filter(component => !installed[component]); const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, {elevation})).filter((command): command is SetupCommand => command !== null) : []; @@ -171,7 +181,7 @@ export function createSetupPlan({ return { platform, - platformLabel: platformLabel(platform, osRelease), + platformLabel: platformLabel(platform, osRelease, env), packageManager, installed, selected: uniqueSelected, @@ -179,6 +189,17 @@ export function createSetupPlan({ }; } +function validateNativePackageManager(manager: SetupPackageManager, host: PlatformProfile): void { + const required = host.id === 'termux' ? 'termux-pkg' : host.id === 'haiku' ? 'pkgman' : host.id === 'sunos' ? 'pkgin' : null; + if ((required && manager !== required) + || (manager === 'termux-pkg' && host.id !== 'termux') + || (manager === 'pkgman' && host.id !== 'haiku') + || (manager === 'pkg' && host.id !== 'freebsd') + || ['aix', 'android', 'unknown'].includes(host.id)) { + throw new Error(`No verified ${manager} playback package recipe for ${host.id}.${required ? ` Use --package-manager=${required}.` : ' Install a native player manually and run radiocli doctor.'}`); + } +} + export function parseSetupArgs(args: string[]): ParsedSetupArgs { let all = false; let dryRun = false; @@ -333,7 +354,7 @@ async function runInstallCommand(command: SetupCommand, output: Writable, platfo try { await new Promise((resolve, reject) => { - const invocation = packageCommandInvocation(command, platform); + const invocation = packageCommandInvocation(command, platform, resolveCommand); const child = spawn(invocation.program, invocation.args, {shell: false, stdio: ['inherit', 'pipe', 'pipe']} satisfies SpawnOptions); child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); }); child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); }); From 633574c2efbc6e75a2791e42acf75b4df4804c05 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:47:33 -0500 Subject: [PATCH 13/25] feat(platform): assess runtime and architecture support from evidence --- src/cli.test.ts | 18 +++ src/cli.tsx | 6 +- src/platform/runtime.test.ts | 6 + src/platform/runtime.ts | 8 +- src/platform/support.test.ts | 274 +++++++++++++++++++++++++++++++++++ src/platform/support.ts | 222 ++++++++++++++++++++++++++++ 6 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 src/platform/support.test.ts create mode 100644 src/platform/support.ts diff --git a/src/cli.test.ts b/src/cli.test.ts index ace4b4e..e696fa9 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -9,6 +9,8 @@ import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; import * as platformPaths from './platform/paths.js'; import * as schedulers from './alarms/scheduler.js'; +import * as support from './platform/support.js'; +import {identifyPlatform} from './platform/runtime.js'; vi.mock('./player/backend-install.js', async importOriginal => { const actual = await importOriginal(); @@ -176,6 +178,22 @@ describe('CLI command dispatch', () => { } finally { spy.mockRestore(); } }); + it.each(['first-class', 'supported', 'experimental', 'unsupported'] as const)('preserves the %s support assessment in JSON and text doctor output', async tier => { + const assessment = support.assessPlatformSupport(identifyPlatform()); + const spy = vi.spyOn(support, 'assessPlatformSupport').mockReturnValue({...assessment, tier}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.support.tier).toBe(tier); + expect(report.support.evidence).toEqual(assessment.evidence); + expect(report.backends).toEqual([]); + logs.length = 0; + await runCommand(['doctor']); + expect(logs.join('\n')).toContain(`support_tier=${tier}`); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + } finally {spy.mockRestore();} + }); + it('rejects unknown commands with the help hint', async () => { await expect(runCommand(['wat'])).rejects.toThrow('Unknown command: wat\nRun radiocli help.'); }); diff --git a/src/cli.tsx b/src/cli.tsx index b0a524f..48f6f6b 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -27,6 +27,7 @@ import {detectPackageManager} from './platform/packages.js'; import {storageReadiness} from './platform/storage.js'; import {resolveTerminalCapabilities} from './platform/terminal.js'; import {networkDiagnostic} from './platform/network.js'; +import {assessPlatformSupport} from './platform/support.js'; const runtime = {nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url)}; @@ -125,6 +126,8 @@ export async function runCommand(args: string[]): Promise { console.log(`capability_${name}=${capability.status} ${capability.message}`); } console.log(`network=${report.network.status} ${report.network.message}`); + console.log(`support_tier=${report.support.tier} runtime=${report.support.runtime.status}`); + for (const reason of report.support.reasons) console.log(`support_note=${reason}`); return; } @@ -336,7 +339,8 @@ async function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic nodeVersion: process.version, platform: process.platform, architecture: process.arch, - host: {id: host.id, arch: host.arch, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl}, + host: {id: host.id, arch: host.arch, armVersion: host.armVersion, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl}, + support: assessPlatformSupport(host), capabilities, terminal, network: networkDiagnostic(), diff --git a/src/platform/runtime.test.ts b/src/platform/runtime.test.ts index a18f26a..1daad8f 100644 --- a/src/platform/runtime.test.ts +++ b/src/platform/runtime.test.ts @@ -25,6 +25,12 @@ describe('platform identity and adapter policy', () => { expect(identifyPlatform({platform: 'linux', release: '6.6', libc: 'musl', env: {}})).toMatchObject({isWsl: false, libc: 'musl'}); }); + it('records explicit ARM ISA evidence without assuming every arm runtime is ARMv7', () => { + expect(identifyPlatform({platform: 'linux', arch: 'arm', armVersion: 7, env: {}})).toMatchObject({arch: 'arm', armVersion: 7}); + expect(identifyPlatform({platform: 'linux', arch: 'arm', armVersion: null, env: {}}).armVersion).toBeNull(); + expect(identifyPlatform({platform: 'linux', arch: 'arm64', armVersion: 7, env: {}}).armVersion).toBeNull(); + }); + it('does not invent a native service for BSD, Termux, Haiku, illumos, or AIX', () => { for (const platform of ['freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const) { const policy = nativeAdapters(identifyPlatform({platform, env: {}})); diff --git a/src/platform/runtime.ts b/src/platform/runtime.ts index 486f25a..71c9fab 100644 --- a/src/platform/runtime.ts +++ b/src/platform/runtime.ts @@ -8,6 +8,7 @@ export type PlatformProfile = { id: PlatformId; platform: string; arch: string; + armVersion: number | null; endianness: 'LE' | 'BE'; release: string; nodeVersion: string; @@ -20,6 +21,7 @@ export type PlatformProfile = { type PlatformInput = { platform?: string; arch?: string; + armVersion?: number | null; endianness?: 'LE' | 'BE'; release?: string; nodeVersion?: string; @@ -38,10 +40,14 @@ export function identifyPlatform(input: PlatformInput = {}): PlatformProfile { const id: PlatformId = termux ? 'termux' : knownPlatforms.has(platform) ? platform as PlatformId : 'unknown'; const kernelRelease = input.release ?? release(); const nodeVersion = (input.nodeVersion ?? process.versions.node).replace(/^v/, ''); + const arch = input.arch ?? process.arch; + const armVersion = input.armVersion !== undefined ? input.armVersion + : platform === process.platform && arch === process.arch ? Number(Reflect.get(process.config.variables, 'arm_version')) : null; return { id, platform, - arch: input.arch ?? process.arch, + arch, + armVersion: arch === 'arm' && armVersion !== null && Number.isInteger(armVersion) && armVersion > 0 ? armVersion : null, endianness: input.endianness ?? endianness(), release: kernelRelease, nodeVersion, diff --git a/src/platform/support.test.ts b/src/platform/support.test.ts new file mode 100644 index 0000000..aa2366a --- /dev/null +++ b/src/platform/support.test.ts @@ -0,0 +1,274 @@ +import {describe, expect, it} from 'vitest'; +import {identifyPlatform, type PlatformProfile} from './runtime.js'; +import {assessPlatformSupport, type PlatformSupportEvidence} from './support.js'; + +function host(platform: string, arch = 'x64', overrides: Partial = {}): PlatformProfile { + const release = platform === 'darwin' ? '23.6.0' : platform === 'win32' ? '10.0.26100' : '6.12.0'; + return {...identifyPlatform({platform, arch, armVersion: null, release, nodeVersion: '22.23.2', endianness: arch === 's390x' ? 'BE' : 'LE', libc: platform === 'linux' ? 'glibc' : 'none', env: {}, osRelease: ''}), ...overrides}; +} + +const verified: PlatformSupportEvidence = { + nativeRuntimeVerified: true, + packedInstallVerified: true, + playbackVerified: true, + requiredCi: true +}; + +describe('platform runtime eligibility', () => { + it.each([ + ['darwin', 'x64', '20.0.0', 22, 'tier-1'], + ['darwin', 'arm64', '22.6.0', 24, 'tier-1'], + ['win32', 'x64', '10.0.19045', 22, 'tier-1'], + ['win32', 'x64', '10.0.26100', 24, 'tier-1'], + ['win32', 'arm64', '10.0.26100', 22, 'tier-2'], + ['win32', 'arm64', '10.0.26100', 24, 'tier-2'], + ['win32', 'ia32', '10.0.19045', 22, 'tier-1'] + ] as const)('records %s/%s kernel %s with Node %s independently of project evidence', (platform, arch, release, nodeMajor, upstream) => { + const result = assessPlatformSupport(host(platform, arch, {release, nodeVersion: `${nodeMajor}.20.0`, nodeMajor})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + }); + + it.each([ + ['darwin', 'x64', '19.6.0', 22, 'macOS 11'], + ['darwin', 'arm64', '22.5.0', 24, 'macOS 13.5'], + ['darwin', 'ia32', '23.6.0', 22, '32-bit'], + ['win32', 'ia32', '10.0.19045', 24, 'Node.js 22'], + ['win32', 'x64', '6.1.7601', 22, 'Windows 10'] + ] as const)('diagnoses a blocked %s/%s runtime', (platform, arch, release, nodeMajor, reason) => { + const result = assessPlatformSupport(host(platform, arch, {release, nodeVersion: `${nodeMajor}.20.0`, nodeMajor}), verified); + expect(result).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + expect(result.reasons.join(' ')).toContain(reason); + }); + + it.each(['', 'unknown', '22'])('does not turn an incomplete Darwin release %j into a blocked runtime', release => { + const result = assessPlatformSupport(host('darwin', 'arm64', {release, nodeVersion: '24.20.0', nodeMajor: 24})); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + expect(result.runtime.reasons.join(' ')).toContain('Darwin'); + }); + + it('reads Darwin kernel versions rather than comparing them directly to macOS versions', () => { + const old = host('darwin', 'x64', {release: '13.5.0', nodeVersion: '24.20.0', nodeMajor: 24}); + expect(assessPlatformSupport(old).runtime.status).toBe('unsupported'); + expect(assessPlatformSupport({...old, release: '22.6.0'}).runtime.status).toBe('eligible'); + }); + + it.each([ + ['win32', 'ia32', 24], ['darwin', 'ia32', 22], ['aix', 'ppc64', 22] + ] as const)('does not lose a known %s/%s runtime exclusion when the OS release is missing', (platform, arch, nodeMajor) => { + expect(assessPlatformSupport(host(platform, arch, {release: '', nodeVersion: `${nodeMajor}.20.0`, nodeMajor}), verified)).toMatchObject({ + tier: 'unsupported', runtime: {status: 'unsupported'} + }); + }); + + it.each(['linux', 'win32', 'freebsd', 'netbsd', 'openbsd'])( + 'keeps an unknown %s OS release unverified without an independent runtime blocker', platform => { + expect(assessPlatformSupport(host(platform, 'x64', {release: ''}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'unverified'} + }); + } + ); + + it.each([ + ['x64', 22, 'tier-1'], ['x64', 24, 'tier-1'], + ['arm64', 22, 'tier-1'], ['arm64', 24, 'tier-1'], + ['armv7l', 22, 'tier-1'], ['armv7l', 24, 'experimental'], + ['ppc64', 22, 'tier-2'], ['ppc64', 24, 'tier-2'], + ['s390x', 22, 'tier-2'], ['s390x', 24, 'tier-2'], + ['riscv64', 24, 'experimental'], ['ia32', 24, 'experimental'] + ] as const)('assesses glibc Linux %s on Node %s', (arch, nodeMajor, upstream) => { + const result = assessPlatformSupport(host('linux', arch, {nodeVersion: `${nodeMajor}.20.0`, nodeMajor})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + expect(result.runtime.reasons.join(' ')).toContain('glibc'); + }); + + it('requires the ARM ISA revision before calling generic arm an upstream ARMv7 target', () => { + const result = assessPlatformSupport(host('linux', 'arm')); + expect(result.runtime.status).toBe('unverified'); + expect(result.runtime.reasons.join(' ')).toContain('ARMv7'); + }); + + it('uses an explicitly reported ARMv7 ISA while leaving older ARM unverified', () => { + expect(assessPlatformSupport(host('linux', 'arm', {armVersion: 7})).runtime).toMatchObject({status: 'eligible', upstream: 'tier-1'}); + expect(assessPlatformSupport(host('linux', 'arm', {armVersion: 6})).runtime.status).toBe('unverified'); + expect(assessPlatformSupport(host('linux', 'arm', {armVersion: 7, nodeVersion: '24.20.0', nodeMajor: 24})).runtime.upstream).toBe('experimental'); + }); + + it.each(['amd64', 'x86_64', 'aarch64', 'armv7', 'ppc64le', 'i386', 'i686'])( + 'recognizes the explicit architecture spelling %s', arch => { + expect(assessPlatformSupport(host('linux', arch)).runtime.status).toBe('eligible'); + } + ); + + it.each([22, 24])('keeps big-endian Linux ppc64 separate from ppc64le on Node %s', nodeMajor => { + const base = host('linux', 'ppc64', {nodeVersion: `${nodeMajor}.20.0`, nodeMajor}); + expect(assessPlatformSupport(base).runtime.upstream).toBe('tier-2'); + const result = assessPlatformSupport({...base, endianness: 'BE'}); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + expect(result.runtime.reasons.join(' ')).toContain('big-endian'); + }); + + it('leaves conflicting POWER endianness and unknown 32-bit POWER ports unverified', () => { + expect(assessPlatformSupport(host('linux', 'ppc64le', {endianness: 'BE'})).runtime.status).toBe('unverified'); + expect(assessPlatformSupport(host('linux', 'ppc', {endianness: 'BE'})).runtime.status).toBe('unverified'); + }); + + it.each([ + ['x64', 22, 'experimental'], ['x64', 24, 'experimental'], + ['arm64', 22, 'community'], ['arm64', 24, 'community'], + ['armv7l', 22, 'community'], ['s390x', 24, 'community'] + ] as const)('does not borrow glibc support for musl %s on Node %s', (arch, nodeMajor, upstream) => { + const result = assessPlatformSupport(host('linux', arch, {libc: 'musl', nodeVersion: `${nodeMajor}.20.0`, nodeMajor})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + expect(result.runtime.reasons.join(' ')).toContain('musl'); + }); + + it.each([ + {libc: 'unknown'}, {libc: 'bionic'}, {release: ''}, {release: '3.10.0'}, + {arch: 'riscv64'}, {arch: 'future-cpu'}, {arch: 'armv7l', libc: 'musl', nodeVersion: '24.20.0', nodeMajor: 24} + ] satisfies Partial[])('leaves incomplete Linux runtime evidence unverified: %j', overrides => { + const result = assessPlatformSupport(host('linux', 'x64', overrides)); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + expect(result.runtime.reasons.length).toBeGreaterThan(0); + }); + + it('keeps WSL separate from upstream native Linux support', () => { + const result = assessPlatformSupport(host('linux', 'x64', {isWsl: true})); + expect(result.runtime).toMatchObject({status: 'unverified', upstream: 'community'}); + expect(result.runtime.reasons.join(' ')).toContain('WSL'); + }); + + it.each([ + ['freebsd', 'x64', 'experimental'], ['freebsd', 'arm64', 'community'], + ['freebsd', 'ppc64', 'community'], ['openbsd', 'x64', 'community'], + ['openbsd', 'arm64', 'community'], ['openbsd', 'riscv64', 'community'], + ['netbsd', 'x64', 'community'], ['netbsd', 'arm64', 'community'] + ] as const)('recognizes a community %s/%s route without claiming a native RadioCLI run', (platform, arch, upstream) => { + const result = assessPlatformSupport(host(platform, arch, {release: platform === 'freebsd' ? '14.4-RELEASE' : '11.0'})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + expect(result.evidence.nativeRuntimeVerified).toBe(false); + }); + + it('does not infer a FreeBSD RISC-V Node port from the availability of a VM image', () => { + expect(assessPlatformSupport(host('freebsd', 'riscv64', {release: '15.1-RELEASE'}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'unverified'} + }); + }); + + it('does not hard-block a community FreeBSD port below the upstream experimental baseline', () => { + expect(assessPlatformSupport(host('freebsd', 'x64', {release: '13.1-RELEASE'}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'unverified'} + }); + }); + + it.each(['freebsd', 'openbsd', 'netbsd'])('keeps the %s port on Node 24 eligible but experimentally supported without evidence', platform => { + expect(assessPlatformSupport(host(platform, 'x64', {release: '14.4', nodeVersion: '24.20.0', nodeMajor: 24}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'eligible'} + }); + }); + + it.each(['android', 'linux'])('recognizes Termux on %s as a separate Bionic community route', platform => { + const profile = identifyPlatform({platform, arch: 'arm64', nodeVersion: '24.20.0', release: '6.6.0', env: {TERMUX_VERSION: '0.118.3'}}); + const result = assessPlatformSupport(profile); + expect(result.runtime).toMatchObject({status: 'eligible', upstream: 'community'}); + expect(result.runtime.reasons.join(' ')).toContain('Bionic'); + expect(result.tier).toBe('experimental'); + }); + + it('leaves Android outside Termux unverified', () => { + expect(assessPlatformSupport(host('android', 'arm64'))).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + }); + + it('blocks Haiku with its usual Node 20 runtime but leaves a newer custom port experimental', () => { + const haiku = host('haiku', 'x64', {nodeVersion: '20.15.1', nodeMajor: 20}); + expect(assessPlatformSupport(haiku)).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + const custom = assessPlatformSupport({...haiku, nodeVersion: '22.23.2', nodeMajor: 22}); + expect(custom).toMatchObject({tier: 'experimental', runtime: {status: 'unverified', upstream: 'community'}}); + expect(custom.runtime.reasons.join(' ')).toContain('Haiku'); + }); + + it('does not identify generic SunOS release 5.11 as a verified SmartOS distribution', () => { + const result = assessPlatformSupport(host('sunos', 'x64', {release: '5.11'})); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified', upstream: 'community'}}); + expect(result.runtime.reasons.join(' ')).toContain('SmartOS'); + expect(result.runtime.reasons.join(' ')).toContain('illumos'); + }); + + it('recognizes AIX big-endian POWER without treating uname release as a full AIX version', () => { + const result = assessPlatformSupport(host('aix', 'ppc64', {endianness: 'BE', release: '3'})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream: 'tier-2'}); + expect(result.tier).toBe('experimental'); + expect(result.runtime.reasons.join(' ')).toContain('7.2 TL04'); + }); + + it('diagnoses a little-endian AIX architecture mismatch', () => { + const result = assessPlatformSupport(host('aix', 'ppc64', {endianness: 'LE', release: '3'})); + expect(result).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + expect(result.runtime.reasons.join(' ')).toContain('big-endian'); + }); + + it('keeps unknown operating systems and architectures visible', () => { + const result = assessPlatformSupport(host('future-os', 'future-cpu')); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified', upstream: 'unverified'}}); + expect(result.runtime.reasons.join(' ')).toContain('future-os/future-cpu'); + }); + + it.each([18, 20, 21])('blocks Node %s before considering any platform or evidence', nodeMajor => { + const result = assessPlatformSupport(host('linux', 'x64', {nodeVersion: `${nodeMajor}.0.0`, nodeMajor}), verified); + expect(result).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + expect(result.reasons.join(' ')).toContain('Node.js 22 or newer'); + }); + + it.each(['unknown', '22broken', '26.8.1'])('does not borrow a Node 22/24 policy for %s', nodeVersion => { + const result = assessPlatformSupport(host('linux', 'x64', {nodeVersion, nodeMajor: Number.parseInt(nodeVersion, 10)})); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + }); +}); + +describe('project support evidence', () => { + it('never promotes a familiar platform from its identity alone', () => { + const result = assessPlatformSupport(host('darwin', 'arm64')); + expect(result.tier).toBe('experimental'); + expect(result.evidence).toEqual({nativeRuntimeVerified: false, packedInstallVerified: false, playbackVerified: false, requiredCi: false}); + expect(result.reasons.join(' ')).toContain('not been verified'); + }); + + it.each(['nativeRuntimeVerified', 'packedInstallVerified', 'playbackVerified'] as const)('requires %s even when every other evidence item is supplied', missing => { + const result = assessPlatformSupport(host('linux'), {...verified, [missing]: false}); + expect(result.tier).toBe('experimental'); + }); + + it('promotes verified runtime, packed install, and playback to supported', () => { + const result = assessPlatformSupport(host('freebsd', 'x64', {release: '14.4-RELEASE'}), {...verified, requiredCi: false}); + expect(result.tier).toBe('supported'); + expect(result.runtime.upstream).toBe('experimental'); + expect(result.reasons.join(' ')).toContain('required CI'); + }); + + it('requires ongoing required CI for first-class support', () => { + const result = assessPlatformSupport(host('win32', 'arm64'), verified); + expect(result.tier).toBe('first-class'); + expect(result.runtime.upstream).toBe('tier-2'); + }); + + it('allows actual project verification to establish support for a custom port independently of its upstream status', () => { + const result = assessPlatformSupport(host('haiku'), {...verified, requiredCi: false}); + expect(result.tier).toBe('supported'); + expect(result.runtime).toMatchObject({status: 'unverified', upstream: 'community'}); + }); + + it('does not infer missing evidence from a required CI flag', () => { + expect(assessPlatformSupport(host('linux'), {requiredCi: true}).tier).toBe('experimental'); + }); + + it('copies supplied evidence without mutating the caller or retaining its object', () => { + const input = {...verified}; + const result = assessPlatformSupport(host('linux'), input); + expect(input).toEqual(verified); + input.playbackVerified = false; + expect(result.evidence.playbackVerified).toBe(true); + }); +}); diff --git a/src/platform/support.ts b/src/platform/support.ts new file mode 100644 index 0000000..1c72f58 --- /dev/null +++ b/src/platform/support.ts @@ -0,0 +1,222 @@ +import {identifyPlatform, type PlatformProfile} from './runtime.js'; + +type SupportTier = 'first-class' | 'supported' | 'experimental' | 'unsupported'; +type RuntimeStatus = 'eligible' | 'unverified' | 'unsupported'; +type UpstreamSupport = 'tier-1' | 'tier-2' | 'experimental' | 'community' | 'unverified' | 'unsupported'; + +/** Evidence must describe this OS, architecture, libc, and Node release line. */ +export type PlatformSupportEvidence = { + /** A real target runtime was exercised; mocks and foreign-CPU emulation do not establish this. */ + nativeRuntimeVerified: boolean; + /** The packed npm artifact was installed and exercised on the target. */ + packedInstallVerified: boolean; + /** Playback was exercised; discovering an executable or a release asset is insufficient. */ + playbackVerified: boolean; + /** The verified target is covered by required, maintained CI. */ + requiredCi: boolean; +}; + +type RuntimeAssessment = { + /** Eligibility for a known runtime route, not proof that RadioCLI was executed. */ + status: RuntimeStatus; + upstream: UpstreamSupport; + reasons: string[]; +}; + +export type PlatformSupportAssessment = { + tier: SupportTier; + runtime: RuntimeAssessment; + reasons: string[]; + evidence: PlatformSupportEvidence; +}; + +/** + * Runtime policy and project evidence are deliberately independent. Catalogs, + * platform mocks, containers, and downloaded artifacts never supply evidence. + * This checks the Node 22/24 reference matrix; other newer lines remain unverified. + * OS vendor lifecycle, CPU instruction levels, libc versions, and audio services + * require separate checks when the profile does not contain those facts. + * + * Reference policy (checked 2026-09-07): + * https://github.com/nodejs/node/blob/v22.x/BUILDING.md#platform-list + * https://github.com/nodejs/node/blob/v24.x/BUILDING.md#platform-list + */ +export function assessPlatformSupport( + host: PlatformProfile = identifyPlatform(), + suppliedEvidence: Partial = {} +): PlatformSupportAssessment { + const evidence: PlatformSupportEvidence = { + nativeRuntimeVerified: suppliedEvidence.nativeRuntimeVerified === true, + packedInstallVerified: suppliedEvidence.packedInstallVerified === true, + playbackVerified: suppliedEvidence.playbackVerified === true, + requiredCi: suppliedEvidence.requiredCi === true + }; + const runtime = assessRuntime(host); + const reasons = [...runtime.reasons]; + let tier: SupportTier = 'experimental'; + + if (runtime.status === 'unsupported') { + tier = 'unsupported'; + } else if (evidence.nativeRuntimeVerified && evidence.packedInstallVerified && evidence.playbackVerified) { + tier = evidence.requiredCi ? 'first-class' : 'supported'; + reasons.push(evidence.requiredCi + ? 'Native runtime, packed installation, and playback are verified with required CI coverage.' + : 'Native runtime, packed installation, and playback are verified; required CI coverage is not established.'); + } else { + if (!evidence.nativeRuntimeVerified) reasons.push('Native runtime execution has not been verified.'); + if (!evidence.packedInstallVerified) reasons.push('Packed npm installation has not been verified.'); + if (!evidence.playbackVerified) reasons.push('Playback has not been verified.'); + } + + return {tier, runtime, reasons, evidence}; +} + +function assessRuntime(host: PlatformProfile): RuntimeAssessment { + const node = /^(\d+)\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.exec(host.nodeVersion); + if (!node) return assessment('unverified', 'unverified', `Cannot assess the Node.js version ${JSON.stringify(host.nodeVersion)}.`); + const major = Number(node[1]); + if (!Number.isSafeInteger(major)) return assessment('unverified', 'unverified', 'The Node.js major version is not recognized.'); + if (major < 22) return assessment('unsupported', 'unsupported', `RadioCLI requires Node.js 22 or newer; this runtime is ${host.nodeVersion}.`); + if (major !== 22 && major !== 24) { + return assessment('unverified', 'unverified', `Node.js ${major} meets the package minimum, but its platform requirements are outside the verified Node.js 22/24 reference matrix.`); + } + + const arch = host.arch === 'arm' && host.armVersion === 7 ? 'armv7' : architecture(host.arch); + if (host.id === 'darwin' && arch === 'ia32') return assessment('unsupported', 'unsupported', 'Node.js 22/24 do not provide a 32-bit macOS runtime; use Intel x64 or Apple Silicon arm64.'); + if (host.id === 'win32' && major === 24 && arch === 'ia32') return assessment('unsupported', 'unsupported', 'Node.js 24 has no Windows x86 runtime; use Node.js 22 for ia32 or a native x64/arm64 runtime.'); + if (host.id === 'aix' && (arch === 'ppc64' || arch === 'ppc64le') && (host.endianness !== 'BE' || arch === 'ppc64le')) { + return assessment('unsupported', 'unsupported', 'The AIX Node.js runtime requires big-endian ppc64; a little-endian POWER profile is incompatible.'); + } + const version = releaseVersion(host.release); + if (!version) { + return assessment('unverified', 'unverified', `${host.id === 'darwin' ? 'Darwin' : host.platform} release is unavailable or unrecognized; runtime eligibility needs the OS version.`); + } + + switch (host.id) { + case 'darwin': + if (!['x64', 'arm64'].includes(arch)) return unknownArchitecture(host); + // os.release() is the Darwin kernel: Darwin 20 = macOS 11, + // Darwin 22.6 = macOS 13.5. It is not a macOS product-version string. + if (version.length < 2) return assessment('unverified', 'tier-1', 'A complete Darwin kernel release is needed to check the macOS runtime minimum.'); + if (olderThan(version, major === 22 ? [20, 0] : [22, 6])) { + return assessment('unsupported', 'unsupported', `Node.js ${major} requires ${major === 22 ? 'macOS 11 (Darwin 20)' : 'macOS 13.5 (Darwin 22.6)'} or newer.`); + } + return assessment('eligible', 'tier-1', `The Darwin kernel meets the Node.js ${major} macOS binary minimum for ${arch}.`); + case 'win32': + if (!['x64', 'arm64', 'ia32'].includes(arch)) return unknownArchitecture(host); + if (version.length < 2) return assessment('unverified', 'unverified', 'A complete Windows kernel version is needed to check the Windows 10 minimum.'); + if (olderThan(version, [10, 0])) return assessment('unsupported', 'unsupported', 'The standard Node.js 22/24 Windows runtime requires Windows 10 / Server 2016 or newer.'); + return assessment('eligible', arch === 'arm64' ? 'tier-2' : 'tier-1', arch === 'ia32' + ? 'Node.js 22 provides Windows x86 binaries; upstream tests run under WoW64, which does not establish native 32-bit Windows verification.' + : `Node.js ${major} provides a Windows ${arch} runtime; the kernel version alone does not distinguish Windows desktop and Server editions.`); + case 'linux': + return linuxRuntime(host, arch, major, version); + case 'freebsd': + // Port allowlists are not evidence that every release/CPU binary exists. + // https://github.com/freebsd/freebsd-ports/blob/main/www/node22/Makefile + // https://github.com/freebsd/freebsd-ports/blob/main/www/node24/Makefile + if (!['x64', 'arm64', 'ia32', 'armv7', 'ppc64', 'ppc64le'].includes(arch)) return unknownArchitecture(host); + if (olderThan(version, [13, 2])) return assessment('unverified', 'experimental', 'FreeBSD is below the upstream experimental 13.2 baseline; a compatible Node.js port needs separate verification.'); + return assessment('eligible', arch === 'x64' ? 'experimental' : 'community', 'FreeBSD has Node.js 22/24 ports; use the matching npm-node package and verify availability for this release and architecture.'); + case 'openbsd': + // https://github.com/openbsd/ports/blob/master/lang/node/Makefile + if (!['x64', 'arm64', 'ia32', 'ppc64', 'riscv64'].includes(arch)) return unknownArchitecture(host); + return assessment('eligible', 'community', 'OpenBSD uses its community Node.js port; the selected OS release and architecture must provide Node.js 22 or newer.'); + case 'netbsd': + // https://github.com/NetBSD/pkgsrc/tree/trunk/lang/nodejs22 + if (!['x64', 'arm64'].includes(arch)) return unknownArchitecture(host); + return assessment('eligible', 'community', 'NetBSD has pkgsrc Node.js 22/24 ports; binary packages are named nodejs and depend on the repository release and architecture.'); + case 'termux': + // https://github.com/termux/termux-packages/blob/master/packages/nodejs-lts/build.sh + if (!['x64', 'arm64', 'ia32', 'arm', 'armv7'].includes(arch)) return unknownArchitecture(host); + return assessment('eligible', 'community', 'Termux needs its Android Bionic Node.js package and Android 7 or newer; the Linux kernel release does not establish the Android API version.'); + case 'android': + return assessment('unverified', 'unverified', 'Android outside Termux has no established RadioCLI runtime installation route; verify a compatible Bionic Node.js build.'); + case 'haiku': + // https://github.com/haikuports/haikuports/tree/master/net-libs/nodejs + return assessment('unverified', 'community', 'HaikuPorts currently provides Node.js 20; this newer runtime requires a separately verified custom Node.js port.'); + case 'sunos': + return assessment('unverified', 'community', 'SmartOS x64 has upstream Tier 2 status; generic SunOS kernel 5.11 does not identify SmartOS, illumos distribution, or its version. Verify the selected Node.js port and playback backend.'); + case 'aix': + if (arch !== 'ppc64') return unknownArchitecture(host); + return assessment('eligible', 'tier-2', `AIX requires big-endian POWER8 or newer and AIX 7.2 TL04 or newer${major === 24 ? ', plus libstdc++12' : ''}; uname release alone does not establish these prerequisites. Playback backend availability needs separate verification.`); + case 'unknown': + return unknownArchitecture(host); + } +} + +function linuxRuntime(host: PlatformProfile, arch: string, major: number, version: number[]): RuntimeAssessment { + if (host.isWsl) return assessment('unverified', 'community', 'WSL uses a Linux Node.js runtime, but upstream support requires reproducing issues on native Linux; verify this WSL environment separately.'); + if (arch === 'arm') return assessment('unverified', 'unverified', 'The generic arm architecture does not identify its ISA revision. Node.js 22 Tier 1 applies to ARMv7; older ARM and Node.js 24 ARMv7 need separate community verification.'); + if ((arch === 'ppc64' || arch === 'ppc64le') && host.endianness !== 'LE') { + return assessment('unverified', 'unverified', 'Upstream Linux POWER support applies to ppc64le, not big-endian ppc64. A compatible big-endian Node.js 22+ community port must be verified separately.'); + } + if (host.libc === 'musl') { + // Node 24 now publishes x64-musl files while its matrix remains Experimental. + // Other known routes are distributor builds, not glibc binary compatibility. + // https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt + // https://github.com/nodejs/docker-node/blob/main/versions.json + const knownRoute = ['x64', 'arm64', 's390x'].includes(arch) || (arch === 'armv7' && major === 22); + if (!knownRoute) return assessment('unverified', 'community', `A Node.js ${major} musl build for ${host.arch} requires separate package and runtime verification.`); + if (olderThan(version, [3, 10])) return assessment('unverified', 'experimental', 'This kernel is below the Node.js musl reference baseline; compatibility is unverified.'); + return assessment('eligible', arch === 'x64' ? 'experimental' : 'community', `Use a musl-specific Node.js ${major} build and its libstdc++ dependencies; libc family detection does not verify the musl version.`); + } + if (host.libc !== 'glibc') return assessment('unverified', 'unverified', `Linux libc is ${host.libc}; select a compatible Node.js build before declaring runtime eligibility.`); + + let upstream: UpstreamSupport; + let minimumKernel = [4, 18]; + let minimumGlibc = '2.28'; + let detail = ''; + if (arch === 'x64' || arch === 'arm64') upstream = 'tier-1'; + else if (arch === 'armv7') { + upstream = major === 22 ? 'tier-1' : 'experimental'; + detail = major === 22 ? ' ARMv7 binaries also require GLIBCXX_3.4.28.' : ' Node.js 24 ARMv7 needs a community build; no upstream release binary is provided.'; + } else if (arch === 'ppc64' || arch === 'ppc64le' || arch === 's390x') { + upstream = 'tier-2'; + if (arch !== 's390x') detail = ' POWER8 or newer is required.'; + } else if (arch === 'riscv64' && major === 24) { + upstream = 'experimental'; + minimumKernel = [5, 19]; + minimumGlibc = '2.36'; + } else if (arch === 'ia32') { + upstream = 'experimental'; + minimumKernel = [3, 10]; + minimumGlibc = '2.17'; + } else return unknownArchitecture(host); + + if (version.length < 2 || olderThan(version, minimumKernel)) { + return assessment('unverified', upstream, `The glibc Linux kernel does not establish the ${minimumKernel.join('.')} reference minimum; older kernels may work but require separate verification.`); + } + return assessment('eligible', upstream, `Node.js ${major} requires glibc >= ${minimumGlibc} and a compatible libstdc++; the profile records only the libc family.${detail}`); +} + +function assessment(status: RuntimeStatus, upstream: UpstreamSupport, ...reasons: string[]): RuntimeAssessment { + return {status, upstream, reasons}; +} + +function unknownArchitecture(host: PlatformProfile): RuntimeAssessment { + return assessment('unverified', 'unverified', `No runtime eligibility is established for ${host.platform}/${host.arch}; verify a compatible Node.js port instead of inferring support from another target.`); +} + +function architecture(arch: string): string { + if (['amd64', 'x86_64'].includes(arch)) return 'x64'; + if (arch === 'aarch64') return 'arm64'; + if (['x86', 'i386', 'i686'].includes(arch)) return 'ia32'; + if (arch === 'armv7l') return 'armv7'; + return arch; +} + +function releaseVersion(release: string): number[] | null { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[.+_-][\w.+-]+)?$/.exec(release); + if (!match) return null; + const parts = match.slice(1).filter(part => part !== undefined).map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function olderThan(actual: number[], minimum: number[]): boolean { + for (let index = 0; index < minimum.length; index++) { + const difference = (actual[index] ?? 0) - minimum[index]!; + if (difference !== 0) return difference < 0; + } + return false; +} From e9f27329a0211d74a9663b6a701b08976e031911 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:53:05 -0500 Subject: [PATCH 14/25] fix(platform): preserve native launch environments and cleanup evidence --- src/agent/launcher.test.ts | 10 +++- src/alarms/guard.test.ts | 86 ++++++++++++++++++++++++++++++++++ src/alarms/guard.ts | 3 +- src/alarms/scheduler.test.ts | 73 ++++++++++++++++++++++++++++- src/alarms/scheduler.ts | 41 +++++++++++----- src/platform/launch-command.ts | 39 +++++++++++++++ src/platform/terminals.test.ts | 54 ++++++++++++++++----- src/platform/terminals.ts | 28 +++++++---- 8 files changed, 298 insertions(+), 36 deletions(-) create mode 100644 src/platform/launch-command.ts diff --git a/src/agent/launcher.test.ts b/src/agent/launcher.test.ts index feaebde..ffaec17 100644 --- a/src/agent/launcher.test.ts +++ b/src/agent/launcher.test.ts @@ -13,6 +13,14 @@ const spawn=vi.fn((command:string,args:readonly string[])=>{calls.push({command, beforeEach(()=>{calls.length=0;spawn.mockClear();vi.mocked(nodeSpawn).mockImplementation(spawn as unknown as typeof nodeSpawn);}); afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); function fixture(){const root=mkdtempSync(join(tmpdir(),'radiocli-launcher-'));roots.push(root);return root;} +function nodeInvocation(args: readonly string[]): {command: string; args: string[]} { + const encoded = args[args.indexOf('-EncodedCommand') + 1]; + const script = Buffer.from(encoded!, 'base64').toString('utf16le'); + const match = /FromBase64String\('([^']+)'\)/.exec(script); + expect(match).not.toBeNull(); + const invocation = JSON.parse(Buffer.from(match![1]!, 'base64').toString('utf8')) as {command: string; args: string[]}; + return invocation.args.includes('-EncodedCommand') ? nodeInvocation(invocation.args) : invocation; +} describe('agent graphical launcher',()=>{ it('does not resolve a directory as a command',()=>{const root=fixture();const path=join(root,'radiocli-test-command');mkdirSync(path);expect(resolveExecutable(path,{PATH:root},'linux')).toBeUndefined();}); @@ -24,7 +32,7 @@ describe('agent graphical launcher',()=>{ await launchRadioTui(nodePath,cliPath,'encoded-agent-command',{platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:terminal,RADIOCLI_HOME:home},spawn,resolve:command=>command}); const call=calls[0]!;expect(call.command).not.toBe('cmd.exe');const encoded=call.args[call.args.indexOf('-EncodedCommand')+1];expect(call.args).toContain('-EncodedCommand'); const script=Buffer.from(encoded!,'base64').toString('utf16le');const match=/FromBase64String\('([^']+)'\)/.exec(script);expect(match).not.toBeNull(); - const invocation=JSON.parse(Buffer.from(match![1]!,'base64').toString('utf8')) as {command:string;args:string[]};expect(invocation.command).toBe(nodePath); + const invocation=nodeInvocation(call.args);expect(invocation.command).toBe(nodePath); const payload=JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')); expect(payload).toEqual({args:[cliPath,'agent-ui','encoded-agent-command'],environment:{RADIOCLI_HOME:home}});expect(script).not.toContain(home);expect(script).not.toContain(cliPath); }); diff --git a/src/alarms/guard.test.ts b/src/alarms/guard.test.ts index 23f1dbc..6937105 100644 --- a/src/alarms/guard.test.ts +++ b/src/alarms/guard.test.ts @@ -129,5 +129,91 @@ describe('one-shot alarm guard',()=>{ }); }); +describe('alarm-specific Guard ownership', () => { + const oldOccurrence = '2030-01-01T00:01:00.000Z'; + const nameFor = (alarmId: string) => `${Buffer.from(`${alarmId}\0${oldOccurrence}`).toString('base64url')}.json`; + const ownerFor = (alarmId: string) => JSON.stringify({alarmId, occurrenceAt: oldOccurrence, pid: process.pid, port: 43210, token: 'a'.repeat(64)}); + + async function withRecord(name: string, contents: string, check: (fixture: { + service: AlarmGuardService; + alarm: Alarm; + path: string; + directory: string; + spawn: ReturnType; + }) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), 'radiocli-guard-record-scope-')); + try { + const directory = join(root, 'guards'); + mkdirSync(directory); + const path = join(directory, name); + writeFileSync(path, contents); + const store = new AlarmPowerGuardStore(join(root, 'power.json')); + let spawnedToken: string | undefined; + const spawn = vi.fn((_command: string, args: string[]) => { + const occurrenceAt = args.at(-3)!; + const pidPath = args.at(-2)!; + const token = args.at(-1)!; + spawnedToken = token; + writeFileSync(pidPath, JSON.stringify({alarmId: 'a', occurrenceAt, pid: process.pid, port: 43210, token})); + store.markActive('a', new Date(), occurrenceAt); + return {pid: process.pid, unref: vi.fn()}; + }); + const alarm: Alarm = { + id: 'a', label: 'Wake', enabled: true, + station: {id: 'x', provider: 'radio-browser', name: 'X', tags: []}, + schedule: {type: 'once', at: '2030-01-01T00:02:00.000Z'}, + playback: {volume: 30, fadeSeconds: 0, stopAfterMinutes: 30}, + reliability: {missedRunGraceMinutes: 10, wakeIfSupported: false}, + createdAt: 'x', updatedAt: 'x' + }; + const service = new AlarmGuardService(store, 'node', 'cli.js', spawn, () => new Date('2030-01-01T00:00:00Z'), directory, async owner => owner.token === spawnedToken, vi.fn(async () => false)); + await check({service, alarm, path, directory, spawn}); + } finally { + rmSync(root, {recursive: true, force: true}); + } + } + + it.each([ + {name: 'partial.json', contents: '{"alarmId":', reason: 'an undecodable filename'}, + {name: nameFor('b'), contents: '{"alarmId":', reason: 'another alarm encoded in its filename'}, + {name: nameFor('a'), contents: ownerFor('b'), reason: 'a parsed owner ID that differs from its filename'} + ])('starts an unrelated alarm despite $reason', async ({name, contents}) => { + await withRecord(name, contents, async ({service, alarm, path, spawn}) => { + await expect(service.start(alarm)).resolves.toMatchObject({pid: process.pid, occurrenceAt: alarm.schedule.type === 'once' ? alarm.schedule.at : undefined}); + expect(spawn).toHaveBeenCalledTimes(1); + expect(readFileSync(path, 'utf8')).toBe(contents); + }); + }); + + it.each([ + {name: nameFor('a'), contents: '{"alarmId":', reason: 'its own encoded filename'}, + {name: nameFor('b'), contents: ownerFor('a'), reason: 'its own parsed owner ID'} + ])('blocks a replacement identified by $reason', async ({name, contents}) => { + await withRecord(name, contents, async ({service, alarm, path, spawn}) => { + await expect(service.start(alarm)).rejects.toThrow(/ownership.*repair/i); + expect(spawn).not.toHaveBeenCalled(); + expect(readFileSync(path, 'utf8')).toBe(contents); + }); + }); + + it('reports one alarm stopped while retaining an unknown record for global repair', async () => { + await withRecord('partial.json', '{"alarmId":', async ({service, path, directory}) => { + const knownPath = join(directory, nameFor('a')); + writeFileSync(knownPath, ownerFor('a')); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => {throw Object.assign(new Error('No process'), {code: 'ESRCH'});}); + try { + expect(await service.stop('a')).toBe(true); + expect(() => readFileSync(knownPath)).toThrow(); + expect(await service.stop()).toBe(false); + expect(await service.status()).toMatchObject({active: false, guards: [], unresolvedGuards: [{message: expect.stringContaining('retained for repair')}]}); + expect(readFileSync(path, 'utf8')).toBe('{"alarmId":'); + expect(kill.mock.calls.every(call => call[1] === 0)).toBe(true); + } finally { + kill.mockRestore(); + } + }); + }); +}); + function readFile(path:string){try{readFileSync(path);return true;}catch{return false;}} function controlRequest(port:number,token:string,method:string,path:string,host='127.0.0.1'):Promise<{status:number;body:string}>{return new Promise((resolve,reject)=>{const req=request({host,port,path,method,agent:false,headers:{authorization:`Bearer ${token}`}},res=>{let body='';res.on('data',chunk=>body+=String(chunk));res.on('end',()=>resolve({status:res.statusCode??0,body}));});req.once('error',reject);req.end();});} diff --git a/src/alarms/guard.ts b/src/alarms/guard.ts index 9fc3cb4..864f460 100644 --- a/src/alarms/guard.ts +++ b/src/alarms/guard.ts @@ -153,7 +153,8 @@ export class AlarmGuardService { private hasOwnershipRecords(alarmId?:string):boolean { return this.pidFiles().some(path=>{ const ownerId=readPid(path)?.alarmId??alarmIdFromPath(path); - return !alarmId||!ownerId||ownerId===alarmId; + // Unknown records require global repair, but cannot identify this alarm. + return !alarmId||ownerId===alarmId; }); } private async withOwnership(alarmId:string,work:()=>Promise):Promise{const lock=join(this.directory,`.lock-${Buffer.from(alarmId).toString('base64url')}`);mkdirSync(this.directory,{recursive:true,mode:0o700});const deadline=Date.now()+3000;while(true){try{mkdirSync(lock,{mode:0o700});writeFileSync(join(lock,'created'),String(Date.now()),{mode:0o600});break;}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;let stale=false;try{const created=Number(readFileSync(join(lock,'created'),'utf8'));stale=Number.isFinite(created)?Date.now()-created>10_000:Date.now()-statSync(lock).mtimeMs>10_000;}catch{try{stale=Date.now()-statSync(lock).mtimeMs>10_000;}catch{}}if(stale){rmSync(lock,{recursive:true,force:true});continue;}if(Date.now()>deadline)throw new Error('Alarm Guard ownership is busy.');await delay(20);}}try{return await work();}finally{rmSync(lock,{recursive:true,force:true});}} diff --git a/src/alarms/scheduler.test.ts b/src/alarms/scheduler.test.ts index 98821f2..155b8be 100644 --- a/src/alarms/scheduler.test.ts +++ b/src/alarms/scheduler.test.ts @@ -2,6 +2,7 @@ import {describe, expect, it, vi} from 'vitest'; import {existsSync,mkdirSync,mkdtempSync,readdirSync,rmSync,writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {basename,dirname,join} from 'node:path'; +import {execFileSync} from 'node:child_process'; import type {Alarm} from '../types.js'; import {createSchedulerAdapter, SchedulerService,shouldRunLaunchdOccurrence,type SchedulerAdapter} from './scheduler.js'; @@ -14,6 +15,17 @@ const alarm: Alarm = { createdAt: '2029-01-01T00:00:00.000Z', updatedAt: '2029-01-01T00:00:00.000Z' }; +function windowsTaskInvocation(body: string): {command: string; args: string[]} { + expect(body).toContain('%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); + const argumentsText = /([^<]+)<\/Arguments>/.exec(body)?.[1]; + expect(argumentsText).toBeDefined(); + const args = argumentsText!.split(' '); + const script = Buffer.from(args[args.indexOf('-EncodedCommand') + 1]!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(encoded).toBeDefined(); + return JSON.parse(Buffer.from(encoded!, 'base64').toString('utf8')); +} + describe('native alarm schedulers', () => { it('generates safely escaped launchd artifacts and an argv-only command', async () => { const write = vi.fn(); const run = vi.fn(async () => ({code: 0, stdout: '', stderr: ''})); @@ -62,6 +74,22 @@ describe('native alarm schedulers', () => { expect(adapter.capabilities().message).toMatch(/systemd/i); }); + it('retains scheduler health and repair artifacts when systemctl is unavailable',async()=>{ + const run=vi.fn();const removeFile=vi.fn();const health={record:vi.fn(),list:()=>[],get:()=>[],remove:vi.fn()}; + const adapter=createSchedulerAdapter({platform:'linux',home:'/missing-radiocli-home',env:{},commandExists:()=>false,run,removeFile,writeFile:vi.fn()}); + const service=new SchedulerService(adapter,()=>new Date(),health as never); + await expect(service.remove(alarm.id)).rejects.toThrow(/systemctl.*unavailable|verify.*remov|verify.*absence/i); + expect(health.remove).not.toHaveBeenCalled();expect(removeFile).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled(); + expect(health.record).toHaveBeenCalledWith(expect.objectContaining({alarmId:alarm.id,component:'scheduler',healthy:false,message:expect.stringContaining('retained')})); + }); + + it('allows removal of a definition on a runtime with no native scheduler adapter',async()=>{ + const run=vi.fn();const adapter=createSchedulerAdapter({platform:'freebsd',env:{},run}); + const health={record:vi.fn(),list:()=>[],get:()=>[],remove:vi.fn()}; + await expect(new SchedulerService(adapter,()=>new Date(),health as never).remove(alarm.id)).resolves.toBeUndefined(); + expect(health.remove).toHaveBeenCalledWith(alarm.id);expect(run).not.toHaveBeenCalled(); + }); + it('creates a current-user Windows task with wake and local interactive audio semantics', async () => { const write=vi.fn();const run=vi.fn(async()=>({code:0,stdout:'',stderr:''})); const adapter=createSchedulerAdapter({platform:'win32',home:'C:\\Users\\A B',nodePath:'C:\\Node A\\node.exe',cliPath:'C:\\Radio A\\dist\\cli.js',writeFile:write,removeFile:vi.fn(),run,env:{LOCALAPPDATA:'C:\\Data A',RADIOCLI_HOME:'C:\\Profile & A'}}); @@ -74,18 +102,59 @@ describe('native alarm schedulers', () => { expect(artifact).toContain('false'); expect(artifact).toContain('PT0S'); expect(artifact).toContain('encoding="UTF-8"'); - expect(artifact).toContain('RADIOCLI_HOME=C:\\Profile ^& A" &&'); - expect(artifact).toContain('RADIOCLI_ALARM_TERMINAL=win32:console'); + const invocation=windowsTaskInvocation(artifact); + expect(invocation.command).toBe('C:\\Node A\\node.exe'); + expect(JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')).environment).toEqual({RADIOCLI_HOME:'C:\\Profile & A',RADIOCLI_ALARM_TERMINAL:'win32:console'}); expect(artifact).not.toContain('&amp;'); expect(adapter.capabilities().exactWake).toBe(false); expect((run.mock.calls as unknown as Array<[string,string[]]>)[0]?.[1]).toContain('/XML'); }); + it('preserves literal Windows job arguments and approved environment values',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-windows-job-')); + try{ + const cliPath=join(root,"Radio $ %PATH%! & ' 单播.cjs");const writes:Array<[string,string]>=[]; + const configured={RADIOCLI_HOME:"C:\\Data %PATH%! & ' 单播",RADIOCLI_MPV_PATH:"C:\\Players %PATH%! & ' 单播\\mpv.exe",RADIOCLI_FFPLAY_PATH:'C:\\Players A\\ffplay.exe',RADIOCLI_VLC_PATH:'C:\\Players A\\vlc.exe',RADIOCLI_FFMPEG_PATH:'C:\\Players A\\ffmpeg.exe',RADIOCLI_OFFLINE:'0',RADIOCLI_LOW_BANDWIDTH:'1'}; + writeFileSync(cliPath,`process.stdout.write(JSON.stringify({args:process.argv.slice(2),environment:Object.fromEntries(${JSON.stringify(Object.keys(configured))}.map(key=>[key,process.env[key]]))}))`); + const adapter=createSchedulerAdapter({platform:'win32',home:root,nodePath:process.execPath,cliPath,env:{...configured,HTTPS_PROXY:'https://private-password@proxy.invalid'},writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); + const {command,args}=windowsTaskInvocation(writes[0]![1]);expect(command).toBe(process.execPath);expect(args).toHaveLength(3);expect(args[0]).toBe('-e'); + const payload=JSON.parse(Buffer.from(args.at(-1)!,'base64url').toString('utf8'));expect(payload.environment).toEqual({...configured,RADIOCLI_ALARM_TERMINAL:'win32:console'});expect(JSON.stringify(payload)).not.toContain('private-password'); + const output=execFileSync(command,args,{encoding:'utf8',windowsHide:true}); + expect(JSON.parse(output)).toEqual({args:['alarm','internal-run',alarm.id,'2030-02-03T14:05:00.000Z'],environment:configured}); + }finally{rmSync(root,{recursive:true,force:true});} + }); + it('pins systemd timers in UTC and escapes specifiers',async()=>{ const writes:Array<[string,string]>=[];const adapter=createSchedulerAdapter({platform:'linux',home:'/home/a',nodePath:'/node%p',cliPath:'/app%h/cli.js',writeFile:(p,c)=>writes.push([p,c]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''})),commandExists:()=>true,env:{RADIOCLI_HOME:'/data%u'}}); await adapter.install(alarm,new Date('2030-02-03T14:05:00Z'));const units=writes.map(item=>item[1]).join('\n');expect(units).toContain('2030-02-03 14:05:00 UTC');expect(units).toContain('/node%%p');expect(units).toContain('RADIOCLI_HOME=/data%%u'); }); + it('disables systemd command expansion while preserving literal environment values',async()=>{ + const writes:Array<[string,string]>=[]; + const adapter=createSchedulerAdapter({platform:'linux',home:'/home/a',nodePath:'/Node $ ${CUSTOM} % 单播/node',cliPath:'/app-${CUSTOM}/% spaced 单播/cli.js',env:{RADIOCLI_HOME:'/data $ ${CUSTOM} % 单播'},commandExists:()=>true,writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); + const service=writes.find(([path])=>path.endsWith('.service'))![1]; + expect(service).toContain('ExecStart=:"/Node $ ${CUSTOM} %% 单播/node" "/app-${CUSTOM}/%% spaced 单播/cli.js"'); + expect(service).toContain('Environment="RADIOCLI_HOME=/data $ ${CUSTOM} %% 单播"'); + expect(service).not.toContain('$$'); + }); + + it.each(['darwin','linux'] as const)('preserves explicit player locations and network policy without proxy secrets in %s jobs',async platform=>{ + const writes:Array<[string,string]>=[]; + const configured={RADIOCLI_MPV_PATH:'/Players A/mpv',RADIOCLI_FFPLAY_PATH:'/Players A/ffplay',RADIOCLI_VLC_PATH:'/Players A/vlc',RADIOCLI_FFMPEG_PATH:'/Players A/ffmpeg',RADIOCLI_OFFLINE:'0',RADIOCLI_LOW_BANDWIDTH:'1'}; + const adapter=createSchedulerAdapter({platform,home:'/home/a',nodePath:'/node',cliPath:'/cli.js',env:{...configured,HTTPS_PROXY:'https://proxy-user:private-password@proxy.invalid',ARBITRARY_SECRET:'private-secret'},commandExists:()=>true,writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); + const artifact=writes.map(([,body])=>body).join('\n'); + for(const [key,value] of Object.entries(configured))expect(artifact).toContain(platform==='darwin'?`${key}${value}`:`Environment="${key}=${value}"`); + expect(artifact).not.toMatch(/proxy-user|private-password|private-secret|HTTPS_PROXY|ARBITRARY_SECRET/); + }); + + it.each(['darwin','linux','win32'] as const)('rejects control characters in an approved %s job environment before writing artifacts',async platform=>{ + const writeFile=vi.fn();const adapter=createSchedulerAdapter({platform,home:'/home/a',nodePath:'/node',cliPath:'/cli.js',env:{RADIOCLI_MPV_PATH:'/tools/mpv\nInjected=value'},commandExists:()=>true,writeFile,removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await expect(adapter.install(alarm,new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/RADIOCLI_MPV_PATH.*control/i);expect(writeFile).not.toHaveBeenCalled(); + }); + it('removes disabled alarms and reschedules enabled alarms', async () => { const adapter = {capabilities: vi.fn(() => ({supported: true, exactWake: false, catchUpAfterWake: true, message: 'ok'})), install: vi.fn(), remove: vi.fn(), status: vi.fn()}; const service = new SchedulerService(adapter, () => new Date('2029-01-01T00:00:00Z'), {record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[])} as never); diff --git a/src/alarms/scheduler.ts b/src/alarms/scheduler.ts index 21c30c0..435cce7 100644 --- a/src/alarms/scheduler.ts +++ b/src/alarms/scheduler.ts @@ -12,6 +12,8 @@ import {AlarmPowerGuardStore} from './power-guard-store.js'; import {detectAlarmTerminal} from './terminal-launcher.js'; import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; import {resolveCommandDetails} from '../player/command.js'; +import {launchEnvironment, nodeLaunchCommand} from '../platform/launch-command.js'; +import {powershellCommand} from '../platform/shell.js'; export type SchedulerCapabilities = { name?: string; @@ -108,17 +110,16 @@ export function createSchedulerAdapter(deps: SchedulerDeps = {}): SchedulerAdapt const common = {home, nodePath, cliPath, env, terminal: detectAlarmTerminal(platform, env), write, removeFile, run}; if (policy.scheduler === 'launchd') return launchdAdapter(common); if (policy.scheduler === 'task-scheduler') return windowsAdapter(common); - if (policy.scheduler === 'systemd' && commandExists('systemctl')) return systemdAdapter(common); - return unsupportedAdapter(policy.scheduler === 'systemd' - ? 'User scheduling requires systemd. This Linux session is unsupported; RadioCLI will not claim the alarm is registered.' - : `Alarm scheduling is not supported on ${host.id==='unknown'?host.platform:host.id}.`); + if (policy.scheduler === 'systemd') { + if (commandExists('systemctl')) return systemdAdapter(common); + return unavailableAdapter('systemctl is unavailable; systemd job registration and removal cannot be verified. Repair artifacts were retained.'); + } + return unsupportedAdapter(`Alarm scheduling is not supported on ${host.id==='unknown'?host.platform:host.id}.`); } type Common = {home: string; nodePath: string; cliPath: string; env: NodeJS.ProcessEnv; terminal:string; write: (p:string,c:string)=>void; removeFile:(p:string)=>void; run:(c:string,a:string[])=>Promise}; function runtimeEnvironment(common:Common):Record{ - const result:Record={RADIOCLI_ALARM_TERMINAL:common.terminal}; - for(const key of ['RADIOCLI_HOME','DISPLAY','WAYLAND_DISPLAY','DBUS_SESSION_BUS_ADDRESS','XDG_RUNTIME_DIR']){const value=common.env[key];if(value)result[key]=value;} - return result; + return launchEnvironment(common.env, {includeDesktop: true, terminal: common.terminal}); } const jobName = (id: string) => `io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`; const invocationArgs = (common: Common, id: string, occurrence: Date,internalCommand='internal-run') => [common.cliPath, 'alarm', internalCommand, id, occurrence.toISOString()]; @@ -167,7 +168,7 @@ function systemdAdapter(common: Common): SchedulerAdapter { return { capabilities, probeCapabilities, - async install(alarm, occurrence) { const readiness=await probeCapabilities();if(!readiness.supported)throw new Error(readiness.message);const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=[common.nodePath,...invocationArgs(common,alarm.id,occurrence)].map(systemdQuote).join(' '); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, + async install(alarm, occurrence) { const readiness=await probeCapabilities();if(!readiness.supported)throw new Error(readiness.message);const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=systemdExecCommand([common.nodePath,...invocationArgs(common,alarm.id,occurrence)]); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, async remove(id){ const n=names(id); await removeWithVerification(()=>common.run('systemctl',['--user','disable','--now',n.timer]),()=>common.run('systemctl',['--user','show',n.timer,'--property=LoadState','--property=ActiveState']),result=>/^LoadState=not-found\r?$/m.test(result.stdout)&&/^ActiveState=inactive\r?$/m.test(result.stdout),'systemd timer'); @@ -182,13 +183,27 @@ function windowsAdapter(common: Common): SchedulerAdapter { const xmlPath=(id:string)=>join(common.env.LOCALAPPDATA??join(common.home,'AppData','Local'),'RadioCLI','scheduler',`${jobName(id)}.xml`); return { capabilities:()=>({name:'Task Scheduler',supported:true,exactWake:false,catchUpAfterWake:true,message:'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.'}), - async install(alarm,occurrence){const name=`\\RadioCLI\\${jobName(alarm.id)}`;const command=windowsCommandLine(common.nodePath,invocationArgs(common,alarm.id,occurrence));const environment=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`set "${key}=${cmdEscape(value)}"`).join(' && ');const raw=`${environment} && ${command}`;const body=`${xml(alarm.label)}${windowsLocalBoundary(occurrence)}trueInteractiveTokenLeastPrivilegetrue${alarm.reliability.wakeIfSupported}falsefalsePT0SIgnoreNewcmd.exe/d /s /c "${xml(raw)}"`;const path=xmlPath(alarm.id);common.write(path,body);ensureSuccess(await common.run('schtasks.exe',['/Create','/TN',name,'/XML',path,'/F']),'schtasks create');}, + async install(alarm, occurrence) { + const name = `\\RadioCLI\\${jobName(alarm.id)}`; + const direct = nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence), runtimeEnvironment(common)); + // Task Scheduler expands %NAME% in Path and Arguments itself. Only its + // standard PowerShell path and fixed flags/encoded data cross that boundary. + const command = '%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + const args = powershellCommand(direct).join(' '); + const body = `${xml(alarm.label)}${windowsLocalBoundary(occurrence)}trueInteractiveTokenLeastPrivilegetrue${alarm.reliability.wakeIfSupported}falsefalsePT0SIgnoreNew${xml(command)}${xml(args)}`; + const path = xmlPath(alarm.id); + common.write(path, body); + ensureSuccess(await common.run('schtasks.exe', ['/Create', '/TN', name, '/XML', path, '/F']), 'schtasks create'); + }, async remove(id){const name=`\\RadioCLI\\${jobName(id)}`;await removeWithVerification(()=>common.run('schtasks.exe',['/Delete','/TN',name,'/F']),()=>common.run('schtasks.exe',['/Query','/TN',name]),result=>result.code===1&&/^ERROR: The system cannot find the (?:file|path) specified\.$/.test(`${result.stderr}\n${result.stdout}`.trim()),'Task Scheduler job');common.removeFile(xmlPath(id));}, async status(id){const result=await common.run('schtasks.exe',['/Query','/TN',`\\RadioCLI\\${jobName(id)}`]);const artifact=existsSync(xmlPath(id));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:result.code===0&&artifact,healthy:result.code===0&&artifact&&executable,message:result.code!==0?'task not registered':!artifact?'task XML artifact missing':!executable?'RadioCLI executable missing':'registered and executable'};} }; } function unsupportedAdapter(message:string):SchedulerAdapter{return{capabilities:()=>({supported:false,exactWake:false,catchUpAfterWake:false,message}),install:async()=>{throw new Error(message);},remove:async()=>{},status:async()=>({installed:false,healthy:false,message})};} +function unavailableAdapter(message: string): SchedulerAdapter { + return {...unsupportedAdapter(message), remove: async () => {throw new Error(message);}}; +} function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true,timeout:10_000});let stdout='';let stderr='';child.stdout.on('data',v=>stdout+=String(v));child.stderr.on('data',v=>stderr+=String(v));child.on('error',e=>resolve({code:127,stdout,stderr:e.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} async function boundedProbe(probe:Promise):Promise{let timer:NodeJS.Timeout|undefined;try{return await Promise.race([probe,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('User-manager probe timed out.')),3_000);})]);}finally{if(timer)clearTimeout(timer);}} async function removeWithVerification(remove:()=>Promise,query:()=>Promise,isAbsent:(result:CommandResult)=>boolean,label:string):Promise{ @@ -204,9 +219,11 @@ function commandMessage(result:CommandResult){return(result.stderr||result.stdou function ensureSuccess(result:CommandResult,label:string){if(result.code!==0)throw new Error(`${label} failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);} function xml(value:string){return value.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');} function systemdQuote(value:string){if(/[\r\n\0]/.test(value))throw new Error('Scheduler arguments cannot contain control characters.');return `"${value.replaceAll('%','%%').replaceAll('\\','\\\\').replaceAll('"','\\"')}"`;} -function cmdEscape(value:string){return value.replaceAll('%','%%').replaceAll('"','""').replaceAll('^','^^').replaceAll('&','^&').replaceAll('|','^|').replaceAll('<','^<').replaceAll('>','^>');} -function windowsCommandLine(command:string,args:string[]){return [command,...args].map(windowsArg).join(' ');} -function windowsArg(value:string){return `"${value.replace(/(\\*)"/g,'$1$1\\"').replace(/(\\+)$/,'$1$1')}"`;} +function systemdExecCommand(values: string[]): string { + // ':' disables $VAR/${VAR} expansion for ExecStart. Environment= has + // different syntax: its dollar signs are already literal and must stay so. + return `:${values.map(systemdQuote).join(' ')}`; +} function localParts(date:Date){return{year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};} function systemdCalendar(date:Date){return `${date.getUTCFullYear()}-${String(date.getUTCMonth()+1).padStart(2,'0')}-${String(date.getUTCDate()).padStart(2,'0')} ${String(date.getUTCHours()).padStart(2,'0')}:${String(date.getUTCMinutes()).padStart(2,'0')}:${String(date.getUTCSeconds()).padStart(2,'0')} UTC`;} function windowsLocalBoundary(date:Date){const p=localParts(date);return `${p.year}-${String(p.month).padStart(2,'0')}-${String(p.day).padStart(2,'0')}T${String(p.hour).padStart(2,'0')}:${String(p.minute).padStart(2,'0')}:${String(p.second).padStart(2,'0')}`;} diff --git a/src/platform/launch-command.ts b/src/platform/launch-command.ts new file mode 100644 index 0000000..646be8b --- /dev/null +++ b/src/platform/launch-command.ts @@ -0,0 +1,39 @@ +const applicationEnvironmentKeys = [ + 'RADIOCLI_HOME', + 'RADIOCLI_MPV_PATH', + 'RADIOCLI_FFPLAY_PATH', + 'RADIOCLI_VLC_PATH', + 'RADIOCLI_FFMPEG_PATH', + 'RADIOCLI_OFFLINE', + 'RADIOCLI_LOW_BANDWIDTH' +] as const; + +const desktopEnvironmentKeys = ['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR'] as const; + +/** Persist only application settings and the desktop connection a job needs. */ +export function launchEnvironment( + env: NodeJS.ProcessEnv, + options: {includeDesktop?: boolean; terminal?: string} = {} +): Record { + const result: Record = {}; + const add = (key: string, value: string | undefined) => { + if (value === undefined) return; + if (/[\r\n\0]/.test(value)) throw new Error(`${key} cannot contain control characters in a launch environment.`); + result[key] = value; + }; + for (const key of applicationEnvironmentKeys) add(key, env[key]); + if (options.includeDesktop) for (const key of desktopEnvironmentKeys) add(key, env[key]); + add('RADIOCLI_ALARM_TERMINAL', options.terminal); + return result; +} + +/** Keep application argv and approved environment out of native shell syntax. */ +export function nodeLaunchCommand(nodePath: string, args: readonly string[], environment: Record): string[] { + if ([nodePath, ...args, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) { + throw new Error('Launch command values cannot contain NUL bytes.'); + } + // This fixed program contains no double quotes, including after expansion. + // PowerShell 5 and Task Scheduler only receive this program and encoded data. + const program = "const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...process.env,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);"; + return [nodePath, '-e', program, Buffer.from(JSON.stringify({args, environment}), 'utf8').toString('base64url')]; +} diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts index 6cc32d0..390b4c2 100644 --- a/src/platform/terminals.test.ts +++ b/src/platform/terminals.test.ts @@ -1,17 +1,27 @@ import {execFileSync,type ChildProcess} from 'node:child_process'; import {EventEmitter} from 'node:events'; -import {mkdtempSync,mkdirSync,rmSync,writeFileSync} from 'node:fs'; +import {existsSync,mkdtempSync,mkdirSync,readFileSync,rmSync,writeFileSync} from 'node:fs'; import {basename,join,posix} from 'node:path'; import {afterEach,describe,expect,it,vi} from 'vitest'; -import {createTerminalLaunch,detectGraphicalTerminal,waitForLaunch} from './terminals.js'; +import {createTerminalLaunch,detectGraphicalTerminal,launchTerminalCommand,waitForLaunch} from './terminals.js'; const roots:string[]=[]; afterEach(()=>{vi.useRealTimers();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); const resolve=(command:string)=>command; function fixture(){const root=mkdtempSync(join(process.cwd(),'.radiocli-terminals-'));roots.push(root);return root;} function terminalPath(root:string,name:string){return posix.join(basename(root),name);} +function nodeInvocation(args: readonly string[]): {command: string; args: string[]} { + const index = args.indexOf('-EncodedCommand'); + expect(index).toBeGreaterThanOrEqual(0); + const script = Buffer.from(args[index + 1]!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(encoded).toBeDefined(); + const invocation = JSON.parse(Buffer.from(encoded!, 'base64').toString('utf8')) as {command: string; args: string[]}; + return invocation.args.includes('-EncodedCommand') ? nodeInvocation(invocation.args) : invocation; +} describe('shared graphical terminal discovery',()=>{ + it('ignores a malformed saved descriptor and continues normal terminal detection',()=>{expect(detectGraphicalTerminal('linux',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linuxX'},command=>command==='kitty'?'/usr/bin/kitty':undefined)).toBe('linux:/usr/bin/kitty');}); it.each(['linux','freebsd','openbsd','netbsd'] as const)('requires the central Unix adapter policy and an installed terminal on %s',platform=>{const env={DISPLAY:':0',TERMINAL:'xterm'};expect(detectGraphicalTerminal(platform,env,command=>command==='xterm'?'/usr/local/bin/xterm':undefined)).toBe(`${platform}:/usr/local/bin/xterm`);expect(detectGraphicalTerminal(platform,env,()=>undefined)).toBe(`${platform}:unsupported`);expect(detectGraphicalTerminal(platform,{...env,DISPLAY:''},resolve)).toBe(`${platform}:unsupported`);}); it.each(['freebsd','openbsd','netbsd'] as const)('accepts a legacy Linux descriptor after revalidating the saved Unix terminal on %s',platform=>{expect(detectGraphicalTerminal(platform,{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/local/bin/xterm'},resolve)).toBe('linux:/usr/local/bin/xterm');expect(detectGraphicalTerminal(platform,{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/local/bin/xterm'},()=>undefined)).toBe(`${platform}:unsupported`);}); it('requires an X11 or Wayland session before selecting a Unix terminal',()=>{expect(detectGraphicalTerminal('linux',{RADIOCLI_ALARM_TERMINAL:'linux:/bin/kitty'},resolve)).toBe('linux:unsupported');}); @@ -32,18 +42,40 @@ describe('graphical terminal invocation plans',()=>{ it('preserves a special data home and every argument through the Windows Node bootstrap',()=>{ const home='C:\\Data %PATH%! & "quote" \' 单播';const values=['space value','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home},nodePath:process.execPath,args:['-e',"process.stdout.write(JSON.stringify({args:process.argv.slice(1),home:process.env.RADIOCLI_HOME}))",'--',...values],resolve,closeOnExit:true}); - const script=Buffer.from(plan.args.at(-1)!,'base64').toString('utf16le');const payload=JSON.parse(Buffer.from(/FromBase64String\('([^']+)'\)/.exec(script)![1]!,'base64').toString('utf8')) as {command:string;args:string[]}; + const payload=nodeInvocation(plan.args); expect(payload.args[1]).not.toContain('"');const output=execFileSync(payload.command,payload.args,{encoding:'utf8'});expect(JSON.parse(output)).toEqual({args:values,home});expect(plan.command).toBe('powershell.exe'); }); - it.skipIf(process.platform!=='win32')('round-trips literal CLI paths and argv through native Windows PowerShell',()=>{ - const root=fixture();const cli=join(root,"Radio %PATH%! & ' 单播.cjs"); - // ASCII output separates exact argv transport from console code pages. - writeFileSync(cli,"process.stdout.write(Buffer.from(JSON.stringify({args:process.argv.slice(2),home:process.env.RADIOCLI_HOME}),'utf8').toString('base64'))"); - const home='C:\\Data %PATH%! & "quote" \' 单播';const args=['spaces here','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; - const plan=createTerminalLaunch({platform:'win32',env:{...process.env,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home},nodePath:process.execPath,args:[cli,...args],closeOnExit:true}); - const output=execFileSync(plan.command,plan.args,{encoding:'utf8',windowsHide:true,timeout:10_000}); - expect(JSON.parse(Buffer.from(output,'base64').toString('utf8'))).toEqual({args,home}); + it('requests a separate Windows console without inheriting detached launcher stdio',()=>{ + const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve,closeOnExit:true}); + const script=Buffer.from(plan.args.at(-1)!,'base64').toString('utf16le'); + expect(script).toContain('Start-Process');expect(script).toContain('-WindowStyle Normal');expect(script).not.toMatch(/-NoNewWindow|-RedirectStandard/); }); + it.skipIf(process.platform!=='win32')('opens real Windows console TTY handles and preserves literal argv and environment',async()=>{ + const root=fixture();const cli=join(root,"Radio %PATH%! & ' 单播.cjs");const output=join(root,'console-result.json'); + // File output observes the actual TTY child without redirecting its stdio. + writeFileSync(cli,"const fs=require('node:fs');const output=process.argv[2];fs.writeFileSync(output+'.tmp',JSON.stringify({stdin:process.stdin.isTTY===true,stdout:process.stdout.isTTY===true,stderr:process.stderr.isTTY===true,args:process.argv.slice(3),home:process.env.RADIOCLI_HOME,mpv:process.env.RADIOCLI_MPV_PATH}),'utf8');fs.renameSync(output+'.tmp',output)"); + const home='C:\\Data %PATH%! & "quote" \' 单播';const mpv="C:\\Players %PATH%! & ' 单播\\mpv.exe";const args=['spaces here','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; + await launchTerminalCommand({platform:'win32',env:{...process.env,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:[cli,output,...args],closeOnExit:true}); + const deadline=Date.now()+10_000; + while(!existsSync(output)&&Date.now()setTimeout(resolve,25)); + expect(existsSync(output)).toBe(true); + expect(JSON.parse(readFileSync(output,'utf8'))).toEqual({stdin:true,stdout:true,stderr:true,args,home,mpv}); + },15_000); + it.each([{platform:'linux' as const,terminal:'linux:/tools/kitty'},{platform:'darwin' as const,terminal:'darwin:wezterm'}])('preserves configured players and network policy through an existing $terminal server',({platform,terminal})=>{ + const configured={RADIOCLI_MPV_PATH:"/Players % ! & ' 单播/mpv",RADIOCLI_FFPLAY_PATH:'/Players A/ffplay',RADIOCLI_VLC_PATH:'/Players A/vlc',RADIOCLI_FFMPEG_PATH:'/Players A/ffmpeg',RADIOCLI_OFFLINE:'0',RADIOCLI_LOW_BANDWIDTH:'true'}; + const script=`process.stdout.write(JSON.stringify(Object.fromEntries(${JSON.stringify(Object.keys(configured))}.map(key=>[key,process.env[key]]))))`; + const plan=createTerminalLaunch({platform,env:{...configured,DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:terminal,HTTPS_PROXY:'https://private-password@proxy.invalid'},nodePath:process.execPath,args:['-e',script],resolve}); + const args=plan.args.slice(plan.args.indexOf(process.execPath)+1); + const output=execFileSync(process.execPath,args,{encoding:'utf8',env:{...process.env,...Object.fromEntries(Object.keys(configured).map(key=>[key,'stale-server-value']))}}); + expect(JSON.parse(output)).toEqual(configured); + expect(JSON.parse(Buffer.from(args.at(-1)!,'base64url').toString('utf8')).environment).toEqual(configured); + }); + it.skipIf(process.platform==='win32')('passes configured player paths literally through Apple Terminal shell commands',()=>{ + const mpv="/Players $ % ! & ' 单播/mpv"; + const plan=createTerminalLaunch({platform:'darwin',env:{RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:['-e','process.stdout.write(process.env.RADIOCLI_MPV_PATH)'],resolve}); + expect(execFileSync('/bin/sh',['-c',plan.args.at(-1)!],{encoding:'utf8',env:{...process.env,RADIOCLI_MPV_PATH:'stale'}})).toBe(mpv); + }); + it.each(['darwin','linux','win32'] as const)('rejects control characters in approved %s terminal environment values',platform=>{expect(()=>createTerminalLaunch({platform,env:{DISPLAY:':0',RADIOCLI_MPV_PATH:'/tools/mpv\nInjected=value'},nodePath:'/node',args:['/cli.js'],resolve})).toThrow(/RADIOCLI_MPV_PATH.*control/i);}); it('uses the same data-home bootstrap without assuming /usr/bin/env exists',()=>{const home="/Data A/O'Brien 单播";const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/tools/kitty',RADIOCLI_HOME:home},nodePath:process.execPath,args:['-e','process.stdout.write(process.env.RADIOCLI_HOME)'],resolve});expect(plan.args).not.toContain('/usr/bin/env');expect(execFileSync(process.execPath,plan.args.slice(2),{encoding:'utf8'})).toBe(home);}); it('fails before spawning when Windows has no runnable PowerShell',()=>{expect(()=>createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve:()=>undefined})).toThrow(/PowerShell.*unavailable/i);}); }); diff --git a/src/platform/terminals.ts b/src/platform/terminals.ts index 5760aac..5ddb770 100644 --- a/src/platform/terminals.ts +++ b/src/platform/terminals.ts @@ -3,6 +3,7 @@ import {posix,win32} from 'node:path'; import {resolveCommandDetails} from './executables.js'; import {identifyPlatform,nativeAdapters} from './runtime.js'; import {powershellCommand} from './shell.js'; +import {launchEnvironment, nodeLaunchCommand} from './launch-command.js'; export type TerminalResolver=(command:string)=>string|undefined; type TerminalSpawn=(command:string,args:readonly string[])=>ChildProcess; @@ -38,7 +39,7 @@ export function detectGraphicalTerminal(platform:NodeJS.Platform=process.platfor } if(adapter!=='unix'||!hasDisplay(env))return unsupported; if(configured){ - const separator=configured.indexOf(':');const prefix=configured.slice(0,separator); + const separator=configured.indexOf(':');const prefix=separator<0?'':configured.slice(0,separator); if(prefix===host.id||prefix==='linux'){ const path=resolveUnixTerminal(configured.slice(separator+1),env,resolve); return path?`${prefix}:${path}`:unsupported; @@ -55,16 +56,17 @@ export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ const platform=options.platform??process.platform;const env=options.env??process.env;const resolve=options.resolve??resolver(platform,env); const terminal=detectGraphicalTerminal(platform,env,resolve);const values=[options.nodePath,...options.args]; if(values.some(value=>value.includes('\0')))throw new Error('Terminal command values cannot contain NUL bytes.'); - const home=env.RADIOCLI_HOME; + const environment=launchEnvironment(env); if(terminal.endsWith(':unsupported')){ if(nativeAdapters(identifyPlatform({platform,env})).terminal==='unix'&&!hasDisplay(env))throw new Error('No graphical desktop session is available: DISPLAY and WAYLAND_DISPLAY are unset. Open radiocli manually for controls.'); throw new Error('No supported installed graphical terminal was found. Open radiocli manually for controls.'); } if(terminal==='darwin:apple-terminal'||terminal==='darwin:iterm'){ - const command=`${home!==undefined?`RADIOCLI_HOME=${shellQuote(home)} `:''}${values.map(shellQuote).join(' ')}${options.closeOnExit?'; exit':''}`; + const assignments=Object.entries(environment).map(([key,value])=>`${key}=${shellQuote(value)}`).join(' '); + const command=`${assignments?`${assignments} `:''}${values.map(shellQuote).join(' ')}${options.closeOnExit?'; exit':''}`; return{terminal,command:'/usr/bin/osascript',args:terminal==='darwin:apple-terminal'?appleTerminalScript(command):iTermScript(command)}; } - const direct=home===undefined?values:nodeBootstrap(options.nodePath,options.args,{RADIOCLI_HOME:home}); + const direct=Object.keys(environment).length?nodeLaunchCommand(options.nodePath,options.args,environment):values; if(terminal==='darwin:wezterm')return{terminal,command:'/usr/bin/open',args:['-na','WezTerm','--args','start','--always-new-process','--',...direct]}; if(terminal==='darwin:ghostty')return{terminal,command:'/usr/bin/open',args:['-na','Ghostty','--args','-e',...direct]}; if(terminal==='darwin:kitty')return{terminal,command:'/usr/bin/open',args:['-na','kitty','--args','--detach',...direct]}; @@ -74,9 +76,9 @@ export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ if(!powershell)throw new Error('PowerShell is unavailable; a RadioCLI terminal cannot be requested.'); // PowerShell 5 does not preserve arbitrary native argv quoting. Only a // fixed quote-free Node program and encoded JSON cross that boundary. - const command=nodeBootstrap(options.nodePath,options.args,home===undefined?{}:{RADIOCLI_HOME:home}); + const command=nodeLaunchCommand(options.nodePath,options.args,environment); const args=powershellCommand(command,{}, {keepOpen:!options.closeOnExit}); - if(terminal==='win32:console')return{terminal,command:powershell,args}; + if(terminal==='win32:console')return{terminal,command:powershell,args:newWindowsConsole(powershell,args)}; const wt=resolve('wt.exe')??(env.LOCALAPPDATA?resolve(win32.join(env.LOCALAPPDATA,'Microsoft','WindowsApps','wt.exe')):undefined); if(!wt)throw new Error('Windows Terminal is unavailable; the saved terminal cannot be requested.'); return{terminal,command:wt,args:['-w','new','new-tab','--title',options.title??'RadioCLI',powershell,...args]}; @@ -115,9 +117,17 @@ function resolveUnixTerminal(input:string,env:NodeJS.ProcessEnv,resolve:Terminal const path=resolve(input);return path&&unixTerminals.some(item=>item.name===posix.basename(path))?path:undefined; } function shellQuote(value:string):string{return`'${value.replaceAll("'",`'\\''`)}'`;} -function nodeBootstrap(nodePath:string,args:readonly string[],environment:Record):string[]{ - const program="const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...process.env,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);"; - return[nodePath,'-e',program,Buffer.from(JSON.stringify({args,environment}),'utf8').toString('base64url')]; +function newWindowsConsole(powershell: string, args: readonly string[]): string[] { + const payload = Buffer.from(JSON.stringify({command: powershell, args}), 'utf8').toString('base64'); + // Node's detached Windows launcher has NUL stdio. Start-Process must create + // a separate console without -NoNewWindow or any standard-stream redirection. + // Its ArgumentList joins values, so only fixed flags and encoded data go in it. + const script = [ + "$ErrorActionPreference='Stop'", + `$radiocliConsole=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json`, + 'Start-Process -FilePath ([string]$radiocliConsole.command) -ArgumentList ([string[]]$radiocliConsole.args) -WindowStyle Normal' + ].join(';'); + return ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]; } function appleTerminalScript(command:string):string[]{return['-e','on run argv','-e','tell application "Terminal"','-e','activate','-e','do script (item 1 of argv)','-e','end tell','-e','end run',command];} function iTermScript(command:string):string[]{return['-e','on run argv','-e','tell application "iTerm"','-e','activate','-e','set w to (create window with default profile)','-e','tell current session of w to write text (item 1 of argv)','-e','end tell','-e','end run',command];} From ecfc3eaaeb9780f3dc262b3f4523e3cd8d006d12 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:55:20 -0500 Subject: [PATCH 15/25] ci: verify packed playback on native CPUs and BSD guest kernels --- .github/workflows/ci.yml | 227 +++++++++++++++++- scripts/fresh-check.mjs | 91 ++----- scripts/install-smoke-mpv.mjs | 160 +++++++++++++ scripts/install-smoke-mpv.test-node.mjs | 57 +++++ scripts/packed-smoke.mjs | 302 ++++++++++++++++++++++++ scripts/packed-smoke.test-node.mjs | 75 ++++++ 6 files changed, 838 insertions(+), 74 deletions(-) create mode 100644 scripts/install-smoke-mpv.mjs create mode 100644 scripts/install-smoke-mpv.test-node.mjs create mode 100644 scripts/packed-smoke.mjs create mode 100644 scripts/packed-smoke.test-node.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22b7fc2..f02ecff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,13 @@ concurrency: on: pull_request: workflow_dispatch: + inputs: + experimental_bsd: + description: Run experimental packed-runtime checks in BSD guest VMs + type: boolean + default: false + schedule: + - cron: '37 7 * * 1' push: branches: - master @@ -18,6 +25,7 @@ on: jobs: verify: name: Verify + if: github.event_name != 'schedule' runs-on: ${{ matrix.os }} timeout-minutes: 25 strategy: @@ -25,6 +33,33 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] node-version: [22.x, 24.x] + # https://docs.github.com/en/actions/reference/runners/github-hosted-runners + # Keep the original six jobs, then add the missing CPU architectures. + include: + - os: ubuntu-latest + architecture: x64 + - os: macos-latest + architecture: arm64 + - os: windows-latest + architecture: x64 + - os: ubuntu-24.04-arm + node-version: 22.x + architecture: arm64 + - os: ubuntu-24.04-arm + node-version: 24.x + architecture: arm64 + - os: windows-11-arm + node-version: 22.x + architecture: arm64 + - os: windows-11-arm + node-version: 24.x + architecture: arm64 + - os: macos-15-intel + node-version: 22.x + architecture: x64 + - os: macos-15-intel + node-version: 24.x + architecture: x64 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -33,8 +68,14 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node-version }} + architecture: ${{ matrix.architecture }} cache: npm + - name: Verify native runtime identity + env: + RADIOCLI_EXPECTED_ARCH: ${{ matrix.architecture }} + run: node --input-type=module -e "import assert from 'node:assert/strict'; import os from 'node:os'; assert.equal(process.arch, process.env.RADIOCLI_EXPECTED_ARCH); console.log(JSON.stringify({method:'GitHub-hosted native OS/CPU VM',platform:process.platform,architecture:process.arch,kernel:os.release(),node:process.version}));" + - name: Install run: npm ci @@ -50,6 +91,9 @@ jobs: - name: Test run: npm run test + - name: Package smoke harness tests + run: node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs + - name: Build run: npm run build @@ -63,22 +107,49 @@ jobs: run: npm run check:package - name: Data smoke - if: github.event_name != 'pull_request' + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' run: npm run smoke:data + - name: Install native Linux playback fixture + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y mpv + + - name: Install pinned native macOS or Windows playback fixture + if: runner.os != 'Linux' + env: + RADIOCLI_SMOKE_EVIDENCE_DIR: packed-evidence + run: node scripts/install-smoke-mpv.mjs + - name: Fresh install smoke - if: github.event_name != 'pull_request' - run: npm run fresh:check + env: + RADIOCLI_EXPECTED_ARCH: ${{ matrix.architecture }} + RADIOCLI_SMOKE_EXECUTION_METHOD: GitHub-hosted native OS/CPU VM (${{ matrix.os }}) + RADIOCLI_SMOKE_EVIDENCE_DIR: packed-evidence + run: npm run fresh:check -- --require-mpv + + - name: Preserve packed runtime evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: packed-${{ matrix.os }}-${{ matrix.node-version }} + path: packed-evidence/*.json + if-no-files-found: warn playback-smoke: name: Playback Smoke - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' - timeout-minutes: 15 + runs-on: ${{ matrix.os }} + if: github.event_name != 'schedule' + timeout-minutes: 25 strategy: fail-fast: false matrix: + os: [ubuntu-latest, ubuntu-24.04-arm] node-version: [22.x, 24.x] + include: + - os: ubuntu-latest + architecture: x64 + - os: ubuntu-24.04-arm + architecture: arm64 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -87,6 +158,7 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node-version }} + architecture: ${{ matrix.architecture }} cache: npm - name: Install system playback backend @@ -98,8 +170,24 @@ jobs: - name: Playback smoke run: npm run smoke:playback + - name: Packed mpv playback and portable install smoke + env: + RADIOCLI_EXPECTED_ARCH: ${{ matrix.architecture }} + RADIOCLI_SMOKE_EXECUTION_METHOD: GitHub-hosted native Linux VM (${{ matrix.os }}), real mpv with null audio + RADIOCLI_SMOKE_EVIDENCE_DIR: playback-evidence + run: npm run fresh:check -- --require-mpv + + - name: Preserve playback evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playback-${{ matrix.os }}-${{ matrix.node-version }} + path: playback-evidence/*.json + if-no-files-found: warn + docs: name: Docs + if: github.event_name != 'schedule' runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -131,3 +219,130 @@ jobs: - name: Build working-directory: apps/docs run: npm run build + + # Experimental jobs run in full guest kernels, not Linux containers. They are + # opt-in/weekly and do not establish maintained or audible-playback support. + # VM action source is pinned; upstream release assets/images are maintained + # separately by vmactions. The selected guest versions and actual runtimes are logged. + experimental-package: + name: Build experimental portability artifact + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.experimental_bsd) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup build Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.x + cache: npm + + - name: Install build dependencies + run: npm ci --omit=optional + + - name: Build the npm artifact for guest installation + run: | + mkdir -p portability-artifact + npm pack --pack-destination portability-artifact + cp scripts/packed-smoke.mjs portability-artifact/ + sha256sum portability-artifact/*.tgz + + - name: Preserve the built package and standalone harness + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: portability-package + path: portability-artifact/ + if-no-files-found: error + + experimental-freebsd: + name: Experimental FreeBSD 14.4 x64 guest / Node 22 + needs: experimental-package + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Download the Linux-built npm package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: portability-package + path: portability-artifact + + - name: Install and exercise JavaScript in FreeBSD + uses: vmactions/freebsd-vm@f0552d3b69211736abd97f02ff3d4674c56b73b1 # v1.5.5 + with: + release: '14.4' + arch: x86_64 + copyback: false + prepare: pkg install -y node22 npm-node22 mpv + run: | + set -eu + uname -a + node --version + npm --version + cd portability-artifact + set -- ./*.tgz + test "$#" -eq 1 + node packed-smoke.mjs "$1" --omit-optional --require-mpv --execution-method 'QEMU FreeBSD 14.4 x86_64 guest kernel; null audio' + + experimental-openbsd: + name: Experimental OpenBSD 7.9 x64 guest / packaged Node + needs: experimental-package + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Download the Linux-built npm package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: portability-package + path: portability-artifact + + - name: Install and exercise JavaScript in OpenBSD + uses: vmactions/openbsd-vm@86cdc08415d9d0865267e686561e276c52d62530 # v1.4.7 + with: + release: '7.9' + arch: x86_64 + copyback: false + prepare: pkg_add -I node mpv + run: | + set -eu + uname -a + node --version + npm --version + cd portability-artifact + set -- ./*.tgz + test "$#" -eq 1 + node packed-smoke.mjs "$1" --omit-optional --require-mpv --execution-method 'QEMU OpenBSD 7.9 x86_64 guest kernel; null audio' + + experimental-netbsd: + name: Experimental NetBSD 10.1 x64 guest / Node 22 + needs: experimental-package + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Download the Linux-built npm package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: portability-package + path: portability-artifact + + - name: Install and exercise JavaScript in NetBSD + uses: vmactions/netbsd-vm@7f23a491dcc69c34195721ab67934917707f84c7 # v1.4.9 + with: + release: '10.1' + arch: x86_64 + copyback: false + prepare: | + export PATH=/usr/pkg/bin:/usr/pkg/sbin:$PATH + pkgin -y update + pkgin -y install 'nodejs>=22<23' mpv + run: | + set -eu + export PATH=/usr/pkg/bin:/usr/pkg/sbin:$PATH + uname -a + node --version + npm --version + cd portability-artifact + set -- ./*.tgz + test "$#" -eq 1 + node packed-smoke.mjs "$1" --omit-optional --require-mpv --execution-method 'QEMU NetBSD 10.1 x86_64 guest kernel; null audio' diff --git a/scripts/fresh-check.mjs b/scripts/fresh-check.mjs index 5287656..a37ac75 100755 --- a/scripts/fresh-check.mjs +++ b/scripts/fresh-check.mjs @@ -1,79 +1,34 @@ #!/usr/bin/env node -import {existsSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdtempSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join, resolve} from 'node:path'; -import {spawnSync} from 'node:child_process'; -import process from 'node:process'; import {fileURLToPath} from 'node:url'; +import {npmCommand, runCommand, runPackedSmoke, smokeEnvironment} from './packed-smoke.mjs'; const root = resolve(fileURLToPath(new URL('..', import.meta.url))); -const tempProject = mkdtempSync(join(tmpdir(), 'radiocli-fresh-')); -const tempStore = mkdtempSync(join(tmpdir(), 'radiocli-store-')); -let packedTarball; +const args = process.argv.slice(2); +if (args.some(arg => arg !== '--require-mpv')) throw new Error('Usage: npm run fresh:check [-- --require-mpv]'); +const temporary = mkdtempSync(join(tmpdir(), 'radiocli-pack-')); try { - if (existsSync(join(root, 'tsconfig.build.json'))) { - run('npm', ['run', 'build', '--silent'], {cwd: root}); + const npm = npmCommand(); + const env = smokeEnvironment(join(temporary, 'state')); + // npm pack runs the normal prepack build, and writes only into a temp folder. + const packed = runCommand(npm.command, [...npm.args, 'pack', '--json', '--dry-run=false', '--pack-destination', temporary], {cwd: root, env, timeout: 180_000}); + const info = JSON.parse(packed.stdout); + if (info.length !== 1 || !info[0]?.filename) throw new Error('npm pack did not return exactly one package.'); + const tarball = join(temporary, info[0].filename); + if (!existsSync(tarball)) throw new Error(`npm pack did not create ${tarball}`); + const evidenceRoot = process.env.RADIOCLI_SMOKE_EVIDENCE_DIR; + for (const omitOptional of [false, true]) { + await runPackedSmoke(tarball, { + omitOptional, + requireMpv: args.includes('--require-mpv'), + executionMethod: process.env.RADIOCLI_SMOKE_EXECUTION_METHOD ?? 'local process', + evidencePath: evidenceRoot ? join(evidenceRoot, `${omitOptional ? 'omit-optional' : 'normal'}.json`) : undefined + }); } - - const pack = run('npm', ['pack', '--json', '--dry-run=false'], {cwd: root, env: freshEnv()}); - const packageInfo = JSON.parse(pack.stdout)[0]; - packedTarball = resolve(root, packageInfo.filename); - - if (!existsSync(packedTarball)) { - throw new Error(`npm pack did not create ${packedTarball}`); - } - - writeFileSync(join(tempProject, 'package.json'), '{"private":true,"type":"module"}\n', 'utf8'); - run('npm', ['install', '--no-audit', '--fund=false', packedTarball], {cwd: tempProject, env: freshEnv(), timeout: 180_000}); - - const bin = process.platform === 'win32' - ? join(tempProject, 'node_modules', '.bin', 'radiocli.cmd') - : join(tempProject, 'node_modules', '.bin', 'radiocli'); - - run(bin, ['help'], {cwd: tempProject, env: freshEnv(), timeout: 30_000}); - const check = run(bin, ['check'], {cwd: tempProject, env: freshEnv(), timeout: 45_000}); - - process.stdout.write(check.stdout); - console.log('fresh_check=ok'); + console.log('fresh_check=ok installs=normal,omit-optional'); } finally { - if (packedTarball) { - rmSync(packedTarball, {force: true}); - } - - rmSync(tempProject, {force: true, recursive: true}); - rmSync(tempStore, {force: true, recursive: true}); -} - -function freshEnv() { - const env = { - ...process.env, - RADIOCLI_HOME: tempStore, - RADIOCLI_DISABLE_ANIMATION: '1' - }; - delete env.npm_config_dry_run; - delete env.NPM_CONFIG_DRY_RUN; - return env; -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd ?? root, - env: options.env ?? process.env, - encoding: 'utf8', - shell: process.platform === 'win32', - timeout: options.timeout ?? 120_000 - }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - process.stderr.write(result.stdout ?? ''); - process.stderr.write(result.stderr ?? ''); - throw new Error(`${command} ${args.join(' ')} exited with ${result.status}`); - } - - return result; + rmSync(temporary, {force: true, recursive: true}); } diff --git a/scripts/install-smoke-mpv.mjs b/scripts/install-smoke-mpv.mjs new file mode 100644 index 0000000..02faf2a --- /dev/null +++ b/scripts/install-smoke-mpv.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {runCommand} from './packed-smoke.mjs'; + +// CI-only release fixtures, verified against the release API on 2026-09-07. +// https://github.com/mpv-player/mpv/releases/tag/v0.41.0 +// Keep hashes fixed: a replaced release asset must fail rather than change CI silently. +const assets = { + 'darwin-x64': {name: 'mpv-v0.41.0-macos-15-intel.zip', sha256: '41003617ab4f7784394b5ddea7ce51b3e0838e8cfc8166ad1a378b2eda3b583c'}, + 'darwin-arm64': {name: 'mpv-v0.41.0-macos-26-arm.zip', sha256: '09820c0d84f6687446b84eb9df81fcf6a26ebe869cee58ea1857d7948cfb7c71'}, + 'win32-x64': {name: 'mpv-v0.41.0-x86_64-pc-windows-msvc.zip', sha256: '4e197f729f5071c6772f35fffd96e0f36e3e8a044bd9479b136bb09b7c6a80ff'}, + 'win32-arm64': {name: 'mpv-v0.41.0-aarch64-pc-windows-msvc.zip', sha256: 'a822abeffd0ac88951f4084f3425f949842aa17d616f880637ebe9041e482e97'} +}; + +export function selectMpvAsset(platform, architecture) { + const asset = assets[`${platform}-${architecture}`]; + if (!asset) throw new Error(`No pinned native mpv smoke asset for ${platform}/${architecture}.`); + return {...asset, url: `https://github.com/mpv-player/mpv/releases/download/v0.41.0/${asset.name}`}; +} + +export function validateArchivePaths(paths) { + for (const path of paths) { + const normalized = path.replaceAll('\\', '/'); + assert.ok(!/[\0\r\n]/.test(normalized) && !normalized.startsWith('/') && !/^[a-z]:/i.test(normalized) && !normalized.split('/').includes('..'), `Unsafe archive path: ${JSON.stringify(path)}`); + } +} + +function zipEntryNames(bytes) { + const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + assert.ok(end >= 0 && end + 22 <= bytes.length, 'Invalid ZIP directory.'); + const count = bytes.readUInt16LE(end + 10); + let offset = bytes.readUInt32LE(end + 16); + const names = []; + for (let index = 0; index < count; index++) { + assert.ok(offset + 46 <= bytes.length && bytes.readUInt32LE(offset) === 0x02014b50, 'Invalid ZIP entry.'); + const nameLength = bytes.readUInt16LE(offset + 28); + const next = offset + 46 + nameLength + bytes.readUInt16LE(offset + 30) + bytes.readUInt16LE(offset + 32); + assert.ok(next <= bytes.length, 'Truncated ZIP entry.'); + names.push(bytes.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')); + offset = next; + } + assert.ok(names.length > 0, 'The pinned ZIP must contain files.'); + return names; +} + +/** Inspect the executable before running it; emulated x64 is not ARM64 evidence. */ +export function verifyNativeBinary(bytes, platform, architecture) { + if (platform === 'win32') { + assert.ok(bytes.length >= 64 && bytes.toString('ascii', 0, 2) === 'MZ', 'Invalid Windows PE header.'); + const offset = bytes.readUInt32LE(0x3c); + assert.ok(offset + 6 <= bytes.length && bytes.readUInt32LE(offset) === 0x00004550, 'Invalid Windows PE header.'); + const cpu = ({[0x8664]: 'x64', [0xaa64]: 'arm64'})[bytes.readUInt16LE(offset + 4)]; + assert.equal(cpu, architecture, 'mpv executable architecture does not match Node and the runner.'); + return {architectures: [cpu]}; + } + assert.ok(platform === 'darwin' && bytes.length >= 32, 'Unknown executable format.'); + const cpuName = cpu => ({[0x01000007]: 'x64', [0x0100000c]: 'arm64'})[cpu]; + let architectures; + if (bytes.readUInt32BE(0) === 0xcafebabe) { + const slices = []; + for (let index = 0; index < bytes.readUInt32BE(4); index++) { + const offset = 8 + index * 20; + assert.ok(offset + 20 <= bytes.length, 'Truncated universal Mach-O header.'); + slices.push({architecture: cpuName(bytes.readUInt32BE(offset)), offset: bytes.readUInt32BE(offset + 8), size: bytes.readUInt32BE(offset + 12)}); + } + architectures = slices.map(slice => slice.architecture).filter(Boolean); + const selected = slices.find(slice => slice.architecture === architecture); + assert.ok(selected && selected.offset + selected.size <= bytes.length, 'mpv executable architecture does not match the runner.'); + bytes = bytes.subarray(selected.offset, selected.offset + selected.size); + } + assert.ok(bytes.length >= 32 && bytes.readUInt32LE(0) === 0xfeedfacf, 'Invalid 64-bit Mach-O header.'); + const cpu = cpuName(bytes.readUInt32LE(4)); + assert.equal(cpu, architecture, 'mpv executable architecture does not match Node and the runner.'); + let minimumMacos; + let offset = 32; + for (let index = 0; index < bytes.readUInt32LE(16); index++) { + assert.ok(offset + 8 <= bytes.length, 'Truncated Mach-O command.'); + const command = bytes.readUInt32LE(offset); + const size = bytes.readUInt32LE(offset + 4); + assert.ok(size >= 8 && offset + size <= bytes.length, 'Invalid Mach-O command.'); + const versionOffset = command === 0x32 && size >= 24 ? 12 : command === 0x24 && size >= 16 ? 8 : null; + if (versionOffset !== null) { + const version = bytes.readUInt32LE(offset + versionOffset); + minimumMacos = `${version >>> 16}.${(version >>> 8) & 0xff}.${version & 0xff}`; + } + offset += size; + } + return {architectures: architectures ?? [cpu], ...(minimumMacos ? {minimumMacos} : {})}; +} + +function versionAtLeast(actual, minimum) { + const left = actual.split('.').map(Number); + const right = minimum.split('.').map(Number); + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference) return difference > 0; + } + return true; +} + +async function install() { + const asset = selectMpvAsset(process.platform, process.arch); + const root = mkdtempSync(join(process.env.RUNNER_TEMP ?? tmpdir(), 'radiocli-ci-mpv-')); + const extracted = join(root, 'unpacked'); + mkdirSync(extracted); + let completed = false; + try { + let bytes; + if (process.env.RADIOCLI_SMOKE_MPV_ARCHIVE) bytes = readFileSync(process.env.RADIOCLI_SMOKE_MPV_ARCHIVE); + else { + const response = await fetch(asset.url, {signal: AbortSignal.timeout(120_000)}); + assert.ok(response.ok, `mpv asset download failed: HTTP ${response.status}.`); + bytes = Buffer.from(await response.arrayBuffer()); + } + assert.equal(createHash('sha256').update(bytes).digest('hex'), asset.sha256, 'mpv release archive SHA256 mismatch.'); + validateArchivePaths(zipEntryNames(bytes)); + const archive = join(root, asset.name); + writeFileSync(archive, bytes); + let executable; + let macos; + if (process.platform === 'darwin') { + runCommand('/usr/bin/ditto', ['-x', '-k', archive, extracted]); + const packed = join(extracted, 'mpv.tar.gz'); + validateArchivePaths(runCommand('/usr/bin/tar', ['-tzf', packed]).stdout.split('\n').filter(Boolean)); + runCommand('/usr/bin/tar', ['-xzf', packed, '-C', extracted]); + executable = join(extracted, 'mpv.app', 'Contents', 'MacOS', 'mpv'); + macos = runCommand('/usr/bin/sw_vers', ['-productVersion']).stdout.trim(); + } else { + // Fixed PowerShell code. Neither archive path is interpolated into code. + runCommand('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'Expand-Archive -LiteralPath $env:RADIOCLI_MPV_ARCHIVE -DestinationPath $env:RADIOCLI_MPV_EXTRACT -Force'], { + env: {...process.env, RADIOCLI_MPV_ARCHIVE: archive, RADIOCLI_MPV_EXTRACT: extracted} + }); + executable = join(extracted, 'mpv.exe'); + } + const binary = verifyNativeBinary(readFileSync(executable), process.platform, process.arch); + if (binary.minimumMacos) assert.ok(versionAtLeast(macos, binary.minimumMacos), `mpv requires macOS ${binary.minimumMacos}; this runner has ${macos}.`); + const version = runCommand(executable, ['--version']).stdout.split('\n')[0]; + const evidence = {asset: asset.url, sha256: asset.sha256, platform: process.platform, architecture: process.arch, ...binary, ...(macos ? {macos} : {}), version, executable}; + if (process.env.GITHUB_PATH) appendFileSync(process.env.GITHUB_PATH, `${dirname(executable)}\n`); + if (process.env.GITHUB_ENV) appendFileSync(process.env.GITHUB_ENV, `RADIOCLI_MPV_PATH=${executable}\n`); + if (process.env.RADIOCLI_SMOKE_EVIDENCE_DIR) { + mkdirSync(process.env.RADIOCLI_SMOKE_EVIDENCE_DIR, {recursive: true}); + writeFileSync(join(process.env.RADIOCLI_SMOKE_EVIDENCE_DIR, 'mpv-asset.json'), `${JSON.stringify(evidence, null, 2)}\n`); + } + console.log(`mpv_asset=${JSON.stringify(evidence)}`); + completed = true; + } finally { + // Successful installs live only in runner temp for subsequent smoke steps. + if (!completed) rmSync(root, {recursive: true, force: true}); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await install().catch(error => { console.error(error.stack ?? error.message); process.exitCode = 1; }); +} diff --git a/scripts/install-smoke-mpv.test-node.mjs b/scripts/install-smoke-mpv.test-node.mjs new file mode 100644 index 0000000..c2aa0dd --- /dev/null +++ b/scripts/install-smoke-mpv.test-node.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +async function helpers() { + const module = await import('./install-smoke-mpv.mjs').catch(() => ({})); + assert.equal(typeof module.selectMpvAsset, 'function', 'CI must select a pinned native mpv asset before downloading'); + return module; +} + +test('Windows ARM selects an aarch64 build and rejects unsupported architectures', async () => { + const {selectMpvAsset} = await helpers(); + assert.match(selectMpvAsset('win32', 'arm64').name, /aarch64/); + assert.match(selectMpvAsset('win32', 'x64').name, /x86_64/); + assert.throws(() => selectMpvAsset('win32', 'ia32'), /No pinned native mpv/); + assert.throws(() => selectMpvAsset('unknown', 'arm64'), /No pinned native mpv/); +}); + +test('a Windows x64 executable cannot satisfy ARM64 playback evidence', async () => { + const {verifyNativeBinary} = await helpers(); + const binary = Buffer.alloc(128); + binary.write('MZ'); + binary.writeUInt32LE(64, 0x3c); + binary.write('PE\0\0', 64); + binary.writeUInt16LE(0x8664, 68); + assert.throws(() => verifyNativeBinary(binary, 'win32', 'arm64'), /architecture/); + binary.writeUInt16LE(0xaa64, 68); + assert.deepEqual(verifyNativeBinary(binary, 'win32', 'arm64').architectures, ['arm64']); +}); + +test('Mach-O inspection verifies CPU and records its declared minimum macOS', async () => { + const {verifyNativeBinary} = await helpers(); + const binary = Buffer.alloc(56); + binary.writeUInt32LE(0xfeedfacf, 0); + binary.writeUInt32LE(0x01000007, 4); + binary.writeUInt32LE(1, 16); + binary.writeUInt32LE(24, 20); + binary.writeUInt32LE(0x32, 32); + binary.writeUInt32LE(24, 36); + binary.writeUInt32LE(1, 40); + binary.writeUInt32LE(0x000d0500, 44); + assert.deepEqual(verifyNativeBinary(binary, 'darwin', 'x64'), {architectures: ['x64'], minimumMacos: '13.5.0'}); + assert.throws(() => verifyNativeBinary(binary, 'darwin', 'arm64'), /architecture/); +}); + +test('archive paths cannot leave the isolated extraction directory', async () => { + const {validateArchivePaths} = await helpers(); + validateArchivePaths(['mpv.exe', 'mpv.app/Contents/MacOS/mpv']); + for (const path of ['../mpv.exe', '/tmp/mpv', 'C:\\mpv.exe', 'a/../../mpv', 'a\\..\\mpv']) { + assert.throws(() => validateArchivePaths([path]), /Unsafe archive path/); + } +}); + +test('unknown binary formats fail before executing downloaded code', async () => { + const {verifyNativeBinary} = await helpers(); + assert.throws(() => verifyNativeBinary(Buffer.from('#!/bin/sh'), 'win32', 'arm64'), /PE|format|header/); + assert.throws(() => verifyNativeBinary(Buffer.alloc(64), 'darwin', 'x64'), /Mach-O|format|header/); +}); diff --git a/scripts/packed-smoke.mjs b/scripts/packed-smoke.mjs new file mode 100644 index 0000000..d38c1f6 --- /dev/null +++ b/scripts/packed-smoke.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import {spawn, spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {arch, endianness, release, tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath, pathToFileURL} from 'node:url'; +import {setTimeout as delay} from 'node:timers/promises'; + +const script = fileURLToPath(import.meta.url); + +/** npm.cmd is a shell program; execute npm's JS entry with the selected Node. */ +export function npmCommand({execPath = process.execPath, platform = process.platform, env = process.env} = {}) { + const candidates = [env.npm_execpath, + join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + resolve(dirname(execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')]; + const path = Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] ?? ''; + for (const directory of path.split(platform === 'win32' ? ';' : ':').filter(Boolean)) { + candidates.push(join(directory, 'npm'), join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js')); + } + for (const candidate of candidates) { + if (!candidate || !existsSync(candidate)) continue; + const entry = realpathSync(candidate); + if (entry.endsWith('npm-cli.js')) return {command: execPath, args: [entry]}; + } + throw new Error('Cannot locate npm-cli.js. Install npm alongside Node.js or run this script through npm.'); +} + +export function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, env: options.env ?? process.env, encoding: 'utf8', shell: false, + windowsHide: true, timeout: options.timeout ?? 120_000, maxBuffer: 16 * 1024 * 1024 + }); + if (result.error || result.status !== (options.expectedStatus ?? 0)) { + throw new Error(`${command} failed: ${result.error?.message ?? `exited with ${result.status}`}\n${result.stdout ?? ''}${result.stderr ?? ''}`); + } + return result; +} + +export function smokeEnvironment(home) { + const env = {...process.env, RADIOCLI_HOME: home, RADIOCLI_OFFLINE: '1', RADIOCLI_DISABLE_ANIMATION: '1', RADIOCLI_MPV_AUDIO_OUTPUT: 'null', NO_COLOR: '1'}; + for (const key of Object.keys(env)) { + if (['node_path', 'node_options', 'npm_config_dry_run', 'npm_config_omit', 'npm_config_include', 'npm_config_ignore_scripts'].includes(key.toLowerCase())) delete env[key]; + } + return env; +} + +/** Imports only the installed tarball; never the checkout's dist or node_modules. */ +export async function runPackedSmoke(tarball, {omitOptional = false, requireMpv = false, executionMethod = 'local process', evidencePath} = {}) { + tarball = resolve(tarball); + assert.ok(existsSync(tarball), `Packed tarball is missing: ${tarball}`); + assert.ok(Number(process.versions.node.split('.')[0]) >= 22, 'Packed runtime checks require Node.js 22 or newer.'); + if (process.env.RADIOCLI_EXPECTED_ARCH) assert.equal(process.arch, process.env.RADIOCLI_EXPECTED_ARCH, 'The packed runtime must match the requested native runner architecture.'); + const temporary = mkdtempSync(join(tmpdir(), 'radiocli packed & 日本 ')); + const project = join(temporary, 'project'); + const home = join(temporary, 'state'); + mkdirSync(project); + mkdirSync(home); + const env = smokeEnvironment(home); + const networkLog = join(temporary, 'unexpected-network.log'); + const guard = join(temporary, 'network-guard.mjs'); + writeFileSync(guard, `import {appendFileSync} from 'node:fs';\nglobalThis.fetch = () => { appendFileSync(process.env.RADIOCLI_SMOKE_NETWORK_LOG, 'fetch attempted\\n'); return Promise.reject(new Error('Packed smoke forbids live network requests.')); };\n`); + env.RADIOCLI_SMOKE_NETWORK_LOG = networkLog; + try { + writeFileSync(join(project, 'package.json'), '{"private":true,"type":"module"}\n'); + const npm = npmCommand(); + console.log(`packed_install=${omitOptional ? 'omit-optional' : 'normal'} method=${executionMethod}`); + runCommand(npm.command, [...npm.args, 'install', '--no-audit', '--fund=false', '--omit=dev', '--ignore-scripts=false', + ...(omitOptional ? ['--omit=optional'] : ['--include=optional']), tarball], {cwd: project, env, timeout: 300_000}); + const packageRoot = join(project, 'node_modules', '@ciphore', 'radiocli'); + const metadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); + const cli = join(packageRoot, metadata.bin.radiocli); + assert.ok(existsSync(cli), 'The installed package is missing its declared CLI entry.'); + if (omitOptional) assert.equal(existsSync(join(project, 'node_modules', 'node-airtunes2')), false, 'The portable install unexpectedly installed the optional native sender.'); + const runCli = (args, options = {}) => runCommand(process.execPath, ['--import', guard, cli, ...args], {cwd: project, env, timeout: 45_000, ...options}); + assert.match(runCli(['help']).stdout, /Usage:/); + assert.equal(runCli(['version']).stdout.trim(), metadata.version); + const doctor = JSON.parse(runCli(['doctor', '--json']).stdout); + assert.equal(doctor.radioCliVersion, metadata.version); + assert.equal(doctor.platform, process.platform); + assert.equal(doctor.architecture, process.arch); + assert.ok(doctor.capabilities && doctor.host, 'doctor must report host facts and capabilities.'); + + // A missing offline cache must fail promptly; stale persisted data must work. + // The preload makes accidental live-directory access an explicit failure. + assert.match(runCli(['countries'], {expectedStatus: 1}).stderr, /offline|cached/i); + seedProviderCache(home); + assert.match(runCli(['countries']).stdout, /ZZ\t2\tSmoke Islands/); + assert.match(runCli(['search', 'packed-smoke']).stdout, /Packed Smoke Radio/); + assert.match(runCli(['search', 'uncached-fixture'], {expectedStatus: 1}).stderr, /offline|cached/i); + const stationName = 'Émission 日本 & $(literal)'; + const stationUrl = 'https://example.invalid/packed-smoke?x=1&y=2'; + assert.match(runCli(['add-url', stationUrl, stationName]).stdout, /added=/); + const playlist = join(project, 'input & 日本.m3u'); + const exported = join(project, 'output & 日本.m3u'); + writeFileSync(playlist, '#EXTM3U\n#EXTINF:-1,Imported Fixture\nhttps://example.invalid/imported\n'); + assert.match(runCli(['import', playlist]).stdout, /imported=1/); + runCli(['export', exported]); + const contents = readFileSync(exported, 'utf8'); + assert.ok(contents.includes(stationName) && contents.includes(stationUrl), 'The exported playlist lost the custom URL or Unicode name.'); + assert.match(contents, /Imported Fixture\nhttps:\/\/example\.invalid\/imported/); + const libraryPath = join(home, 'radiocli.json'); + const library = JSON.parse(readFileSync(libraryPath, 'utf8')); + assert.equal(library.imported.length, 2, 'Both CLI processes must persist to the isolated store.'); + library.settings.agentControl.enabled = true; + library.settings.agentControl.openUiOnPlay = false; + library.settings.automaticUpdateChecks = false; + writeFileSync(libraryPath, `${JSON.stringify(library)}\n`); + const mcpTools = await smokeMcp({cli, cwd: project, env, nodeArgs: ['--import', guard]}); + + let binWrapper = 'pending'; + if (process.platform !== 'win32') { + const wrapper = join(project, 'node_modules', '.bin', 'radiocli'); + assert.equal(runCommand(wrapper, ['version'], {cwd: project, env, timeout: 45_000}).stdout.trim(), metadata.version); + binWrapper = 'posix npm link'; + } else if (process.env.CI) { + // Fixed PowerShell code, with the generated path passed only through env. + const wrapper = join(project, 'node_modules', '.bin', 'radiocli.ps1'); + assert.ok(existsSync(wrapper), 'npm did not create its PowerShell bin shim.'); + const result = runCommand('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', '& $env:RADIOCLI_SMOKE_BIN version; exit $LASTEXITCODE'], { + cwd: project, env: {...env, RADIOCLI_SMOKE_BIN: wrapper}, timeout: 45_000 + }); + assert.equal(result.stdout.trim(), metadata.version); + binWrapper = 'PowerShell npm shim'; + } + let playback = 'not exercised: mpv unavailable'; + if (doctor.backends.includes('mpv')) { + const result = runCommand(process.execPath, ['--import', guard, script, '--exercise-playback', packageRoot, home], {cwd: project, env, timeout: 60_000}); + assert.match(result.stdout, /packed_playback=ok/); + process.stdout.write(result.stdout); + playback = 'mpv: local WAV, IPC readiness/volume/pause/resume/stop, ao=null; no audio hardware assertion'; + } else if (requireMpv) throw new Error('This job requires real mpv playback, but the installed CLI did not detect mpv.'); + assert.equal(existsSync(networkLog), false, 'A packed runtime fixture attempted a live network request.'); + const evidence = { + executionMethod, platform: process.platform, architecture: arch(), endianness: endianness(), kernel: release(), + node: process.version, npm: runCommand(npm.command, [...npm.args, '--version'], {cwd: project, env}).stdout.trim(), + package: `${metadata.name}@${metadata.version}`, install: omitOptional ? 'omit-optional' : 'normal', + tarballSha256: createHash('sha256').update(readFileSync(tarball)).digest('hex'), + host: doctor.host, binWrapper, offlineCache: 'passed', mcpTools, playback, + checks: ['help', 'version', 'doctor-json', 'offline-cache-hit-and-miss', 'add-url', 'import', 'export', 'MCP-stdio'], result: 'passed' + }; + if (evidencePath) { + mkdirSync(dirname(resolve(evidencePath)), {recursive: true}); + writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); + } + console.log(`packed_smoke=${JSON.stringify(evidence)}`); + return evidence; + } finally { + rmSync(temporary, {recursive: true, force: true}); + } +} + +function seedProviderCache(home) { + const entries = {}; + const add = (path, params, value) => { + const query = new URLSearchParams(Object.entries(params).sort(([a], [b]) => a.localeCompare(b))); + entries[`${path}?${query}`] = {createdAt: Date.now() - 2 * 24 * 60 * 60 * 1000, value}; + }; + add('/json/countries', {hidebroken: 'true'}, [{name: 'Smoke Islands', iso_3166_1: 'ZZ', stationcount: 2}]); + for (const field of ['name', 'tag', 'country', 'language']) { + add('/json/stations/search', {hidebroken: 'true', limit: '20', offset: '0', order: 'clickcount', reverse: 'true', [field]: 'packed-smoke'}, [ + {stationuuid: 'packed-smoke-station', name: 'Packed Smoke Radio', url_resolved: 'https://example.invalid/cached', tags: 'packed-smoke'} + ]); + } + writeFileSync(join(home, 'radiocli-cache.json'), `${JSON.stringify({version: 1, entries})}\n`); +} + +export async function smokeMcp({cli, cwd, env, nodeArgs = []}) { + const child = spawn(process.execPath, [...nodeArgs, cli, 'mcp', 'serve'], {cwd, env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe']}); + const closed = new Promise(resolveClosed => child.once('close', resolveClosed)); + try { + return await new Promise((resolveResult, reject) => { + let output = ''; + let stderr = ''; + let settled = false; + let toolCount = 0; + const finish = (error, value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + error ? reject(error) : resolveResult(value); + }; + const timeout = setTimeout(() => finish(new Error(`MCP smoke timed out. ${stderr}`)), 30_000); + const send = message => child.stdin.write(`${JSON.stringify({jsonrpc: '2.0', ...message})}\n`); + child.stderr.on('data', chunk => { stderr += String(chunk); }); + child.stdin.on('error', error => finish(error)); + child.once('error', error => finish(error)); + child.once('close', code => finish(new Error(`MCP server exited with ${code}. ${stderr}`))); + child.stdout.on('data', chunk => { + output += String(chunk); + let newline; + while ((newline = output.indexOf('\n')) >= 0 && !settled) { + const line = output.slice(0, newline).trim(); + output = output.slice(newline + 1); + if (!line) continue; + try { + const response = JSON.parse(line); + assert.equal(response.error, undefined, 'MCP returned a protocol error.'); + if (response.id === 1) { + assert.equal(response.result?.serverInfo?.name, 'radiocli'); + send({method: 'notifications/initialized'}); + send({id: 2, method: 'tools/list', params: {}}); + } else if (response.id === 2) { + const tools = response.result?.tools ?? []; + for (const name of ['radio_status', 'radio_search', 'radio_play', 'radio_stop', 'radio_alarm_list']) assert.ok(tools.some(tool => tool.name === name), `MCP tool missing: ${name}`); + toolCount = tools.length; + send({id: 3, method: 'tools/call', params: {name: 'radio_status', arguments: {}}}); + } else if (response.id === 3) { + assert.notEqual(response.result?.isError, true, 'MCP radio_status failed.'); + const status = JSON.parse(response.result?.content?.find(item => item.type === 'text')?.text ?? 'null'); + assert.equal(status?.connected, false, 'An isolated MCP smoke must not find a user session.'); + finish(null, toolCount); + } + } catch (error) { + finish(new Error(`${error instanceof SyntaxError ? 'MCP server wrote non-JSON stdout' : 'MCP smoke failed'}: ${error.message}`)); + } + } + }); + send({id: 1, method: 'initialize', params: {protocolVersion: '2025-11-25', capabilities: {}, clientInfo: {name: 'radiocli-packed-smoke', version: '1.0.0'}}}); + }); + } finally { + child.stdin.destroy(); + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + const force = setTimeout(() => child.kill('SIGKILL'), 1000); + force.unref(); + await closed; + clearTimeout(force); + } + } +} + +async function exercisePlayback(packageRoot, home) { + const {PlayerController} = await import(pathToFileURL(join(packageRoot, 'dist', 'player', 'player-controller.js')).href); + const {JsonLibraryStore} = await import(pathToFileURL(join(packageRoot, 'dist', 'storage', 'store.js')).href); + const {resolveCommand} = await import(pathToFileURL(join(packageRoot, 'dist', 'player', 'command.js')).href); + const mpv = resolveCommand('mpv'); + assert.ok(mpv, 'mpv executable is required.'); + console.log(runCommand(mpv, ['--version']).stdout.split('\n')[0]); + const store = new JsonLibraryStore(); + store.updateSettings({preferredBackend: 'mpv', volume: 0, automaticUpdateChecks: false}); + const player = new PlayerController(() => store.snapshot().settings); + const wav = join(home, 'local silence.wav'); + writeFileSync(wav, silentWav()); + try { + await player.play({id: 'packed-playback', provider: 'playlist', name: 'Packed playback', tags: ['smoke'], streamUrl: wav}, wav); + await delay(750); + assert.equal(player.getState().ready, true, 'mpv must acknowledge playback readiness over IPC.'); + assert.equal(player.getState().state, 'playing'); + assert.equal((await player.setVolume(31)).ok, true, 'mpv must acknowledge the volume command.'); + assert.equal(player.getState().volume, 31); + assert.equal((await player.pause()).ok, true); + assert.equal(player.getState().state, 'paused'); + assert.equal((await player.resume()).ok, true); + assert.equal(player.getState().state, 'playing'); + } finally { + await player.stop(); + } + assert.equal(player.diagnostics().active, false, 'The playback child must stop.'); + assert.equal(player.getState().state, 'stopped'); + console.log('packed_playback=ok source=local-wav audio-output=null controls=IPC-volume-pause-resume-stop'); +} + +function silentWav() { + const dataBytes = 10 * 44_100 * 2 * 2; + const buffer = Buffer.alloc(44 + dataBytes); + buffer.write('RIFF'); + buffer.writeUInt32LE(36 + dataBytes, 4); + buffer.write('WAVEfmt ', 8); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(2, 22); + buffer.writeUInt32LE(44_100, 24); + buffer.writeUInt32LE(44_100 * 4, 28); + buffer.writeUInt16LE(4, 32); + buffer.writeUInt16LE(16, 34); + buffer.write('data', 36); + buffer.writeUInt32LE(dataBytes, 40); + return buffer; +} + +async function main(args) { + if (args[0] === '--exercise-playback') return exercisePlayback(args[1], args[2]); + const options = {}; + let tarball; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--omit-optional') options.omitOptional = true; + else if (arg === '--require-mpv') options.requireMpv = true; + else if (arg === '--execution-method') options.executionMethod = args[++index]; + else if (arg === '--evidence') options.evidencePath = args[++index]; + else if (!arg.startsWith('-') && !tarball) tarball = arg; + else throw new Error(`Unexpected packed-smoke argument: ${arg}`); + } + if (!tarball) throw new Error('Usage: node scripts/packed-smoke.mjs [--omit-optional] [--require-mpv] [--execution-method ] [--evidence ]'); + await runPackedSmoke(tarball, options); +} + +if (process.argv[1] && resolve(process.argv[1]) === script) { + await main(process.argv.slice(2)).catch(error => { console.error(error.stack ?? error.message); process.exitCode = 1; }); +} diff --git a/scripts/packed-smoke.test-node.mjs b/scripts/packed-smoke.test-node.mjs new file mode 100644 index 0000000..3b04cf3 --- /dev/null +++ b/scripts/packed-smoke.test-node.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +// Run explicitly with node --test; this name avoids Vitest's test-file glob. +import {mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {test} from 'node:test'; + +async function helpers() { + const module = await import('./packed-smoke.mjs').catch(() => ({})); + assert.equal(typeof module.npmCommand, 'function', 'the packed smoke must provide a shell-free npm launcher'); + return module; +} + +function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), 'radiocli smoke & 日本 ')); + t.after(() => rmSync(directory, {recursive: true, force: true})); + return directory; +} + +test('npm runs its JavaScript entry directly and preserves shell-sensitive arguments', async t => { + const {npmCommand, runCommand} = await helpers(); + const directory = fixture(t); + const cli = join(directory, 'npm-cli.js'); + writeFileSync(cli, 'console.log(JSON.stringify(process.argv.slice(2)));\n'); + const args = ['install', join(directory, 'a & b; $(never-run).tgz'), '100% literal !']; + const invocation = npmCommand({env: {npm_execpath: cli}}); + assert.equal(invocation.command, process.execPath); + assert.equal(invocation.args[0], realpathSync(cli)); + const result = runCommand(invocation.command, [...invocation.args, ...args]); + assert.deepEqual(JSON.parse(result.stdout), args); +}); + +test('npm discovery resolves the Windows Node distribution layout without executing a cmd shim', async t => { + const {npmCommand} = await helpers(); + const directory = fixture(t); + const node = join(directory, 'node.exe'); + const cli = join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'); + mkdirSync(dirname(cli), {recursive: true}); + writeFileSync(node, ''); + writeFileSync(cli, ''); + assert.deepEqual(npmCommand({execPath: node, platform: 'win32', env: {}}), {command: node, args: [realpathSync(cli)]}); +}); + +test('npm discovery fails clearly when only an unusable shim is available', async t => { + const {npmCommand} = await helpers(); + const directory = fixture(t); + writeFileSync(join(directory, 'npm.cmd'), '@echo off\r\n'); + assert.throws(() => npmCommand({execPath: join(directory, 'node.exe'), platform: 'win32', env: {PATH: directory}}), /npm-cli\.js/); +}); + +test('command failures include stderr and retain a nonzero failure', async () => { + const {runCommand} = await helpers(); + assert.throws(() => runCommand(process.execPath, ['-e', 'console.error("fixture failure"); process.exit(7)']), /fixture failure/); +}); + +test('a stalled child cannot turn a smoke test into an unbounded job', async () => { + const {runCommand} = await helpers(); + assert.throws(() => runCommand(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {timeout: 100}), /timed out|ETIMEDOUT/); +}); + +test('MCP smoke rejects non-JSON stdout from the actual child process', async t => { + const {smokeMcp} = await helpers(); + const directory = fixture(t); + const cli = join(directory, 'broken-mcp.cjs'); + writeFileSync(cli, 'console.log("unexpected banner"); setInterval(() => {}, 1000);\n'); + await assert.rejects(smokeMcp({cli, cwd: directory, env: process.env}), /non-JSON stdout/); +}); + +test('MCP smoke reports an early server exit instead of waiting for its deadline', async t => { + const {smokeMcp} = await helpers(); + const directory = fixture(t); + const cli = join(directory, 'closed-mcp.cjs'); + writeFileSync(cli, 'console.error("fixture server exited"); process.exit(3);\n'); + await assert.rejects(smokeMcp({cli, cwd: directory, env: process.env}), /fixture server exited|exited with 3/); +}); From 694c298fe556503512c0fb6b9b23ea91886453eb Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:19:14 -0500 Subject: [PATCH 16/25] fix: correct native portability verification failures --- .github/workflows/ci.yml | 1 + src/agent/launcher.test.ts | 26 ++++++------ src/cli-terminal.test.tsx | 60 +++++++++++++++++++++++++++ src/cli.test.ts | 2 + src/cli.tsx | 6 ++- src/platform/network.test.ts | 26 ++++++++++-- src/platform/shell.test.ts | 17 ++++++++ src/platform/shell.ts | 1 + src/platform/terminals.test.ts | 43 +++++++++++++++---- src/platform/terminals.ts | 26 ++++++++---- src/storage/store.test.ts | 4 +- src/ui/App.tsx | 2 +- src/ui/alarm-tui-integration.test.tsx | 14 ++++++- src/ui/portable-app.test.tsx | 15 +++---- src/ui/portable-render.test.tsx | 26 ++++++++++++ 15 files changed, 225 insertions(+), 44 deletions(-) create mode 100644 src/cli-terminal.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f02ecff..ac477e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -334,6 +334,7 @@ jobs: copyback: false prepare: | export PATH=/usr/pkg/bin:/usr/pkg/sbin:$PATH + /usr/sbin/pkg_add pkgin pkgin -y update pkgin -y install 'nodejs>=22<23' mpv run: | diff --git a/src/agent/launcher.test.ts b/src/agent/launcher.test.ts index ffaec17..5523d6c 100644 --- a/src/agent/launcher.test.ts +++ b/src/agent/launcher.test.ts @@ -1,5 +1,5 @@ import {EventEmitter} from 'node:events'; -import {spawn as nodeSpawn,type ChildProcess} from 'node:child_process'; +import {spawn as nodeSpawn,type ChildProcess,type SpawnOptions} from 'node:child_process'; import {mkdtempSync,mkdirSync,rmSync,writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -8,17 +8,19 @@ import {launchRadioTui,resolveExecutable} from './launcher.js'; vi.mock('node:child_process',async importOriginal=>({...await importOriginal(),spawn:vi.fn()})); const roots:string[]=[]; -const calls:Array<{command:string;args:readonly string[]}>=[]; -const spawn=vi.fn((command:string,args:readonly string[])=>{calls.push({command,args});const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',0);});return child;}); +const calls:Array<{command:string;args:readonly string[];options?:SpawnOptions}>=[]; +const spawn=vi.fn((command:string,args:readonly string[],options?:SpawnOptions)=>{calls.push({command,args,options});const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',0);});return child;}); beforeEach(()=>{calls.length=0;spawn.mockClear();vi.mocked(nodeSpawn).mockImplementation(spawn as unknown as typeof nodeSpawn);}); afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); function fixture(){const root=mkdtempSync(join(tmpdir(),'radiocli-launcher-'));roots.push(root);return root;} -function nodeInvocation(args: readonly string[]): {command: string; args: string[]} { - const encoded = args[args.indexOf('-EncodedCommand') + 1]; - const script = Buffer.from(encoded!, 'base64').toString('utf16le'); - const match = /FromBase64String\('([^']+)'\)/.exec(script); - expect(match).not.toBeNull(); - const invocation = JSON.parse(Buffer.from(match![1]!, 'base64').toString('utf8')) as {command: string; args: string[]}; +function nodeInvocation(args: readonly string[], environment: NodeJS.ProcessEnv = {}): {command: string; args: string[]} { + const encodedScript = args[args.indexOf('-EncodedCommand') + 1]; + const script = Buffer.from(encodedScript!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + const key = /GetEnvironmentVariable\('([^']+)'/.exec(script)?.[1]; + const source = encoded ? Buffer.from(encoded, 'base64').toString('utf8') : environment[key!]; + expect(source).toBeDefined(); + const invocation = JSON.parse(source!) as {command: string; args: string[]}; return invocation.args.includes('-EncodedCommand') ? nodeInvocation(invocation.args) : invocation; } @@ -31,8 +33,8 @@ describe('agent graphical launcher',()=>{ const home='C:\\Data %PATH%! & "quoted" \' 单播';const nodePath='C:\\Node %TEMP%! & 单播\\node.exe';const cliPath='C:\\Radio %PATH%! & 单播\\cli.js'; await launchRadioTui(nodePath,cliPath,'encoded-agent-command',{platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:terminal,RADIOCLI_HOME:home},spawn,resolve:command=>command}); const call=calls[0]!;expect(call.command).not.toBe('cmd.exe');const encoded=call.args[call.args.indexOf('-EncodedCommand')+1];expect(call.args).toContain('-EncodedCommand'); - const script=Buffer.from(encoded!,'base64').toString('utf16le');const match=/FromBase64String\('([^']+)'\)/.exec(script);expect(match).not.toBeNull(); - const invocation=nodeInvocation(call.args);expect(invocation.command).toBe(nodePath); + const script=Buffer.from(encoded!,'base64').toString('utf16le'); + const invocation=nodeInvocation(call.args,call.options?.env);expect(invocation.command).toBe(nodePath); const payload=JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')); expect(payload).toEqual({args:[cliPath,'agent-ui','encoded-agent-command'],environment:{RADIOCLI_HOME:home}});expect(script).not.toContain(home);expect(script).not.toContain(cliPath); }); @@ -43,5 +45,5 @@ describe('agent graphical launcher',()=>{ }); it('does not launch a graphical terminal in a headless Unix session',async()=>{await expect(launchRadioTui('/node','/cli.js','encoded',{platform:'linux',env:{RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},spawn,resolve:command=>command})).rejects.toThrow(/graphical|display|headless/i);expect(spawn).not.toHaveBeenCalled();}); - it.each(['freebsd','openbsd','netbsd'] as const)('requests the same agent session command in a supported %s desktop',async platform=>{await launchRadioTui('/node','/cli.js','encoded',{platform,env:{DISPLAY:':0',TERMINAL:'xterm'},spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined});expect(calls[0]).toEqual({command:'/usr/local/bin/xterm',args:['-e','/node','/cli.js','agent-ui','encoded']});}); + it.each(['freebsd','openbsd','netbsd'] as const)('requests the same agent session command in a supported %s desktop',async platform=>{await launchRadioTui('/node','/cli.js','encoded',{platform,env:{DISPLAY:':0',TERMINAL:'xterm'},spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined});expect(calls[0]).toMatchObject({command:'/usr/local/bin/xterm',args:['-e','/node','/cli.js','agent-ui','encoded']});}); }); diff --git a/src/cli-terminal.test.tsx b/src/cli-terminal.test.tsx new file mode 100644 index 0000000..4acebe7 --- /dev/null +++ b/src/cli-terminal.test.tsx @@ -0,0 +1,60 @@ +import {fileURLToPath} from 'node:url'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {resolveTerminalCapabilities} from './platform/terminal.js'; + +const {render, configure} = vi.hoisted(() => ({render: vi.fn(), configure: vi.fn()})); +vi.mock('ink', () => ({render})); +vi.mock('./ui/App.js', () => ({App: () => null})); +vi.mock('./ui/terminal-renderer.js', () => ({configureTerminalRenderer: configure})); + +const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); +const originalArgv = process.argv; +const originalExitCode = process.exitCode; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubEnv('TERM', 'xterm-256color'); + for (const key of ['NO_COLOR', 'FORCE_COLOR', 'RADIOCLI_SCREEN_READER', 'INK_SCREEN_READER']) vi.stubEnv(key, undefined); + configure.mockImplementation(async (env, evidence) => resolveTerminalCapabilities(env, evidence)); + process.argv = [process.execPath, fileURLToPath(new URL('./cli.tsx', import.meta.url))]; +}); + +afterEach(() => { + process.argv = originalArgv; + process.exitCode = originalExitCode; + if (stdinDescriptor) Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor); + else Reflect.deleteProperty(process.stdin, 'isTTY'); + if (stdoutDescriptor) Object.defineProperty(process.stdout, 'isTTY', stdoutDescriptor); + else Reflect.deleteProperty(process.stdout, 'isTTY'); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('CLI terminal entry point', () => { + it.each([true, undefined])('normalizes stdout.isTTY=%s before configuring Ink', async stdoutTty => { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: true}); + Object.defineProperty(process.stdout, 'isTTY', {configurable: true, value: stdoutTty}); + await import('./cli.js'); + expect(configure).toHaveBeenCalledOnce(); + expect(configure.mock.calls[0]?.[1]).toMatchObject({isTTY: Boolean(stdoutTty)}); + expect(render).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + interactive: stdoutTty ? undefined : false, + debug: !stdoutTty, + kittyKeyboard: expect.objectContaining({mode: stdoutTty ? 'auto' : 'disabled'}) + })); + }); + + it('prints actionable help and exits before rendering when terminal input is absent', async () => { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: undefined}); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await import('./cli.js'); + expect(error).toHaveBeenCalledWith(expect.stringContaining('interactive TUI needs terminal input')); + expect(log).toHaveBeenCalledWith(expect.stringContaining('Usage:')); + expect(process.exitCode).toBe(1); + expect(render).not.toHaveBeenCalled(); + expect(configure).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli.test.ts b/src/cli.test.ts index e696fa9..db82003 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -185,11 +185,13 @@ describe('CLI command dispatch', () => { await runCommand(['doctor', '--json']); const report = JSON.parse(logs.join('\n')); expect(report.support.tier).toBe(tier); + expect(report.support.scope).toBe('current-installation'); expect(report.support.evidence).toEqual(assessment.evidence); expect(report.backends).toEqual([]); logs.length = 0; await runCommand(['doctor']); expect(logs.join('\n')).toContain(`support_tier=${tier}`); + expect(logs.join('\n')).toContain('support_scope=current-installation'); expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); } finally {spy.mockRestore();} }); diff --git a/src/cli.tsx b/src/cli.tsx index 48f6f6b..941e4b0 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -58,7 +58,7 @@ async function renderTui(encodedCommand?: string): Promise { return; } const {configureTerminalRenderer} = await import('./ui/terminal-renderer.js'); - const terminal = await configureTerminalRenderer(process.env, {isTTY: process.stdout.isTTY, colorDepth: process.stdout.getColorDepth?.()}); + const terminal = await configureTerminalRenderer(process.env, {isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.()}); const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); render(, { // App owns Ctrl+C so it can confirm before performing a clean shutdown. @@ -127,6 +127,8 @@ export async function runCommand(args: string[]): Promise { } console.log(`network=${report.network.status} ${report.network.message}`); console.log(`support_tier=${report.support.tier} runtime=${report.support.runtime.status}`); + console.log(`support_scope=${report.support.scope}`); + console.log('support_note=Doctor checks this installation without running packed-install or audio acceptance tests; reference CI results are documented in the platform matrix.'); for (const reason of report.support.reasons) console.log(`support_note=${reason}`); return; } @@ -340,7 +342,7 @@ async function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic platform: process.platform, architecture: process.arch, host: {id: host.id, arch: host.arch, armVersion: host.armVersion, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl}, - support: assessPlatformSupport(host), + support: {scope: 'current-installation' as const, ...assessPlatformSupport(host)}, capabilities, terminal, network: networkDiagnostic(), diff --git a/src/platform/network.test.ts b/src/platform/network.test.ts index a3a4b71..028ad6c 100644 --- a/src/platform/network.test.ts +++ b/src/platform/network.test.ts @@ -1,4 +1,4 @@ -import {createServer, type Server} from 'node:http'; +import {createServer, request as requestHttp, type Server} from 'node:http'; import {createServer as createHttpsServer} from 'node:https'; import {connect} from 'node:net'; import {spawn} from 'node:child_process'; @@ -71,7 +71,7 @@ describe('public network policy', () => { it('explains that ALL_PROXY alone is not implemented even for HTTP proxy URLs', async () => { vi.stubEnv('ALL_PROXY', 'http://proxy.invalid:8080'); const fetchImpl = vi.fn(async () => new Response('{}')); - await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/ALL_PROXY/); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/ALL_PROXY/i); expect(fetchImpl).not.toHaveBeenCalled(); }); @@ -80,7 +80,7 @@ describe('public network policy', () => { for (const value of ['not-a-url-private-password', 'http://private-user:private-password%ZZ@proxy.invalid']) { vi.stubEnv('HTTPS_PROXY', value); const error = await withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json()).then(() => null, value => value as Error); - expect(error!.message).toMatch(/HTTPS_PROXY/); + expect(error!.message).toMatch(/HTTPS_PROXY/i); expect(error!.message).not.toMatch(/private-user|private-password|proxy\.invalid/); expect(error!.cause).toBeUndefined(); } @@ -207,10 +207,27 @@ describe('external response lifetime', () => { const destination = await start(origin); let proxyConnections = 0; let authenticatedProxy = false; - const proxy = createServer(); + let proxyTarget: string | undefined; + // Native fetch may use absolute-form HTTP requests or a CONNECT tunnel. + const proxy = createServer((request, response) => { + proxyConnections += 1; + authenticatedProxy = request.headers['proxy-authorization'] === `Basic ${Buffer.from('test-user:test-password').toString('base64')}`; + proxyTarget = new URL(request.url!).origin; + const headers = {...request.headers}; + delete headers['proxy-authorization']; + const upstream = requestHttp(request.url!, {method: request.method, headers, agent: false}, upstreamResponse => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }); + upstream.on('socket', socket => sockets.add(socket)); + upstream.on('error', error => response.destroy(error)); + request.on('error', error => upstream.destroy(error)); + request.pipe(upstream); + }); proxy.on('connect', (request, socket, head) => { proxyConnections += 1; authenticatedProxy = request.headers['proxy-authorization'] === `Basic ${Buffer.from('test-user:test-password').toString('base64')}`; + proxyTarget = `http://${request.url}`; sockets.add(socket); const upstream = connect(Number(new URL(destination).port), '127.0.0.1', () => { socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); @@ -242,6 +259,7 @@ describe('external response lifetime', () => { }); expect(JSON.parse(result)).toEqual({route: 'origin'}); expect(proxyConnections).toBe(1); + expect(proxyTarget).toBe(destination); expect(authenticatedProxy).toBe(true); expect(originProxyAuthorization).toBeUndefined(); }); diff --git a/src/platform/shell.test.ts b/src/platform/shell.test.ts index 3f477cf..f2d7592 100644 --- a/src/platform/shell.test.ts +++ b/src/platform/shell.test.ts @@ -2,6 +2,7 @@ import {spawn} from 'node:child_process'; import {describe,expect,it} from 'vitest'; import {powershellCommand} from './shell.js'; import {resolveCommandDetails} from './executables.js'; +import {nodeLaunchCommand} from './launch-command.js'; describe('PowerShell command transport',()=>{ it('transports paths, arguments, and environment as data rather than executable source',()=>{ @@ -13,6 +14,10 @@ describe('PowerShell command transport',()=>{ }); it('can leave an interactive console open after the child command',()=>{const args=powershellCommand(['node','cli.js'],{}, {keepOpen:true});expect(args).toContain('-NoExit');expect(args).not.toContain('-NonInteractive');expect(Buffer.from(args.at(-1)!,'base64').toString('utf16le')).not.toContain('exit $LASTEXITCODE');}); + it('silences first-use module progress before invoking cmdlets while retaining errors',()=>{ + const script=Buffer.from(powershellCommand(['node','cli.js']).at(-1)!,'base64').toString('utf16le'); + expect(script).toMatch(/^\$ErrorActionPreference='Stop';\$ProgressPreference='SilentlyContinue';/); + }); it('rejects empty commands and NUL bytes before starting a process',()=>{expect(()=>powershellCommand([])).toThrow(/command/i);expect(()=>powershellCommand(['node','bad\0value'])).toThrow(/NUL/i);}); it.skipIf(process.platform!=='win32')('preserves special data-home characters under native Windows PowerShell',async()=>{ @@ -23,4 +28,16 @@ describe('PowerShell command transport',()=>{ const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{const child=spawn(powershell!,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',chunk=>stdout+=String(chunk));child.stderr.on('data',chunk=>stderr+=String(chunk));child.once('error',reject);child.once('close',code=>resolve({code:code??1,stdout,stderr}));}); expect(result.code).toBe(0);expect(result.stderr).toBe('');expect(Buffer.from(result.stdout,'base64').toString('utf8')).toBe(home); }); + + it.skipIf(process.platform!=='win32')('runs the complete Node bootstrap with literal argv and environment through native Windows PowerShell',async()=>{ + const powershell=resolveCommandDetails('powershell.exe').path;expect(powershell).not.toBeNull(); + const home='C:\\Data %PATH%! & "quoted" \' 单播';const mpv="C:\\Players %PATH%! & ' 单播\\mpv.exe"; + const values=['space value','%PATH%!','a&b','"quoted"',"'quoted'",'$(throw boom);','单播','']; + const script="process.stdout.write(Buffer.from(JSON.stringify({args:process.argv.slice(1),home:process.env.RADIOCLI_HOME,mpv:process.env.RADIOCLI_MPV_PATH}),'utf8').toString('base64'))"; + const bootstrap=nodeLaunchCommand(process.execPath,['-e',script,'--',...values],{RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv}); + const args=powershellCommand(bootstrap); + const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{const child=spawn(powershell!,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',chunk=>stdout+=String(chunk));child.stderr.on('data',chunk=>stderr+=String(chunk));child.once('error',reject);child.once('close',code=>resolve({code:code??1,stdout,stderr}));}); + expect(result.code,JSON.stringify(result)).toBe(0);expect(result.stderr).toBe(''); + expect(JSON.parse(Buffer.from(result.stdout,'base64').toString('utf8'))).toEqual({args:values,home,mpv}); + }); }); diff --git a/src/platform/shell.ts b/src/platform/shell.ts index 29aa74f..4a26eaf 100644 --- a/src/platform/shell.ts +++ b/src/platform/shell.ts @@ -15,6 +15,7 @@ export function powershellCommand( const payload = Buffer.from(JSON.stringify({command: values[0], args: values.slice(1), environment}), 'utf8').toString('base64'); const script = [ "$ErrorActionPreference='Stop'", + "$ProgressPreference='SilentlyContinue'", `$radiocliInvocation=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json`, "foreach($radiocliVariable in $radiocliInvocation.environment.PSObject.Properties){[Environment]::SetEnvironmentVariable($radiocliVariable.Name,[string]$radiocliVariable.Value,'Process')}", '$radiocliArguments=@($radiocliInvocation.args | ForEach-Object {[string]$_})', diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts index 390b4c2..c763509 100644 --- a/src/platform/terminals.test.ts +++ b/src/platform/terminals.test.ts @@ -1,4 +1,4 @@ -import {execFileSync,type ChildProcess} from 'node:child_process'; +import {execFileSync,spawn,type ChildProcess} from 'node:child_process'; import {EventEmitter} from 'node:events'; import {existsSync,mkdtempSync,mkdirSync,readFileSync,rmSync,writeFileSync} from 'node:fs'; import {basename,join,posix} from 'node:path'; @@ -10,13 +10,15 @@ afterEach(()=>{vi.useRealTimers();for(const root of roots.splice(0))rmSync(root, const resolve=(command:string)=>command; function fixture(){const root=mkdtempSync(join(process.cwd(),'.radiocli-terminals-'));roots.push(root);return root;} function terminalPath(root:string,name:string){return posix.join(basename(root),name);} -function nodeInvocation(args: readonly string[]): {command: string; args: string[]} { +function nodeInvocation(args: readonly string[], environment: NodeJS.ProcessEnv = {}): {command: string; args: string[]} { const index = args.indexOf('-EncodedCommand'); expect(index).toBeGreaterThanOrEqual(0); const script = Buffer.from(args[index + 1]!, 'base64').toString('utf16le'); const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; - expect(encoded).toBeDefined(); - const invocation = JSON.parse(Buffer.from(encoded!, 'base64').toString('utf8')) as {command: string; args: string[]}; + const key = /GetEnvironmentVariable\('([^']+)'/.exec(script)?.[1]; + const source = encoded ? Buffer.from(encoded, 'base64').toString('utf8') : environment[key!]; + expect(source).toBeDefined(); + const invocation = JSON.parse(source!) as {command: string; args: string[]}; return invocation.args.includes('-EncodedCommand') ? nodeInvocation(invocation.args) : invocation; } @@ -42,23 +44,50 @@ describe('graphical terminal invocation plans',()=>{ it('preserves a special data home and every argument through the Windows Node bootstrap',()=>{ const home='C:\\Data %PATH%! & "quote" \' 单播';const values=['space value','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home},nodePath:process.execPath,args:['-e',"process.stdout.write(JSON.stringify({args:process.argv.slice(1),home:process.env.RADIOCLI_HOME}))",'--',...values],resolve,closeOnExit:true}); - const payload=nodeInvocation(plan.args); + const payload=nodeInvocation(plan.args,plan.environment); expect(payload.args[1]).not.toContain('"');const output=execFileSync(payload.command,payload.args,{encoding:'utf8'});expect(JSON.parse(output)).toEqual({args:values,home});expect(plan.command).toBe('powershell.exe'); }); it('requests a separate Windows console without inheriting detached launcher stdio',()=>{ const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve,closeOnExit:true}); const script=Buffer.from(plan.args.at(-1)!,'base64').toString('utf16le'); expect(script).toContain('Start-Process');expect(script).toContain('-WindowStyle Normal');expect(script).not.toMatch(/-NoNewWindow|-RedirectStandard/); + expect(script).toMatch(/SetEnvironmentVariable\('[^']+',\$null,'Process'\);Start-Process/); + }); + it('keeps a console launch with ordinary deep install paths below the Windows command-line limit',()=>{ + const nodePath=`C:\\Program Files\\${'Runtime\\'.repeat(19)}node.exe`; + const cliPath=`C:\\${'RadioCLI\\'.repeat(22)}dist\\cli.js`; + const dataRoot=`C:\\${'Radio Data\\'.repeat(18)}`; + const env={RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:`${dataRoot}Home`,RADIOCLI_MPV_PATH:`${dataRoot}mpv.exe`,RADIOCLI_FFPLAY_PATH:`${dataRoot}ffplay.exe`,RADIOCLI_VLC_PATH:`${dataRoot}vlc.exe`,RADIOCLI_FFMPEG_PATH:`${dataRoot}ffmpeg.exe`}; + const plan=createTerminalLaunch({platform:'win32',env,nodePath,args:[cliPath],resolve}); + expect([nodePath,cliPath,...Object.values(env)].every(value=>value.length<260)).toBe(true); + // Include quotes, separators, and NUL in the CreateProcessW limit. + expect([plan.command,...plan.args].reduce((length,value)=>length+value.length+3,1)).toBeLessThan(32_767); + const handoff=Object.values(plan.environment!);expect(handoff).toHaveLength(1); + expect(handoff[0]!.length+1).toBeLessThan(32_767); + const inner=JSON.parse(handoff[0]!) as {command:string;args:string[]}; + expect([inner.command,...inner.args].reduce((length,value)=>length+value.length+3,1)).toBeLessThan(32_767); }); it.skipIf(process.platform!=='win32')('opens real Windows console TTY handles and preserves literal argv and environment',async()=>{ const root=fixture();const cli=join(root,"Radio %PATH%! & ' 单播.cjs");const output=join(root,'console-result.json'); // File output observes the actual TTY child without redirecting its stdio. writeFileSync(cli,"const fs=require('node:fs');const output=process.argv[2];fs.writeFileSync(output+'.tmp',JSON.stringify({stdin:process.stdin.isTTY===true,stdout:process.stdout.isTTY===true,stderr:process.stderr.isTTY===true,args:process.argv.slice(3),home:process.env.RADIOCLI_HOME,mpv:process.env.RADIOCLI_MPV_PATH}),'utf8');fs.renameSync(output+'.tmp',output)"); const home='C:\\Data %PATH%! & "quote" \' 单播';const mpv="C:\\Players %PATH%! & ' 单播\\mpv.exe";const args=['spaces here','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; - await launchTerminalCommand({platform:'win32',env:{...process.env,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:[cli,output,...args],closeOnExit:true}); + const diagnostic:{commandCharacters:number;spawned:boolean;code:number|null;signal:NodeJS.Signals|null;error?:string;stdout:string;stderr:string}={commandCharacters:0,spawned:false,code:null,signal:null,stdout:'',stderr:''}; + await launchTerminalCommand({platform:'win32',env:{...process.env,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:[cli,output,...args],closeOnExit:true,spawn:(command,args,options)=>{ + diagnostic.commandCharacters=[command,...args].reduce((length,value)=>length+value.length+3,1); + // Capture only the outer launcher; Start-Process still creates the child + // with its own unredirected console handles. Never log inherited env. + const child=spawn(command,args,{...options,stdio:['ignore','pipe','pipe']}); + child.stdout?.on('data',chunk=>diagnostic.stdout=(diagnostic.stdout+String(chunk)).slice(-4_000)); + child.stderr?.on('data',chunk=>diagnostic.stderr=(diagnostic.stderr+String(chunk)).slice(-4_000)); + child.once('spawn',()=>diagnostic.spawned=true); + child.once('error',error=>diagnostic.error=error.message); + child.once('close',(code,signal)=>{diagnostic.code=code;diagnostic.signal=signal;}); + return child; + }}).catch(error=>{throw new Error(`Windows console launcher failed: ${JSON.stringify(diagnostic)}`,{cause:error});}); const deadline=Date.now()+10_000; while(!existsSync(output)&&Date.now()setTimeout(resolve,25)); - expect(existsSync(output)).toBe(true); + expect(existsSync(output),`Windows console probe did not finish: ${JSON.stringify(diagnostic)}`).toBe(true); expect(JSON.parse(readFileSync(output,'utf8'))).toEqual({stdin:true,stdout:true,stderr:true,args,home,mpv}); },15_000); it.each([{platform:'linux' as const,terminal:'linux:/tools/kitty'},{platform:'darwin' as const,terminal:'darwin:wezterm'}])('preserves configured players and network policy through an existing $terminal server',({platform,terminal})=>{ diff --git a/src/platform/terminals.ts b/src/platform/terminals.ts index 5ddb770..e989dc5 100644 --- a/src/platform/terminals.ts +++ b/src/platform/terminals.ts @@ -1,4 +1,4 @@ -import {spawn,type ChildProcess} from 'node:child_process'; +import {spawn,type ChildProcess,type SpawnOptions} from 'node:child_process'; import {posix,win32} from 'node:path'; import {resolveCommandDetails} from './executables.js'; import {identifyPlatform,nativeAdapters} from './runtime.js'; @@ -6,10 +6,10 @@ import {powershellCommand} from './shell.js'; import {launchEnvironment, nodeLaunchCommand} from './launch-command.js'; export type TerminalResolver=(command:string)=>string|undefined; -type TerminalSpawn=(command:string,args:readonly string[])=>ChildProcess; +type TerminalSpawn=(command:string,args:readonly string[],options?:SpawnOptions)=>ChildProcess; export type TerminalOptions={platform?:NodeJS.Platform;env?:NodeJS.ProcessEnv;resolve?:TerminalResolver;spawn?:TerminalSpawn}; type TerminalCommand=TerminalOptions&{nodePath:string;args:readonly string[];title?:string;closeOnExit?:boolean}; -type TerminalLaunch={terminal:string;command:string;args:string[]}; +type TerminalLaunch={terminal:string;command:string;args:string[];environment?:Record}; type UnixTerminal={name:string;args:string[];display?:'x11'|'wayland';shell?:boolean}; const unixTerminals:UnixTerminal[]=[ @@ -78,7 +78,7 @@ export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ // fixed quote-free Node program and encoded JSON cross that boundary. const command=nodeLaunchCommand(options.nodePath,options.args,environment); const args=powershellCommand(command,{}, {keepOpen:!options.closeOnExit}); - if(terminal==='win32:console')return{terminal,command:powershell,args:newWindowsConsole(powershell,args)}; + if(terminal==='win32:console')return{terminal,command:powershell,...newWindowsConsole(powershell,args)}; const wt=resolve('wt.exe')??(env.LOCALAPPDATA?resolve(win32.join(env.LOCALAPPDATA,'Microsoft','WindowsApps','wt.exe')):undefined); if(!wt)throw new Error('Windows Terminal is unavailable; the saved terminal cannot be requested.'); return{terminal,command:wt,args:['-w','new','new-tab','--title',options.title??'RadioCLI',powershell,...args]}; @@ -94,7 +94,7 @@ export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ /** Request acceptance is separate from the caller's TUI/session verification. */ export async function launchTerminalCommand(options:TerminalCommand):Promise{ const plan=createTerminalLaunch(options); - const child=options.spawn?options.spawn(plan.command,plan.args):spawn(plan.command,plan.args,{env:options.env??process.env,detached:true,stdio:'ignore',windowsHide:false}); + const child=(options.spawn??spawn)(plan.command,plan.args,{env:{...(options.env??process.env),...plan.environment},detached:true,stdio:'ignore',windowsHide:false}); await waitForLaunch(child);return plan.terminal; } @@ -117,17 +117,25 @@ function resolveUnixTerminal(input:string,env:NodeJS.ProcessEnv,resolve:Terminal const path=resolve(input);return path&&unixTerminals.some(item=>item.name===posix.basename(path))?path:undefined; } function shellQuote(value:string):string{return`'${value.replaceAll("'",`'\\''`)}'`;} -function newWindowsConsole(powershell: string, args: readonly string[]): string[] { - const payload = Buffer.from(JSON.stringify({command: powershell, args}), 'utf8').toString('base64'); +function newWindowsConsole(powershell: string, args: readonly string[]): {args: string[]; environment: Record} { + const key = 'RADIOCLI_WINDOWS_CONSOLE_COMMAND'; // Node's detached Windows launcher has NUL stdio. Start-Process must create // a separate console without -NoNewWindow or any standard-stream redirection. // Its ArgumentList joins values, so only fixed flags and encoded data go in it. const script = [ "$ErrorActionPreference='Stop'", - `$radiocliConsole=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json`, + "$ProgressPreference='SilentlyContinue'", + `$radiocliConsole=[Environment]::GetEnvironmentVariable('${key}','Process') | ConvertFrom-Json`, + `[Environment]::SetEnvironmentVariable('${key}',$null,'Process')`, 'Start-Process -FilePath ([string]$radiocliConsole.command) -ArgumentList ([string[]]$radiocliConsole.args) -WindowStyle Normal' ].join(';'); - return ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]; + // Only this short-lived outer process needs the handoff. Encoding the inner + // PowerShell command again exceeds Windows' command-line limit for ordinary + // deep install paths. Clear the private variable before creating the console. + return { + args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], + environment: {[key]: JSON.stringify({command: powershell, args})} + }; } function appleTerminalScript(command:string):string[]{return['-e','on run argv','-e','tell application "Terminal"','-e','activate','-e','do script (item 1 of argv)','-e','end tell','-e','end run',command];} function iTermScript(command:string):string[]{return['-e','on run argv','-e','tell application "iTerm"','-e','activate','-e','set w to (create window with default profile)','-e','tell current session of w to write text (item 1 of argv)','-e','end tell','-e','end run',command];} diff --git a/src/storage/store.test.ts b/src/storage/store.test.ts index 15aeb17..d1f1562 100644 --- a/src/storage/store.test.ts +++ b/src/storage/store.test.ts @@ -291,7 +291,9 @@ describe('JsonLibraryStore', () => { writeFileSync(backupFile, JSON.stringify(backup), 'utf8'); expect(() => destination.importBackup(backupFile)).toThrow(/500/); expect(new JsonLibraryStore(destinationFile).snapshot().alarms).toHaveLength(500); - }); + // This exercises real schema validation and disk operations for 500 records + // repeatedly; shared native Intel runners need more than the default 5 s. + }, 15_000); it('persists recents, favorites, and settings', () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-')); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index fe96462..1a75c89 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -200,7 +200,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const theme = library.settings.theme; const displayMode = useMemo( - () => resolveDisplayMode(library.settings, process.env, {screenReader, isTTY: stdout.isTTY, colorDepth: stdout.getColorDepth?.()}), + () => resolveDisplayMode(library.settings, process.env, {screenReader, isTTY: Boolean(stdout.isTTY), colorDepth: stdout.getColorDepth?.()}), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion, screenReader, stdout] ); const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]); diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index 7844df6..20367e8 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -530,7 +530,19 @@ describe('alarm TUI integration', () => { }); it('does not let background active polling move a non-alarm screen cursor', async () => { - const {store,service}=fixture();const app=render();await settle();app.stdin.write('9');await settle();await moveDown(app,4);const selectedLine=app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '));expect(selectedLine).toBeTruthy();vi.useFakeTimers();try{await act(async () => vi.advanceTimersByTimeAsync(1_600));expect(app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '))).toBe(selectedLine);}finally{app.unmount();vi.useRealTimers();} + const {store, service, calls} = fixture(); + const app = render(); + await settle(); + app.stdin.write('9'); + await settle(); + await moveDown(app, 4); + const selectedLine = app.lastFrame()?.split('\n').find(line => line.trimStart().startsWith('> ')); + expect(selectedLine).toBeTruthy(); + expect(calls.activeAlarms).toHaveBeenCalledOnce(); + await settle(1_600); + expect(calls.activeAlarms).toHaveBeenCalledTimes(2); + expect(app.lastFrame()?.split('\n').find(line => line.trimStart().startsWith('> '))).toBe(selectedLine); + app.unmount(); }); it('toggles, confirms deletion with a second x, and cleans the native job through the injected service', async () => { diff --git a/src/ui/portable-app.test.tsx b/src/ui/portable-app.test.tsx index 90da41b..83edd45 100644 --- a/src/ui/portable-app.test.tsx +++ b/src/ui/portable-app.test.tsx @@ -98,15 +98,16 @@ describe('App terminal integration', () => { }); it.each([ - {env: {TERM: 'dumb'}, animated: false}, - {env: {RADIOCLI_SCREEN_READER: '1'}, animated: false}, - {env: {}, animated: true} - ])('keeps the loading spinner animated=$animated for $env without changing settings', async ({env, animated}) => { + {env: {TERM: 'dumb'}, animated: false, redirected: false}, + {env: {RADIOCLI_SCREEN_READER: '1'}, animated: false, redirected: false}, + {env: {TERM: 'xterm-256color'}, animated: false, redirected: true}, + {env: {}, animated: true, redirected: false} + ])('keeps the loading spinner animated=$animated for $env with redirected=$redirected without changing settings', async ({env, animated, redirected}) => { for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value); setPlayback('playing'); vi.spyOn(ProviderManager.prototype, 'resolve').mockResolvedValue({url: 'https://example.test/portable'}); const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(() => new Promise(() => undefined)); - const {store, input, frames} = await openApp(false); + const {store, input, frames} = await openApp(false, redirected); await input('\u001B[B'); await input('\r'); await input('\r'); @@ -125,7 +126,7 @@ function setPlayback(state: 'playing' | 'loading'): void { vi.spyOn(PlayerController.prototype, 'onChange').mockImplementation(listener => { listener(playback); return () => undefined; }); } -async function openApp(screenReader: boolean) { +async function openApp(screenReader: boolean, redirected = false) { const directory = mkdtempSync(join(tmpdir(), 'radiocli-portable-app-')); directories.push(directory); vi.stubEnv('RADIOCLI_HOME', directory); @@ -133,7 +134,7 @@ async function openApp(screenReader: boolean) { store.updateSettings({resumeOnLaunch: false, automaticUpdateChecks: false, asciiMode: false, reduceMotion: false, transparentBackground: false}); store.toggleFavorite({id: 'portable', provider: 'radio-browser', name: 'Portable Radio', tags: [], streamUrl: 'https://example.test/portable'}); const frames: string[] = []; - const stdout = Object.assign(new Writable({write(chunk, _encoding, done) { frames.push(String(chunk)); done(); }}), {isTTY: true, columns: 100, rows: 30, getColorDepth: () => 24}) as NodeJS.WriteStream; + const stdout = Object.assign(new Writable({write(chunk, _encoding, done) { frames.push(String(chunk)); done(); }}), {columns: 100, rows: 30, ...(!redirected ? {isTTY: true, getColorDepth: () => 24} : {})}) as NodeJS.WriteStream; const errors: string[] = []; const stderr = new Writable({write(chunk, _encoding, done) { errors.push(String(chunk)); done(); }}) as NodeJS.WriteStream; const stdin = Object.assign(new PassThrough(), {isTTY: true, setRawMode: () => undefined, ref: () => undefined, unref: () => undefined}) as unknown as NodeJS.ReadStream; diff --git a/src/ui/portable-render.test.tsx b/src/ui/portable-render.test.tsx index 6ca8c94..46f9aa1 100644 --- a/src/ui/portable-render.test.tsx +++ b/src/ui/portable-render.test.tsx @@ -95,6 +95,32 @@ describe('portable terminal rendering', () => { } }); + it.each([{name: 'Unicode', ascii: false}, {name: 'ASCII', ascii: true}])('summarizes the Stats heatmap without decorative rows in $name mode', ({ascii}) => { + vi.stubEnv('TZ', 'UTC'); + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '30'); + const props = contentProps('stats', sizes[0]!); + props.library = {...library, activity: {sessions: [ + {id: 'yesterday', station, startedAt: '2026-09-06T12:00:00.000Z', endedAt: '2026-09-06T13:00:00.000Z', listenedSeconds: 3600}, + {id: 'today', station, startedAt: '2026-09-07T09:00:00.000Z', endedAt: '2026-09-07T09:30:00.000Z', listenedSeconds: 1800} + ]}}; + const settings = {...library.settings, asciiMode: ascii}; + const visualDisplay = resolveDisplayMode(settings, {}); + const visual = staticFrame(, 100); + expect(stripVTControlCharacters(visual)).toMatch(/\bMon\b/u); + + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode(settings, {INK_SCREEN_READER: 'true'}); + let view!: ReturnType; + act(() => { view = render(); }); + const frame = stripVTControlCharacters(view.lastFrame() ?? ''); + expect(frame).toContain('Activity graph: 2 active days in 2026'); + expect(frame).toMatch(/Total hours listened:\s*1\.5h/u); + expect(frame).toMatch(/Sessions:\s*2/u); + expect(frame).toMatch(/Active days:\s*2\/371/u); + expect(frame).not.toMatch(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec|Mon|Wed|Fri|Less|More)\b/u); + }); + it.each([{TERM: 'dumb'}, {RADIOCLI_SCREEN_READER: '1'}])('stops compact station marquees with %j', async env => { const props = contentProps('library', sizes[2]!); props.displayStations = [{...station, name: `${station.name} — A long international station name that would scroll`}]; From 07847c0d72090156cd1beb3228acfd07b3b07c6f Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:38:40 -0500 Subject: [PATCH 17/25] test: isolate native console failures and complete NetBSD prerequisites --- .github/workflows/ci.yml | 10 ++++++++++ src/platform/terminals.test.ts | 25 ++++++++++++++++++++++--- src/ui/alarm-tui-integration.test.tsx | 15 +++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac477e8..25036e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -333,10 +333,20 @@ jobs: arch: x86_64 copyback: false prepare: | + set -eu export PATH=/usr/pkg/bin:/usr/pkg/sbin:$PATH + # The minimal guest omits X11 libraries required by pkgsrc mpv, + # even for null audio/video. Install the matching official OS set. + # Digest: NetBSD-10.1/amd64/binary/sets/SHA512 on cdn.NetBSD.org. + radiocli_xbase=$(mktemp /tmp/radiocli-xbase.XXXXXX) + trap 'rm -f "$radiocli_xbase"' EXIT + ftp -q 60 -o "$radiocli_xbase" https://cdn.NetBSD.org/pub/NetBSD/NetBSD-10.1/amd64/binary/sets/xbase.tar.xz + test "$(cksum -a SHA512 -q "$radiocli_xbase")" = b6ac2195f91d7ae395ec2734c9ae2a4727014629fbdc94562531225e0d51e3b2705047e8440fcf75e1dacdcecb3cb980df7a82f838c12ad5274ba8ce95e49b72 + tar -xpf "$radiocli_xbase" -C / /usr/sbin/pkg_add pkgin pkgin -y update pkgin -y install 'nodejs>=22<23' mpv + mpv --version run: | set -eu export PATH=/usr/pkg/bin:/usr/pkg/sbin:$PATH diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts index c763509..2eb6989 100644 --- a/src/platform/terminals.test.ts +++ b/src/platform/terminals.test.ts @@ -69,11 +69,29 @@ describe('graphical terminal invocation plans',()=>{ }); it.skipIf(process.platform!=='win32')('opens real Windows console TTY handles and preserves literal argv and environment',async()=>{ const root=fixture();const cli=join(root,"Radio %PATH%! & ' 单播.cjs");const output=join(root,'console-result.json'); + const preload=join(root,'console-preload.cjs');const stages=join(root,'console-stages.jsonl'); + // Observe both the Node bootstrap and CLI without touching standard handles + // or logging argv contents. Monitoring leaves normal exception handling intact. + writeFileSync(preload,[ + "const fs=require('node:fs')", + `const stage=process.argv[1]===${JSON.stringify(cli)}?'cli':'bootstrap'`, + `function record(event){try{fs.appendFileSync(${JSON.stringify(stages)},JSON.stringify({...event,pid:process.pid,stage})+'\\n','utf8')}catch{}}`, + "record({event:'start',argumentLengths:process.argv.map(value=>value.length)})", + "process.once('exit',code=>record({event:'exit',code}))", + "process.on('uncaughtExceptionMonitor',(error,origin)=>record({event:'uncaught',origin,code:error.code??null,message:String(error.message).slice(0,2_000)}))" + ].join(';')); + const nodeOptions=[process.env.NODE_OPTIONS,`--require ${JSON.stringify(preload)}`].filter(Boolean).join(' '); // File output observes the actual TTY child without redirecting its stdio. - writeFileSync(cli,"const fs=require('node:fs');const output=process.argv[2];fs.writeFileSync(output+'.tmp',JSON.stringify({stdin:process.stdin.isTTY===true,stdout:process.stdout.isTTY===true,stderr:process.stderr.isTTY===true,args:process.argv.slice(3),home:process.env.RADIOCLI_HOME,mpv:process.env.RADIOCLI_MPV_PATH}),'utf8');fs.renameSync(output+'.tmp',output)"); + writeFileSync(cli,[ + "const fs=require('node:fs');const output=process.argv[2]", + "fs.writeFileSync(output+'.started','started','utf8')", + 'const result={args:process.argv.slice(3),home:process.env.RADIOCLI_HOME,mpv:process.env.RADIOCLI_MPV_PATH}', + "for(const name of ['stdin','stdout','stderr']){try{result[name]=process[name].isTTY===true}catch(error){result[name]={code:error.code??null,message:error.message}}}", + "fs.writeFileSync(output+'.tmp',JSON.stringify(result),'utf8');fs.renameSync(output+'.tmp',output)" + ].join(';')); const home='C:\\Data %PATH%! & "quote" \' 单播';const mpv="C:\\Players %PATH%! & ' 单播\\mpv.exe";const args=['spaces here','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; const diagnostic:{commandCharacters:number;spawned:boolean;code:number|null;signal:NodeJS.Signals|null;error?:string;stdout:string;stderr:string}={commandCharacters:0,spawned:false,code:null,signal:null,stdout:'',stderr:''}; - await launchTerminalCommand({platform:'win32',env:{...process.env,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:[cli,output,...args],closeOnExit:true,spawn:(command,args,options)=>{ + await launchTerminalCommand({platform:'win32',env:{...process.env,NODE_OPTIONS:nodeOptions,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:[cli,output,...args],closeOnExit:true,spawn:(command,args,options)=>{ diagnostic.commandCharacters=[command,...args].reduce((length,value)=>length+value.length+3,1); // Capture only the outer launcher; Start-Process still creates the child // with its own unredirected console handles. Never log inherited env. @@ -87,7 +105,8 @@ describe('graphical terminal invocation plans',()=>{ }}).catch(error=>{throw new Error(`Windows console launcher failed: ${JSON.stringify(diagnostic)}`,{cause:error});}); const deadline=Date.now()+10_000; while(!existsSync(output)&&Date.now()setTimeout(resolve,25)); - expect(existsSync(output),`Windows console probe did not finish: ${JSON.stringify(diagnostic)}`).toBe(true); + const nodeStages=existsSync(stages)?readFileSync(stages,'utf8').slice(-8_000):''; + expect(existsSync(output),`Windows console probe did not finish: ${JSON.stringify({...diagnostic,nodeStarted:existsSync(output+'.started'),nodeStages})}`).toBe(true); expect(JSON.parse(readFileSync(output,'utf8'))).toEqual({stdin:true,stdout:true,stderr:true,args,home,mpv}); },15_000); it.each([{platform:'linux' as const,terminal:'linux:/tools/kitty'},{platform:'darwin' as const,terminal:'darwin:wezterm'}])('preserves configured players and network policy through an existing $terminal server',({platform,terminal})=>{ diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index 20367e8..56ececb 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -12,6 +12,7 @@ import * as updates from '../update-check.js'; import * as session from '../agent/session.js'; import * as presence from '../alarms/tui-presence.js'; import * as systemActions from './system-actions.js'; +import * as pageFooter from './page-footer.js'; import {JsonLibraryStore} from '../storage/store.js'; import type {Alarm, IcyNowPlaying, PlaybackState, Station} from '../types.js'; import type {AlarmTuiService, TuiActiveAlarm} from './alarm-tui-service.js'; @@ -44,6 +45,20 @@ beforeEach(() => { })); vi.spyOn(session, 'startRadioSession').mockResolvedValue({close: async () => undefined}); vi.spyOn(presence, 'registerTuiPresence').mockReturnValue(() => undefined); + // These keyboard journeys repeat the same expensive footer layout many times. + // Keep the real layout for every distinct input; its wrapping and appearance + // also have dedicated page-footer and visual-baseline coverage. + const layoutFooter = pageFooter.balancedFooterLegendRows; + const footerLayouts = new Map(); + vi.spyOn(pageFooter, 'balancedFooterLegendRows').mockImplementation((...args) => { + const key = JSON.stringify(args); + let rows = footerLayouts.get(key); + if (!rows) { + rows = layoutFooter(...args); + footerLayouts.set(key, rows); + } + return [...rows]; + }); }); afterEach(() => { pendingInputs.length = 0; From 8c41612eec4c1199021ba8955dbd9027825c7eac Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:57:23 -0500 Subject: [PATCH 18/25] fix(windows): create independent console handles for terminal launches --- src/platform/terminals.test.ts | 24 +++++++++++---- src/platform/terminals.ts | 56 +++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts index 2eb6989..9dfae3a 100644 --- a/src/platform/terminals.test.ts +++ b/src/platform/terminals.test.ts @@ -50,8 +50,10 @@ describe('graphical terminal invocation plans',()=>{ it('requests a separate Windows console without inheriting detached launcher stdio',()=>{ const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve,closeOnExit:true}); const script=Buffer.from(plan.args.at(-1)!,'base64').toString('utf16le'); - expect(script).toContain('Start-Process');expect(script).toContain('-WindowStyle Normal');expect(script).not.toMatch(/-NoNewWindow|-RedirectStandard/); - expect(script).toMatch(/SetEnvironmentVariable\('[^']+',\$null,'Process'\);Start-Process/); + expect(script).toMatch(/CreateProcessW\(application, commandLine, IntPtr.Zero, IntPtr.Zero, false,\s*0x00000010, IntPtr.Zero, null, ref startup, out process\)/); + expect(script).toContain('startup.dwFlags = 0x00000001;');expect(script).not.toMatch(/GetStdHandle|Start-Process|startup\.hStd\w+\s*=/); + expect(script).toContain('CloseHandle(process.hThread)');expect(script).toContain('CloseHandle(process.hProcess)'); + expect(script).toMatch(/SetEnvironmentVariable\('[^']+',\$null,'Process'\);Add-Type/); }); it('keeps a console launch with ordinary deep install paths below the Windows command-line limit',()=>{ const nodePath=`C:\\Program Files\\${'Runtime\\'.repeat(19)}node.exe`; @@ -69,7 +71,7 @@ describe('graphical terminal invocation plans',()=>{ }); it.skipIf(process.platform!=='win32')('opens real Windows console TTY handles and preserves literal argv and environment',async()=>{ const root=fixture();const cli=join(root,"Radio %PATH%! & ' 单播.cjs");const output=join(root,'console-result.json'); - const preload=join(root,'console-preload.cjs');const stages=join(root,'console-stages.jsonl'); + const preload=join(root,'console-preload.cjs');const stages=join(root,'console-stages.jsonl');const powershellStages=join(root,'powershell-stages.log'); // Observe both the Node bootstrap and CLI without touching standard handles // or logging argv contents. Monitoring leaves normal exception handling intact. writeFileSync(preload,[ @@ -93,9 +95,18 @@ describe('graphical terminal invocation plans',()=>{ const diagnostic:{commandCharacters:number;spawned:boolean;code:number|null;signal:NodeJS.Signals|null;error?:string;stdout:string;stderr:string}={commandCharacters:0,spawned:false,code:null,signal:null,stdout:'',stderr:''}; await launchTerminalCommand({platform:'win32',env:{...process.env,NODE_OPTIONS:nodeOptions,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:[cli,output,...args],closeOnExit:true,spawn:(command,args,options)=>{ diagnostic.commandCharacters=[command,...args].reduce((length,value)=>length+value.length+3,1); - // Capture only the outer launcher; Start-Process still creates the child + const environment={...options?.env};const handoff=environment.RADIOCLI_WINDOWS_CONSOLE_COMMAND;expect(handoff).toBeDefined(); + const inner=JSON.parse(handoff!) as {command:string;args:string[]};const index=inner.args.indexOf('-EncodedCommand')+1;expect(index).toBeGreaterThan(0); + const script=Buffer.from(inner.args[index]!,'base64').toString('utf16le'); + // Observe PowerShell startup without waiting in the outer launcher or + // redirecting the console. Preserve the invocation's existing exit logic. + const path=Buffer.from(powershellStages,'utf8').toString('base64'); + const prefix=`$radiocliProbe=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${path}'));[IO.File]::AppendAllText($radiocliProbe,"powershell-start $PID\n");trap{[IO.File]::AppendAllText($radiocliProbe,([string]$_.Exception.Message)+"\n");break};`; + const observed=prefix+script.replace('exit $LASTEXITCODE','[IO.File]::AppendAllText($radiocliProbe,"native-exit $LASTEXITCODE\n");exit $LASTEXITCODE'); + inner.args[index]=Buffer.from(observed,'utf16le').toString('base64');environment.RADIOCLI_WINDOWS_CONSOLE_COMMAND=JSON.stringify(inner); + // Capture only the outer launcher; CreateProcessW still creates the child // with its own unredirected console handles. Never log inherited env. - const child=spawn(command,args,{...options,stdio:['ignore','pipe','pipe']}); + const child=spawn(command,args,{...options,env:environment,stdio:['ignore','pipe','pipe']}); child.stdout?.on('data',chunk=>diagnostic.stdout=(diagnostic.stdout+String(chunk)).slice(-4_000)); child.stderr?.on('data',chunk=>diagnostic.stderr=(diagnostic.stderr+String(chunk)).slice(-4_000)); child.once('spawn',()=>diagnostic.spawned=true); @@ -106,7 +117,8 @@ describe('graphical terminal invocation plans',()=>{ const deadline=Date.now()+10_000; while(!existsSync(output)&&Date.now()setTimeout(resolve,25)); const nodeStages=existsSync(stages)?readFileSync(stages,'utf8').slice(-8_000):''; - expect(existsSync(output),`Windows console probe did not finish: ${JSON.stringify({...diagnostic,nodeStarted:existsSync(output+'.started'),nodeStages})}`).toBe(true); + const powershellStage=existsSync(powershellStages)?readFileSync(powershellStages,'utf8').slice(-4_000):''; + expect(existsSync(output),`Windows console probe did not finish: ${JSON.stringify({...diagnostic,nodeStarted:existsSync(output+'.started'),nodeStages,powershellStage})}`).toBe(true); expect(JSON.parse(readFileSync(output,'utf8'))).toEqual({stdin:true,stdout:true,stderr:true,args,home,mpv}); },15_000); it.each([{platform:'linux' as const,terminal:'linux:/tools/kitty'},{platform:'darwin' as const,terminal:'darwin:wezterm'}])('preserves configured players and network policy through an existing $terminal server',({platform,terminal})=>{ diff --git a/src/platform/terminals.ts b/src/platform/terminals.ts index e989dc5..668ab3a 100644 --- a/src/platform/terminals.ts +++ b/src/platform/terminals.ts @@ -119,15 +119,63 @@ function resolveUnixTerminal(input:string,env:NodeJS.ProcessEnv,resolve:Terminal function shellQuote(value:string):string{return`'${value.replaceAll("'",`'\\''`)}'`;} function newWindowsConsole(powershell: string, args: readonly string[]): {args: string[]; environment: Record} { const key = 'RADIOCLI_WINDOWS_CONSOLE_COMMAND'; - // Node's detached Windows launcher has NUL stdio. Start-Process must create - // a separate console without -NoNewWindow or any standard-stream redirection. - // Its ArgumentList joins values, so only fixed flags and encoded data go in it. + // A detached Node launcher has NUL stdio. CreateProcessW explicitly creates + // new console buffers without inheriting those handles; STARTF_USESTDHANDLES + // must stay unset. ShellExecute-based Start-Process does not provide this + // handle contract. Only fixed flags and encoded data enter the argument string. + // https://learn.microsoft.com/en-us/windows/console/creation-of-a-console + // https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw + const native = String.raw` +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +public static class RadioCliConsole { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo { + public uint cb; + public string lpReserved, lpDesktop, lpTitle; + public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags; + public short wShowWindow, cbReserved2; + public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError; + } + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation { + public IntPtr hProcess, hThread; + public uint dwProcessId, dwThreadId; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW(string application, StringBuilder commandLine, + IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startup, + out ProcessInformation process); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + public static void Launch(string application, string arguments) { + var commandLine = new StringBuilder("\"" + application + "\" " + arguments); + if (commandLine.Length >= 32767) throw new ArgumentException("The Windows console command exceeds the native command-line limit."); + var startup = new StartupInfo(); + startup.cb = (uint)Marshal.SizeOf(typeof(StartupInfo)); + startup.dwFlags = 0x00000001; // STARTF_USESHOWWINDOW only; no inherited standard handles. + startup.wShowWindow = 1; // SW_SHOWNORMAL + ProcessInformation process; + if (!CreateProcessW(application, commandLine, IntPtr.Zero, IntPtr.Zero, false, + 0x00000010, IntPtr.Zero, null, ref startup, out process)) { // CREATE_NEW_CONSOLE + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + } +}`; const script = [ "$ErrorActionPreference='Stop'", "$ProgressPreference='SilentlyContinue'", `$radiocliConsole=[Environment]::GetEnvironmentVariable('${key}','Process') | ConvertFrom-Json`, `[Environment]::SetEnvironmentVariable('${key}',$null,'Process')`, - 'Start-Process -FilePath ([string]$radiocliConsole.command) -ArgumentList ([string[]]$radiocliConsole.args) -WindowStyle Normal' + `Add-Type -TypeDefinition '${native.replaceAll("'", "''")}'`, + "[RadioCliConsole]::Launch([string]$radiocliConsole.command, [string]::Join(' ', [string[]]$radiocliConsole.args))" ].join(';'); // Only this short-lived outer process needs the handoff. Encoding the inner // PowerShell command again exceeds Windows' command-line limit for ordinary From 8502fb5fee5e07770eeae0d176c3377851dccb2d Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:10:00 -0500 Subject: [PATCH 19/25] fix(windows): keep the console bootstrap alive through native launch --- src/platform/terminals.test.ts | 33 ++++++++++++++++++++++++++++++--- src/platform/terminals.ts | 25 ++++++++++++++++++++----- src/storage/store.test.ts | 4 +--- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts index 9dfae3a..ef2ac6f 100644 --- a/src/platform/terminals.test.ts +++ b/src/platform/terminals.test.ts @@ -92,9 +92,9 @@ describe('graphical terminal invocation plans',()=>{ "fs.writeFileSync(output+'.tmp',JSON.stringify(result),'utf8');fs.renameSync(output+'.tmp',output)" ].join(';')); const home='C:\\Data %PATH%! & "quote" \' 单播';const mpv="C:\\Players %PATH%! & ' 单播\\mpv.exe";const args=['spaces here','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; - const diagnostic:{commandCharacters:number;spawned:boolean;code:number|null;signal:NodeJS.Signals|null;error?:string;stdout:string;stderr:string}={commandCharacters:0,spawned:false,code:null,signal:null,stdout:'',stderr:''}; + const diagnostic:{commandCharacters:number;detached:boolean;spawned:boolean;code:number|null;signal:NodeJS.Signals|null;error?:string;stdout:string;stderr:string}={commandCharacters:0,detached:false,spawned:false,code:null,signal:null,stdout:'',stderr:''}; await launchTerminalCommand({platform:'win32',env:{...process.env,NODE_OPTIONS:nodeOptions,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:[cli,output,...args],closeOnExit:true,spawn:(command,args,options)=>{ - diagnostic.commandCharacters=[command,...args].reduce((length,value)=>length+value.length+3,1); + diagnostic.detached=Boolean(options?.detached); const environment={...options?.env};const handoff=environment.RADIOCLI_WINDOWS_CONSOLE_COMMAND;expect(handoff).toBeDefined(); const inner=JSON.parse(handoff!) as {command:string;args:string[]};const index=inner.args.indexOf('-EncodedCommand')+1;expect(index).toBeGreaterThan(0); const script=Buffer.from(inner.args[index]!,'base64').toString('utf16le'); @@ -104,9 +104,14 @@ describe('graphical terminal invocation plans',()=>{ const prefix=`$radiocliProbe=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${path}'));[IO.File]::AppendAllText($radiocliProbe,"powershell-start $PID\n");trap{[IO.File]::AppendAllText($radiocliProbe,([string]$_.Exception.Message)+"\n");break};`; const observed=prefix+script.replace('exit $LASTEXITCODE','[IO.File]::AppendAllText($radiocliProbe,"native-exit $LASTEXITCODE\n");exit $LASTEXITCODE'); inner.args[index]=Buffer.from(observed,'utf16le').toString('base64');environment.RADIOCLI_WINDOWS_CONSOLE_COMMAND=JSON.stringify(inner); + const outerArgs=[...args];const outerIndex=outerArgs.indexOf('-EncodedCommand')+1;expect(outerIndex).toBeGreaterThan(0); + const outer=Buffer.from(outerArgs[outerIndex]!,'base64').toString('utf16le'); + const outerMarker=`[IO.File]::AppendAllText([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${path}')),"powershell-outer-start $PID\n");`; + outerArgs[outerIndex]=Buffer.from(outerMarker+outer,'utf16le').toString('base64'); + diagnostic.commandCharacters=[command,...outerArgs].reduce((length,value)=>length+value.length+3,1); // Capture only the outer launcher; CreateProcessW still creates the child // with its own unredirected console handles. Never log inherited env. - const child=spawn(command,args,{...options,env:environment,stdio:['ignore','pipe','pipe']}); + const child=spawn(command,outerArgs,{...options,env:environment,stdio:['ignore','pipe','pipe']}); child.stdout?.on('data',chunk=>diagnostic.stdout=(diagnostic.stdout+String(chunk)).slice(-4_000)); child.stderr?.on('data',chunk=>diagnostic.stderr=(diagnostic.stderr+String(chunk)).slice(-4_000)); child.once('spawn',()=>diagnostic.spawned=true); @@ -144,4 +149,26 @@ describe('terminal launcher acceptance',()=>{ it('rejects a launcher that exits unsuccessfully immediately after spawn',async()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();const result=waitForLaunch(child);child.emit('spawn');child.emit('close',1);await expect(result).rejects.toThrow(/launcher.*exit.*1/i);}); it('accepts a live launcher without claiming application readiness',async()=>{vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();const result=waitForLaunch(child);child.emit('spawn');await vi.advanceTimersByTimeAsync(100);await expect(result).resolves.toBeUndefined();expect(child.unref).toHaveBeenCalledOnce();}); it('bounds a launcher that never reports process startup',async()=>{vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;const result=expect(waitForLaunch(child)).rejects.toThrow(/did not report process startup/i);await vi.advanceTimersByTimeAsync(3_000);await result;}); + it('keeps a transient console bootstrap referenced until it exits',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const launch=vi.fn(()=>child); + const result=launchTerminalCommand({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve,spawn:launch});let settled=false;void result.then(()=>settled=true); + expect(launch).toHaveBeenCalledWith('powershell.exe',expect.any(Array),expect.objectContaining({detached:false,stdio:'ignore',windowsHide:true})); + child.emit('spawn');await vi.advanceTimersByTimeAsync(100); + expect(settled).toBe(false);expect(child.unref).not.toHaveBeenCalled(); + child.emit('close',0);await expect(result).resolves.toBe('win32:console');expect(child.kill).not.toHaveBeenCalled(); + }); + it('rejects a transient bootstrap failure after the ordinary launcher grace period',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const result=waitForLaunch(child,{waitForExit:true}).then(()=>undefined,error=>error as Error); + child.emit('spawn');await vi.advanceTimersByTimeAsync(250);child.emit('close',7); + expect(await result).toMatchObject({message:expect.stringMatching(/launcher.*exit.*7/i)});expect(child.kill).not.toHaveBeenCalled(); + }); + it('bounds a transient bootstrap and kills its owned process on the completion deadline',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const result=waitForLaunch(child,{waitForExit:true}).then(()=>undefined,error=>error as Error); + child.emit('spawn');await vi.advanceTimersByTimeAsync(9_999);expect(child.kill).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toMatchObject({message:expect.stringMatching(/bootstrap.*did not complete/i)});expect(child.kill).toHaveBeenCalledOnce();expect(child.unref).not.toHaveBeenCalled(); + }); }); diff --git a/src/platform/terminals.ts b/src/platform/terminals.ts index 668ab3a..e1df080 100644 --- a/src/platform/terminals.ts +++ b/src/platform/terminals.ts @@ -94,18 +94,33 @@ export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ /** Request acceptance is separate from the caller's TUI/session verification. */ export async function launchTerminalCommand(options:TerminalCommand):Promise{ const plan=createTerminalLaunch(options); - const child=(options.spawn??spawn)(plan.command,plan.args,{env:{...(options.env??process.env),...plan.environment},detached:true,stdio:'ignore',windowsHide:false}); - await waitForLaunch(child);return plan.terminal; + // PowerShell can exit without executing its script under DETACHED_PROCESS. + // Keep this short-lived bootstrap attached until it creates the independent + // console. The new console, not its bootstrap, owns the interactive handles. + // https://github.com/nodejs/node/issues/51018 + const consoleBootstrap=plan.terminal==='win32:console'; + const child=(options.spawn??spawn)(plan.command,plan.args,{env:{...(options.env??process.env),...plan.environment},detached:!consoleBootstrap,stdio:'ignore',windowsHide:consoleBootstrap}); + await waitForLaunch(child,{waitForExit:consoleBootstrap});return plan.terminal; } -export function waitForLaunch(child:ChildProcess):Promise{ +export function waitForLaunch(child:ChildProcess,options:{waitForExit?:boolean}={}):Promise{ return new Promise((resolve,reject)=>{ let settled=false;let acceptedTimer:NodeJS.Timeout|undefined; const finish=(error?:Error)=>{if(settled)return;settled=true;clearTimeout(startupTimer);if(acceptedTimer)clearTimeout(acceptedTimer);if(error)reject(error);else resolve();}; const startupTimer=setTimeout(()=>finish(new Error('Terminal launcher did not report process startup.')),3_000); child.once('error',error=>finish(error)); child.once('close',(code,signal)=>finish(code===0?undefined:new Error(`Terminal launcher exited with ${code??signal??'unknown status'}.`))); - child.once('spawn',()=>{child.unref();acceptedTimer=setTimeout(()=>finish(),100);}); + child.once('spawn',()=>{ + if(options.waitForExit){ + clearTimeout(startupTimer); + acceptedTimer=setTimeout(()=>{ + finish(new Error('Terminal bootstrap did not complete.')); + try{child.kill();}catch{/* Preserve the timeout if cleanup also fails. */} + },10_000); + }else{ + child.unref();acceptedTimer=setTimeout(()=>finish(),100); + } + }); }); } @@ -119,7 +134,7 @@ function resolveUnixTerminal(input:string,env:NodeJS.ProcessEnv,resolve:Terminal function shellQuote(value:string):string{return`'${value.replaceAll("'",`'\\''`)}'`;} function newWindowsConsole(powershell: string, args: readonly string[]): {args: string[]; environment: Record} { const key = 'RADIOCLI_WINDOWS_CONSOLE_COMMAND'; - // A detached Node launcher has NUL stdio. CreateProcessW explicitly creates + // The transient launcher has NUL stdio. CreateProcessW explicitly creates // new console buffers without inheriting those handles; STARTF_USESTDHANDLES // must stay unset. ShellExecute-based Start-Process does not provide this // handle contract. Only fixed flags and encoded data enter the argument string. diff --git a/src/storage/store.test.ts b/src/storage/store.test.ts index d1f1562..15aeb17 100644 --- a/src/storage/store.test.ts +++ b/src/storage/store.test.ts @@ -291,9 +291,7 @@ describe('JsonLibraryStore', () => { writeFileSync(backupFile, JSON.stringify(backup), 'utf8'); expect(() => destination.importBackup(backupFile)).toThrow(/500/); expect(new JsonLibraryStore(destinationFile).snapshot().alarms).toHaveLength(500); - // This exercises real schema validation and disk operations for 500 records - // repeatedly; shared native Intel runners need more than the default 5 s. - }, 15_000); + }); it('persists recents, favorites, and settings', () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-')); From 3ac8429ac8105d6e1a31831f7ea56c1576f4ca35 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:19:25 -0500 Subject: [PATCH 20/25] test: fix Windows packed preloads and console fixture cleanup --- scripts/packed-smoke.mjs | 9 ++++++--- src/platform/terminals.test.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/packed-smoke.mjs b/scripts/packed-smoke.mjs index d38c1f6..7a09a7d 100644 --- a/scripts/packed-smoke.mjs +++ b/scripts/packed-smoke.mjs @@ -52,15 +52,18 @@ export async function runPackedSmoke(tarball, {omitOptional = false, requireMpv assert.ok(existsSync(tarball), `Packed tarball is missing: ${tarball}`); assert.ok(Number(process.versions.node.split('.')[0]) >= 22, 'Packed runtime checks require Node.js 22 or newer.'); if (process.env.RADIOCLI_EXPECTED_ARCH) assert.equal(process.arch, process.env.RADIOCLI_EXPECTED_ARCH, 'The packed runtime must match the requested native runner architecture.'); - const temporary = mkdtempSync(join(tmpdir(), 'radiocli packed & 日本 ')); + const temporary = mkdtempSync(join(tmpdir(), 'radiocli packed & # % 日本 ')); const project = join(temporary, 'project'); const home = join(temporary, 'state'); mkdirSync(project); mkdirSync(home); const env = smokeEnvironment(home); const networkLog = join(temporary, 'unexpected-network.log'); - const guard = join(temporary, 'network-guard.mjs'); - writeFileSync(guard, `import {appendFileSync} from 'node:fs';\nglobalThis.fetch = () => { appendFileSync(process.env.RADIOCLI_SMOKE_NETWORK_LOG, 'fetch attempted\\n'); return Promise.reject(new Error('Packed smoke forbids live network requests.')); };\n`); + const guardPath = join(temporary, 'network-guard.mjs'); + // --import uses ESM URL resolution: drive letters and URL delimiters must + // remain file-path data on Windows and on POSIX paths containing # or %. + const guard = pathToFileURL(guardPath).href; + writeFileSync(guardPath, `import {appendFileSync} from 'node:fs';\nglobalThis.fetch = () => { appendFileSync(process.env.RADIOCLI_SMOKE_NETWORK_LOG, 'fetch attempted\\n'); return Promise.reject(new Error('Packed smoke forbids live network requests.')); };\n`); env.RADIOCLI_SMOKE_NETWORK_LOG = networkLog; try { writeFileSync(join(project, 'package.json'), '{"private":true,"type":"module"}\n'); diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts index ef2ac6f..1631fe8 100644 --- a/src/platform/terminals.test.ts +++ b/src/platform/terminals.test.ts @@ -6,7 +6,7 @@ import {afterEach,describe,expect,it,vi} from 'vitest'; import {createTerminalLaunch,detectGraphicalTerminal,launchTerminalCommand,waitForLaunch} from './terminals.js'; const roots:string[]=[]; -afterEach(()=>{vi.useRealTimers();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); +afterEach(()=>{vi.useRealTimers();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true,maxRetries:5,retryDelay:50});}); const resolve=(command:string)=>command; function fixture(){const root=mkdtempSync(join(process.cwd(),'.radiocli-terminals-'));roots.push(root);return root;} function terminalPath(root:string,name:string){return posix.join(basename(root),name);} @@ -120,10 +120,13 @@ describe('graphical terminal invocation plans',()=>{ return child; }}).catch(error=>{throw new Error(`Windows console launcher failed: ${JSON.stringify(diagnostic)}`,{cause:error});}); const deadline=Date.now()+10_000; - while(!existsSync(output)&&Date.now()setTimeout(resolve,25)); + // The result precedes Node's exit hooks and the final PowerShell diagnostic. + // Wait for both writers before removing the fixture on Windows. + const completed=()=>existsSync(output)&&existsSync(powershellStages)&&/native-exit 0\r?\n/.test(readFileSync(powershellStages,'utf8')); + while(!completed()&&Date.now()setTimeout(resolve,25)); const nodeStages=existsSync(stages)?readFileSync(stages,'utf8').slice(-8_000):''; const powershellStage=existsSync(powershellStages)?readFileSync(powershellStages,'utf8').slice(-4_000):''; - expect(existsSync(output),`Windows console probe did not finish: ${JSON.stringify({...diagnostic,nodeStarted:existsSync(output+'.started'),nodeStages,powershellStage})}`).toBe(true); + expect(completed(),`Windows console probe did not finish: ${JSON.stringify({...diagnostic,nodeStarted:existsSync(output+'.started'),nodeStages,powershellStage})}`).toBe(true); expect(JSON.parse(readFileSync(output,'utf8'))).toEqual({stdin:true,stdout:true,stderr:true,args,home,mpv}); },15_000); it.each([{platform:'linux' as const,terminal:'linux:/tools/kitty'},{platform:'darwin' as const,terminal:'darwin:wezterm'}])('preserves configured players and network policy through an existing $terminal server',({platform,terminal})=>{ From fd57fda75dfed9de547293b9fb28f5fc30f5f0fa Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:37:34 -0500 Subject: [PATCH 21/25] fix: bound validation work and clean up rendering fixtures --- src/alarms/schedule.test.ts | 19 +++++++++++++- src/alarms/schedule.ts | 9 +++++-- src/ui/AdaptiveContent.test.tsx | 36 ++++++++++++++++++++++++--- src/ui/screens/screen-render.test.tsx | 30 ++++++++++++++++------ 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/alarms/schedule.test.ts b/src/alarms/schedule.test.ts index 7bb14f7..8e703b6 100644 --- a/src/alarms/schedule.test.ts +++ b/src/alarms/schedule.test.ts @@ -1,8 +1,9 @@ -import {describe, expect, it} from 'vitest'; +import {describe, expect, it, vi} from 'vitest'; import { assessScheduledOccurrence, canonicalizeAlarmTime, canonicalizeIsoWeekdays, + canonicalizeTimeZone, isValidTimeZone, nextAlarmOccurrence, nextOccurrenceForAlarm @@ -120,4 +121,20 @@ describe('alarm scheduling', () => { expect(() => canonicalizeIsoWeekdays([])).toThrow(/weekday/i); expect(() => canonicalizeIsoWeekdays([0, 8])).toThrow(/weekday/i); }); + + it('canonicalizes each alarm timezone with one native formatter construction', () => { + const expected = new Intl.DateTimeFormat('en-US', {timeZone: 'US/Eastern'}).resolvedOptions().timeZone; + const NativeDateTimeFormat = Intl.DateTimeFormat; + const formatter = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function (...args: Parameters) { + return new NativeDateTimeFormat(...args); + } as typeof Intl.DateTimeFormat); + try { + expect(canonicalizeTimeZone(' US/Eastern ')).toBe(expected); + expect(formatter).toHaveBeenCalledTimes(1); + expect(() => canonicalizeTimeZone('Mars/Olympus_Mons')).toThrow('Invalid IANA timezone: Mars/Olympus_Mons'); + expect(() => canonicalizeTimeZone(' ')).toThrow(/Invalid IANA timezone/); + } finally { + formatter.mockRestore(); + } + }); }); diff --git a/src/alarms/schedule.ts b/src/alarms/schedule.ts index 430e302..7b8f04c 100644 --- a/src/alarms/schedule.ts +++ b/src/alarms/schedule.ts @@ -16,10 +16,15 @@ export function isValidTimeZone(timezone: string): boolean { export function canonicalizeTimeZone(timezone: string): string { const value = timezone.trim(); - if (!isValidTimeZone(value)) { + try { + if (!value) throw new Error('Empty timezone.'); + // The constructor validates the zone and resolves its canonical name. + // Constructing twice made large-library validation expensive on Node 22 + // Intel hosts; keep one native validation for every supplied value. + return new Intl.DateTimeFormat('en-US', {timeZone: value}).resolvedOptions().timeZone; + } catch { throw new Error(`Invalid IANA timezone: ${timezone}`); } - return new Intl.DateTimeFormat('en-US', {timeZone: value}).resolvedOptions().timeZone; } export function canonicalizeAlarmTime(time: string): string { diff --git a/src/ui/AdaptiveContent.test.tsx b/src/ui/AdaptiveContent.test.tsx index 28a26bd..a8b4b99 100644 --- a/src/ui/AdaptiveContent.test.tsx +++ b/src/ui/AdaptiveContent.test.tsx @@ -1,5 +1,6 @@ -import {render} from 'ink-testing-library'; -import {describe, expect, it} from 'vitest'; +import {act} from 'react'; +import {cleanup, render as inkRender} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {receiverStyleNames} from '../types.js'; import type { AirPlayDevice, @@ -17,6 +18,25 @@ import {DisplayContext, resolveDisplayMode} from './display-context.js'; import {displayWidth} from './format.js'; import {defaultExploreCursor} from './app-state.js'; +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // Ink's fixture supplies 100 columns but no rows. Keep its 24-row fallback + // deterministic without asking the host terminal through commands like tput. + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '24'); +}); +afterEach(() => { + act(() => cleanup()); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function render(tree: Parameters[0]): ReturnType { + let view!: ReturnType; + act(() => { view = inkRender(tree); }); + return view; +} + const station: Station = { id: 'kexp', provider: 'radio-browser', @@ -238,7 +258,7 @@ function renderAdaptive( renderStations: Station[] = [station] ): string { const renderLibrary = {...library, settings: {...settings, receiverStyle}}; - return render( + const view = render( - ).lastFrame() ?? ''; + ); + try { + return view.lastFrame() ?? ''; + } finally { + act(() => { + view.unmount(); + view.cleanup(); + }); + } } diff --git a/src/ui/screens/screen-render.test.tsx b/src/ui/screens/screen-render.test.tsx index 150b774..b212183 100644 --- a/src/ui/screens/screen-render.test.tsx +++ b/src/ui/screens/screen-render.test.tsx @@ -1,6 +1,6 @@ import {act} from 'react'; -import {render} from 'ink-testing-library'; -import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render as inkRender} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import type {AppSettings, LibraryState, PlaybackDiagnostics, PlaybackState, Station, TrackPlay} from '../../types.js'; import {DisplayContext, resolveDisplayMode} from '../display-context.js'; import {HelpScreen} from './HelpScreen.js'; @@ -16,6 +16,26 @@ import {defaultExploreCursor} from '../app-state.js'; import {StationList} from '../components/StationList.js'; import {displayWidth} from '../format.js'; +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // Ink's fixture supplies 100 columns but no rows. Keep its 24-row fallback + // deterministic without asking the host terminal through commands like tput. + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '24'); +}); +afterEach(() => { + act(() => cleanup()); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function render(tree: Parameters[0]): ReturnType { + let view!: ReturnType; + act(() => { view = inkRender(tree); }); + return view; +} + const station: Station = { id: 'station-1', provider: 'radio-browser', @@ -100,8 +120,6 @@ const library: LibraryState = { }; describe('HomeScreen rendering', () => { - afterEach(() => vi.useRealTimers()); - it('does not repeat playback status already shown in the app header', () => { const frame = render().lastFrame() ?? ''; @@ -120,8 +138,6 @@ describe('HomeScreen rendering', () => { }); describe('StationList rendering', () => { - afterEach(() => vi.useRealTimers()); - it('replaces standard metadata with selected-station tags on the same row', () => { const frame = render( { }); describe('CountriesScreen rendering', () => { - afterEach(() => vi.useRealTimers()); - it('marquees a focused long name while keeping the row to one terminal line', async () => { vi.useFakeTimers(); const view = render( From 7762f937933df43e9101e53657750f2215998062 Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:55:47 -0500 Subject: [PATCH 22/25] docs: record verified platform support and portability limits --- CONTRIBUTING.md | 42 +++- README.md | 47 +++- apps/docs/content/docs/architecture.mdx | 98 ++++++-- apps/docs/content/docs/contributing.mdx | 41 +++- apps/docs/content/docs/design.mdx | 67 +++++- .../content/docs/getting-started/install.mdx | 137 +++++++++-- .../docs/getting-started/tui-controls.mdx | 63 ++++- apps/docs/content/docs/meta.json | 2 + apps/docs/content/docs/platforms.mdx | 220 ++++++++++++++++++ apps/docs/content/docs/privacy-security.mdx | 98 ++++++-- apps/docs/content/docs/release-packaging.mdx | 65 +++++- apps/docs/content/docs/reliability.mdx | 96 +++++++- apps/docs/content/docs/roadmap.mdx | 66 +++++- apps/docs/content/docs/troubleshooting.mdx | 194 +++++++++++++++ docs/cross-platform/audit.md | 126 +++++++++- docs/cross-platform/verification.md | 148 ++++++++++++ docs/cross-platform/visual-before-after.txt | 106 +++++++++ 17 files changed, 1496 insertions(+), 120 deletions(-) create mode 100644 apps/docs/content/docs/platforms.mdx create mode 100644 apps/docs/content/docs/troubleshooting.mdx create mode 100644 docs/cross-platform/verification.md create mode 100644 docs/cross-platform/visual-before-after.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd2f4ed..9734565 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,19 +12,22 @@ npm run build npm run test ``` -Install `mpv` for playback controls, metadata polling, and the best playback -experience: +Install `mpv` for playback controls and metadata polling. On a macOS +configuration supported by Homebrew: ```bash brew install mpv ``` -On Linux, use your package manager's `mpv` package. On Windows, use: +On Linux, use your package manager's `mpv` package. On Windows x64, use: ```powershell winget install --id shinchiro.mpv -e ``` +Use the [platform installation routes](apps/docs/content/docs/platforms.mdx#installation-and-playback-prerequisites) +for Windows arm64 and other package managers. + ## Verification Run these before opening a PR: @@ -34,11 +37,19 @@ npm run check npm run lint npm run test npm run build +npm run pack:check +npm run fresh:check -- --require-mpv +npm run check:package +npm run smoke:mcp +npm run docs:check +npm run docs:build ``` -Run `npm run smoke:playback` when changing playback code. It opens a public -stream briefly, so do not run it in environments where audio/network access is -not acceptable. +Install documentation dependencies with `npm --prefix apps/docs ci` first. +Run `npm run smoke:playback` when changing playback code. Its default fixture is +a local WAV file; mpv uses null audio in deterministic package +checks. These checks exercise the player process and controls, and do not prove +speaker output. `npm run smoke:data` intentionally contacts public providers. Live provider checks are useful before releases, but ordinary pull requests should stay deterministic. Run `npm run verify:release` for a full maintainer @@ -52,9 +63,28 @@ checks. - Never make location lookup implicit. - Prefer clear terminal text over decorative filler. - Keep command-line and TUI behavior aligned. +- Put OS decisions in `src/platform`; keep capability availability separate from + runtime eligibility and recorded platform verification. - Add tests for parsing, storage, layout, or provider transformations when the behavior can be checked without live audio. +## Platform verification + +Run the Node 22/24 matrix and preserve its strict gates. Expanded native jobs +test x64 and arm64 installation and actual mpv control; weekly or opt-in BSD +checks execute the packed JavaScript in guest kernels. Changes to script harnesses +also require `node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs`. + +Record OS release, CPU, endianness, libc, Node/npm versions, artifact hash, and +whether execution was native, virtualized, emulated, or mocked. An installation +test, native command mock, or foreign binary inspection alone cannot promote a +platform to supported. Follow the [support tiers](apps/docs/content/docs/platforms.mdx). + +The deterministic visual fixtures cover full, compact and micro layouts, all +major screens and receiver styles. Preserve Unicode captures. Explain intentional +portable-rendering changes with before/after captures; do not update snapshots +just to hide failures. See [the captures](docs/cross-platform/visual-before-after.txt). + ## Issue Triage Playback issues should include station name, country, backend, and diff --git a/README.md b/README.md index ff42002..8e4ea3c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Built with [Ink](https://github.com/vadimdemedes/ink), ## Quick start -macOS with Homebrew: +On macOS configurations supported by Homebrew: ```bash brew install ciphore/tap/radiocli @@ -44,11 +44,43 @@ radiocli RadioCLI requires Node.js 22 or newer. The Homebrew formula installs `mpv` and FFmpeg. After an npm install, `radiocli setup` detects the operating system and package manager, lets you choose `mpv`, FFmpeg, and VLC, installs the selected -native tools with branded progress feedback, and verifies playback readiness. +native tools with branded progress feedback, and checks that executables are +available. Play a station to verify the actual audio path. Intel or older macOS +installations should consult the platform matrix for alternative package routes. See the [installation guide](apps/docs/content/docs/getting-started/install.mdx) for Windows, Linux distributions, AirPlay prerequisites, and fallback players. +## Platform coverage + +RadioCLI separates core playback, storage, terminal rendering, desktop helpers, +and native alarm services. A missing scheduler or clipboard helper leaves the +other features available. `radiocli doctor --json` reports each capability, +terminal and network policy, and runtime eligibility separately. + +The [platform matrix](apps/docs/content/docs/platforms.mdx) distinguishes required +CI targets from verified installations, experimental community paths, and known +runtime blockers. Node 22 and 24 are the reference test lines. BSD package plans, +Termux, Haiku and illumos guidance do not imply that every OS/CPU combination has +been exercised. AIX playback and Haiku's packaged Node 20 remain explicit gaps. +Linux without a usable systemd user session can still run the player and TUI; +reliable background alarms require a verified native adapter. + +Invocation preferences do not change saved settings: + +```bash +RADIOCLI_ASCII=1 radiocli # ASCII decoration; station names preserved +NO_COLOR=1 radiocli # no color or background fills +RADIOCLI_SCREEN_READER=1 radiocli # readable controls; no visualizer animation +RADIOCLI_OFFLINE=1 radiocli # cached directories and saved stations +RADIOCLI_LOW_BANDWIDTH=1 radiocli # cached atlas; no automatic receiver scan +``` + +Offline mode disables directory, location, update, vote and receiver-discovery +requests. An external player still needs a connection to listen to a live stream. +For proxy, SSH, read-only storage and custom-player paths, see +[troubleshooting](apps/docs/content/docs/troubleshooting.mdx). + ## Visual tour These recordings come from the built TUI. Generate them locally with @@ -214,6 +246,8 @@ complete data-flow description. - `src/ui` — screens, input, layout, and terminal rendering - `src/providers` — station directories, resolution, and caches - `src/player` — playback backends, metadata, and AirPlay +- `src/platform` — host identity, independent capabilities, native command plans, + paths, terminal policy, networking and runtime eligibility - `src/alarms` — schedules, native registration, Alarm Guard, and active controls - `src/storage` — local library persistence and migration - `apps/docs` — documentation website and manual @@ -231,9 +265,10 @@ npm run verify npm run dev ``` -`npm run verify` checks types, lint, tests, the production build, and package -contents. Playback and live-data smoke tests are available separately because -they contact public services or start a local player. +`npm run verify` checks types, lint, tests, the production build, and live provider +data. `npm run fresh:check -- --require-mpv` tests packed installs with and without +optional dependencies using a local WAV, real mpv IPC controls and MCP. See +[contribution checks](CONTRIBUTING.md) for documentation and packaging validation. ## Contributing @@ -244,6 +279,8 @@ include `radiocli check` output with playback reports. - [Getting started](apps/docs/content/docs/index.mdx) - [Installation](apps/docs/content/docs/getting-started/install.mdx) +- [Platform matrix](apps/docs/content/docs/platforms.mdx) +- [Troubleshooting](apps/docs/content/docs/troubleshooting.mdx) - [Controls](apps/docs/content/docs/getting-started/tui-controls.mdx) - [Architecture](apps/docs/content/docs/architecture.mdx) - [Roadmap](apps/docs/content/docs/roadmap.mdx) diff --git a/apps/docs/content/docs/architecture.mdx b/apps/docs/content/docs/architecture.mdx index 33ec211..12941a4 100644 --- a/apps/docs/content/docs/architecture.mdx +++ b/apps/docs/content/docs/architecture.mdx @@ -3,8 +3,9 @@ title: Architecture description: Runtime shape, provider adapters, playback control, TUI state, and persistence. --- -RadioCLI is a terminal-first product built around four seams: TUI state, -provider adapters, playback control, and local persistence. +RadioCLI separates TUI state, provider adapters, playback control, and local +persistence. A shared platform layer describes the host and selects native +integrations for those components. ## Runtime Shape @@ -45,6 +46,26 @@ AirPlay stream before the alarm switches to local output. If that owner cannot be reached, alarm delivery continues and runtime health records the degraded takeover instead of suppressing the alarm. +## Platform Boundaries + +The modules in `src/platform` keep host detection separate from runtime +readiness and the project's support policy: + +| Modules | Responsibility | +| --- | --- | +| `runtime.ts`, `support.ts` | Describe the OS, CPU, libc, WSL, and Termux environment; assess runtime eligibility and verified support evidence separately | +| `capabilities.ts` | Report each feature as available, unavailable, or unverified from the evidence gathered in this session | +| `paths.ts`, `storage.ts` | Preserve library and runtime namespaces; inspect storage access without creating a write probe | +| `executables.ts`, `packages.ts`, `shell.ts` | Resolve commands and construct installation or launch arguments for the host | +| `desktop.ts`, `terminals.ts`, `terminal.ts` | Select optional desktop helpers and describe terminal capabilities | +| `network.ts`, `loopback.ts` | Bound public HTTP requests and keep authenticated local control independent of public-network proxy settings | + +Finding a player, scheduler command, or terminal executable does not establish +that its audio device, user service, or desktop session works. Native operations +check their results, and `radiocli doctor` reports the available evidence. BSD, +Termux, and other experimental targets do not inherit Linux alarm adapters or a +support claim from a successful platform mock. See [Platform support](./platforms.mdx). + ## Alarm Runtime Alarm definitions are portable library data, while delivery is rebuilt from @@ -67,6 +88,12 @@ uses an occurrence lock to suppress duplicate dispatch, records the result, and then installs the next occurrence. A four-worker bound keeps bulk synchronization from spawning unbounded native commands. +An early native dispatch releases its occurrence lock without marking the +occurrence complete, so the real due dispatch can still run. If removal of a +native job fails, the adapter accepts the result only when a query confirms that +specific job is absent. An ambiguous failure keeps the definition and repair +artifacts available. + Recurring schedules store `HH:mm`, ISO weekdays, and an IANA timezone. Civil time is resolved one minute at a time: a spring-forward gap advances to the first valid minute on that civil date, while a fall-back overlap fires only the @@ -84,9 +111,15 @@ start at the configured target volume. Alarm Guard is a second detached process, separate from native scheduling. It holds the platform idle-sleep inhibitor until just after the next occurrence and uses an authenticated loopback ownership challenge so stale PID files cannot -cause an unrelated process to be signaled. Guard state, scheduler health, -occurrence locks, and active-control discovery are operational machine state, -not portable library state. +cause an unrelated process to be signaled. Unreadable ownership records and +records for live processes that fail the challenge are retained for repair; the +unverified PID is not signaled. An inhibitor lease reports an unexpected helper +exit, allowing runtime health to show that sleep protection was lost. + +System-volume leases snapshot the prior mixer state. A partial change triggers +a rollback attempt, and a failed restore leaves the lease retryable. Guard +state, scheduler health, occurrence locks, and active-control discovery are +operational machine state, not portable library state. ## Provider Layer @@ -100,7 +133,19 @@ not portable library state. Radio Browser calls use mirror fallback and a durable cache. If a live request fails and stale cached data exists, the provider returns stale data rather than -blanking the interface. +blanking the interface. A cache write failure does not discard a successful +directory response. Request deadlines remain active while response bodies are +consumed, and unused bodies are canceled where possible. + +`RADIOCLI_OFFLINE=1` blocks public requests made by RadioCLI's Node HTTP client. +Cached directory results and saved direct stream URLs remain usable. +`RADIOCLI_LOW_BANDWIDTH=1` makes the geotagged atlas cache-only; smaller directory +queries remain available. Both flags skip automatic AirPlay discovery. Offline +mode also blocks manual discovery; low-bandwidth mode allows an explicit +refresh. Authenticated local controls remain available. These policies do not +block the external player's network access when a user requests a direct +stream. Proxy configuration and the exact network boundaries are described in +[Reliability](./reliability.mdx#network-requests). Explore and Nearby share the same geospatial path: RadioCLI caches the full available Radio Browser geotagged station atlas, filters out invalid coordinates, @@ -113,7 +158,7 @@ so map exploration is not capped to globally popular stations. `PlayerController` owns playback process lifecycle. - Default local output: `mpv` -- Fallback backend: `ffplay` for playback only +- Playback-only fallbacks: `ffplay` and VLC - Experimental macOS output: AirPlay/RAOP through Bonjour discovery, FFmpeg, and RadioCLI's bundled sender bridge - `mpv` is controlled over JSON IPC for pause, mute, volume, readiness checks, @@ -130,11 +175,11 @@ other non-Radio-Browser stations use their exposed stream URL. Once a URL is resolved, `PlayerController` starts `mpv` with `--no-video`, `--force-window=no`, a local JSON IPC endpoint, a forced media title, and the -configured volume. That endpoint is a Unix socket on macOS/Linux and a named -pipe on native Windows. `ffplay` is available as a playback-only fallback, but it -does not provide a reliable IPC surface for pause, mute, volume, play/pause -media-key handling, metadata, or readiness checks. RadioCLI labels that mode as -limited instead of updating UI state optimistically. +configured volume. That endpoint is a named pipe on native Windows and a Unix +socket on Unix-like targets whose runtime supports it. `ffplay` and VLC are +playback-only fallbacks; they do not provide a reliable IPC surface for pause, +mute, volume, play/pause media-key handling, metadata, or readiness checks. +RadioCLI labels that mode as limited instead of updating UI state optimistically. On macOS, the optional AirPlay backend discovers RAOP receivers with Bonjour and streams through a child worker that feeds decoded PCM from `ffmpeg` into a @@ -215,11 +260,30 @@ The store is local JSON. It keeps: - alarm definitions, next snooze override, and recent run outcome - settings -Corrupt store/cache files are renamed with a `.bad-*` suffix before defaults are -used, so user data is not silently overwritten. - -Writes use a temp-file-and-rename flow so interrupted writes are less likely to -leave partial JSON behind. +The library distinguishes missing data, malformed JSON or schema data, and an +unreadable file. A missing library starts with defaults. Corrupt +library bytes must first be preserved under a `.bad-*` name; a failed backup +leaves the original file in place and reports an error. Permission and I/O +failures are never treated as corruption or as a missing current library that +allows fallback to an older legacy file. Provider caches use separate best-effort +recovery. + +Library writes take a lock, merge concurrent changes, write a private temporary +file beside the destination, and atomically rename it into place. POSIX file +permissions are set before replacement. A failed write preserves the previous +disk and in-memory state, and cleanup cannot mask the original error. Windows +uses the user directory's ACLs rather than POSIX modes. + +A readable library remains usable when its file or directory is read-only. The +TUI catches optional recent, listening, track, search, and update-history save +failures separately from playback and navigation. Stop, sleep-timer expiry, +alarm takeover, and quit still stop the player. Explicit settings and favorite +changes report a failed save without claiming success. Error messages suggest +a writable `RADIOCLI_HOME` without exposing the private path. + +The platform changes introduce no new library or backup schema. Existing +`RADIOCLI_HOME`, `RADIO_ATLAS_HOME`, and legacy Radio Atlas locations retain +their meaning, including the existing macOS runtime namespaces. Full library backup/restore includes alarm definitions. Native scheduler files, runtime health, Alarm Guard state, occurrence locks, and authenticated local diff --git a/apps/docs/content/docs/contributing.mdx b/apps/docs/content/docs/contributing.mdx index b8f5d66..2c5e9f8 100644 --- a/apps/docs/content/docs/contributing.mdx +++ b/apps/docs/content/docs/contributing.mdx @@ -15,18 +15,22 @@ npm run build npm run test ``` -Install `mpv` for the best playback experience: +Install `mpv` for playback controls and metadata polling. On a macOS +configuration supported by Homebrew: ```bash brew install mpv ``` -On Linux, use your package manager's `mpv` package. On Windows, use: +On Linux, use your package manager's `mpv` package. On Windows x64, use: ```powershell winget install --id shinchiro.mpv -e ``` +Use the [platform installation routes](platforms.mdx#installation-and-playback-prerequisites) +for Windows arm64 and other package managers. + ## Verification Run these before opening a pull request: @@ -36,17 +40,42 @@ npm run check npm run lint npm run test npm run build +npm run smoke:mcp +npm run pack:check +npm run check:package +npm run fresh:check -- --require-mpv +npm run docs:check +npm run docs:build +node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs ``` -Run `npm run smoke:playback` when changing playback code. It opens a public -stream briefly, so do not run it in environments where audio or network access -is not acceptable. +Run `npm run smoke:playback` when changing playback code. By default it generates +a local silent WAV and exercises an installed player. The packed smoke uses +real mpv with null audio and verifies IPC controls. A requested external stream +is a separate live-network test. + +Run `npm ci` in `apps/docs` before the documentation checks. Test maintained +Node 22/24 lines separately, and keep dependency installation separate from +concurrent tests that use the same `node_modules` directory. Live provider checks are useful before releases, but ordinary pull requests -should stay deterministic. Run `npm run verify:release` for a full maintainer +should stay deterministic. Run `npm run smoke:data` explicitly for live directory +verification. Run `npm run verify:release` for a full maintainer pre-release pass that includes live data, playback, packaging, and fresh-install checks. +For platform changes, record OS/product version, kernel, CPU and endianness, +libc where relevant, exact Node/player versions, artifact hash, and whether +execution was native, a same-CPU guest, foreign-CPU emulation, or a mock. +Compilation, a lockfile entry, or a downloaded executable cannot establish +runtime support. Use the [platform matrix](platforms.mdx) tier requirements. + +Review visual changes against the fixed-size captures described in +[Design](design.mdx#visual-regression-captures). Explain intentional changes and +preserve rich-terminal fixtures when adding degraded rendering. Exercise +permission failures and missing optional tools without weakening core playback, +authentication, or cleanup behavior. + ## Development Principles - Keep public provider adapters isolated. diff --git a/apps/docs/content/docs/design.mdx b/apps/docs/content/docs/design.mdx index 37be53a..4ada929 100644 --- a/apps/docs/content/docs/design.mdx +++ b/apps/docs/content/docs/design.mdx @@ -1,6 +1,6 @@ --- title: Design -description: Product rationale for Ink, mpv, provider adapters, location behavior, and the receiver UI. +description: Product rationale for the receiver UI, terminal adaptation, playback backends, and provider adapters. --- RadioCLI is meant to feel like a product, not a terminal proof of concept. @@ -17,10 +17,10 @@ Radio streams are messy: redirects, playlists, HLS, odd codecs, and flaky metadata are normal. `mpv` already handles that complexity well and exposes JSON IPC for controls and metadata. -`ffplay` remains as a playback fallback because it is often already installed -with FFmpeg, but it does not provide the control and state surface RadioCLI needs -for a polished interactive receiver. `mpv` is the intended backend for pause, -mute, volume, media keys, metadata, and readiness checks. +`ffplay` and VLC provide playback fallbacks when `mpv` is unavailable. Their +limited control and state reporting is reflected in the UI. `mpv` is the +intended backend for pause, mute, volume, media keys, metadata, and readiness +checks. ## Why Provider Adapters @@ -48,9 +48,10 @@ The UI borrows from old receiver and car head-unit displays: direct labels, compact hierarchy, visible status, and no fake copy. The app should be usable in a terminal session, not a landing page rendered in ANSI. -The app shell paints its own near-black background instead of relying on the -terminal profile color. That keeps the receiver, tabs, stats panels, and footer -visually cohesive across terminal themes. +On terminals with sufficient color support, the app shell paints a near-black +background that keeps the receiver, tabs, stats panels, and footer visually +cohesive. Transparent Background, limited-color terminals, and `NO_COLOR` +let the terminal keep its own background. The Now Playing screen supports multiple visualizer styles so the display can feel like a receiver without locking the product into one animation. The default @@ -81,6 +82,56 @@ and computes local distance inside the app. The list reports the radius covered by the returned stations so the user can tell whether a cursor position is dense, sparse, or limited by provider geotag coverage. +## Terminal Adaptation + +Full, compact, and micro layouts keep the current selection and keyboard actions +available as space shrinks. Compact and micro views shorten labels and adjust +the receiver and list areas. The selected station's full name remains available +to screen-reader output. + +Unicode and rich color are retained when the terminal supports them. Explicit +ASCII settings or a bare `C`/`POSIX` locale replace decorative glyphs; UTF-8 +locales and SSH sessions retain Unicode. ASCII mode preserves international +station text. Sixteen-color terminals drop the dark fills, and `NO_COLOR` +removes color entirely. + +Reduce Motion stops receiver animation, loading animation, and scrolling labels. +Screen-reader mode also omits receiver visualizers and mouse reporting while +keeping keyboard controls and text status. `TERM=dumb` produces static frames +with ASCII decoration and no color. These environment adaptations apply to the +current launch and leave saved appearance preferences intact. The exact +overrides are listed in [TUI Controls](getting-started/tui-controls.mdx). + +### Visual Regression Captures + +The portability work began with 578 deterministic Ink captures. They cover +every screen and settings group, all receiver styles at full size, +representative receivers at smaller sizes, Unicode and ASCII decoration, +output without color, and focused ANSI color and selection checks. The capture +fixtures use these sizes: + +| Layout | Columns × rows | +| --- | --- | +| Full | 100 × 30 | +| Compact | 50 × 16 | +| Micro | 24 × 8 | + +All 335 Unicode/ANSI captures remain unchanged. Of the 243 ASCII captures, +160 intentionally changed to remove remaining block characters, small circles, +bullets, and ellipses; 83 were already compatible and remain unchanged. + +| Capture | Before | After | +| --- | --- | --- | +| Full Overview logo | `████` | `####` | +| Compact receiver status | `playing · vol 70` | `playing . vol 70` | +| Micro Settings header | `Settings · Choose …` | `Settings . Choose .` | + +The repository contains [full before-and-after terminal text](https://github.com/Ciphore/RadioCLI/blob/master/docs/cross-platform/visual-before-after.txt) +and the [complete snapshot suite](https://github.com/Ciphore/RadioCLI/blob/master/src/ui/visual-baseline.test.tsx). +Time, timezone, station data, and animation frame are fixed. These captures +verify renderer output; native font, audio, and desktop integration results are +tracked separately in [Platforms](/docs/platforms). + ## Navigation And Stats The horizontal tab rail is intentionally dense: it keeps the app feeling like a diff --git a/apps/docs/content/docs/getting-started/install.mdx b/apps/docs/content/docs/getting-started/install.mdx index 59c08fc..b24cda0 100644 --- a/apps/docs/content/docs/getting-started/install.mdx +++ b/apps/docs/content/docs/getting-started/install.mdx @@ -14,17 +14,20 @@ radiocli setup radiocli ``` -On macOS, Homebrew is the all-in-one option because the formula also installs -Node.js, `mpv`, and FFmpeg: +On macOS configurations supported by Homebrew, its formula also installs Node.js, +`mpv`, and FFmpeg: ```bash brew install ciphore/tap/radiocli radiocli ``` -Both paths install the same `radiocli` command. The npm package is the universal -option; its guided `radiocli setup` step installs selected system playback tools -through Homebrew, WinGet, Scoop, Chocolatey, or the detected Linux package manager. +Both paths install the same `radiocli` command. The npm package also has +experimental community installation paths described below. `radiocli setup` +installs selected system playback tools only after showing a plan and receiving +confirmation or `--yes`. npm lifecycle scripts never install system packages. +Consult the [platform matrix](../platforms.mdx) before selecting a runtime or +claiming that a particular OS/CPU combination has been verified. ## Upgrade An Existing Installation @@ -49,7 +52,8 @@ older registration is already stale, run `radiocli mcp status` followed by RadioCLI needs: -- Homebrew on macOS: installs RadioCLI, Node.js, `mpv`, and FFmpeg +- Homebrew on its supported macOS configurations: installs RadioCLI, Node.js, + `mpv`, and FFmpeg - npm, pnpm, or Bun on macOS, Linux, and Windows: Node.js 22 or newer - `mpv` for playback, pause, mute, volume, media keys, metadata, and readiness checks @@ -71,19 +75,22 @@ npx @ciphore/radiocli This is the fastest way to try the TUI. You still need `mpv`, `ffplay`, or VLC for audio — run `npx @ciphore/radiocli doctor` to check. -## Recommended: macOS With Homebrew +## macOS With Homebrew -Homebrew is the lowest-friction macOS path. The tap formula installs RadioCLI, -Node.js, `mpv`, and FFmpeg through Homebrew: +The tap formula installs RadioCLI, Node.js, `mpv`, and FFmpeg through Homebrew. +Check Homebrew's current OS/CPU policy; Intel and older macOS installations may +need a compatible Node distribution and MacPorts or another native player route. +See the [platform matrix](../platforms.mdx#installation-and-playback-prerequisites). ```bash brew install ciphore/tap/radiocli radiocli ``` -Run `radiocli doctor` any time you want to verify local playback readiness. +Run `radiocli doctor` to check executable discovery and launch diagnostics. +Tuning a station verifies the actual audio path. -## Universal npm Install +## npm Install The npm package is `@ciphore/radiocli`, and the installed executable is `radiocli`. npm installs RadioCLI and its JavaScript dependencies only; it does @@ -97,7 +104,7 @@ radiocli The setup receiver detects tools that are already installed, offers `mpv`, FFmpeg/`ffplay`, and VLC, shows every native command before it runs, and finishes -with playback verification. `mpv` is selected by default everywhere. FFmpeg is +by checking executable availability. `mpv` is selected by default everywhere. FFmpeg is also selected by default on macOS because it enables experimental AirPlay. ```text @@ -127,8 +134,9 @@ radiocli setup --mcp --headless-agent # opt out of external terminal windows radiocli setup --no-mcp # disable/remove MCP entries ``` -Agent playback opens the RadioCLI TUI in a terminal window by default on every -platform. On macOS, the first request from each agent host (for example, ChatGPT) +Agent playback requests the RadioCLI TUI in a supported installed graphical +terminal. Missing desktop/session support is reported explicitly; select +`--headless-agent` for command-only playback. On macOS, the first request from each agent host (for example, ChatGPT) also produces Apple's Automation consent dialog. RadioCLI cannot grant that permission on the host's behalf; approve it when prompted. If a user does not want external terminal windows or OS app-control prompts, choose headless mode @@ -137,7 +145,9 @@ during setup or under **Settings → Agent control & MCP**. RadioCLI uses an animated activity bar and elapsed time while each package manager runs. Package managers do not report a portable, reliable percentage, so the bar intentionally shows activity rather than inventing completion data. -In redirected output or CI, setup emits plain start/result log lines instead. +In redirected output, `TERM=dumb`, screen-reader or reduced-motion mode, setup +emits plain start/result log lines instead. ASCII mode and the terminal's color +depth also apply to setup. pnpm and Bun can install the same package for users who already use those package managers: @@ -159,8 +169,9 @@ brew install ffmpeg ``` RadioCLI discovers AirPlay/RAOP receivers with Bonjour and decodes streams with -FFmpeg. The sender bridge is bundled with RadioCLI, so users should not install -extra AirPlay packages manually. Passcode-protected receivers prompt in the TUI; +FFmpeg. The sender is an optional package dependency and must install and load +successfully; `--omit=optional` leaves it out. Sender diagnostics report an absent +or unusable bridge. Passcode-protected receivers prompt in the TUI; enter the code with `:airplay-code 1234` or the AirPlay Code screen. RadioCLI requires an explicit AirPlay receiver selection from Settings or `:airplay` before it tunes through AirPlay. Changing Audio output while a station is @@ -197,9 +208,16 @@ radiocli setup radiocli ``` +On Alpine use a musl-native Node package; a glibc binary is not interchangeable. +Alpine and other musl targets remain experimental until the selected CPU and +runtime have repeatable execution evidence. On OpenRC, runit, a container, or any +system without an accessible systemd user manager, the player, library and TUI +remain usable. Background alarm scheduling is unavailable; RadioCLI does not +substitute cron/at jobs with weaker occurrence or snooze guarantees. + ## Windows -Use Windows Terminal or PowerShell with Node.js 22 or newer and native `mpv`: +Use Windows Terminal or PowerShell with a native Node 22/24 runtime and `mpv`: ```powershell winget install --id OpenJS.NodeJS.LTS -e @@ -222,8 +240,80 @@ scoop bucket add extras scoop install mpv ``` -WSL remains supported through the Linux install path, but native Windows is the -recommended Windows Terminal path. +Windows x86 has a Node 22 route; Node 24 no longer provides it. Windows ARM64 +needs matching native binaries: the WinGet mpv recipe above is x64-only, while +the Node LTS recipe and Scoop's mpv package have ARM64 routes. These catalogs do not establish +runtime or audio verification on their own. Windows XP and older Windows +versions below the standard runtime minimum have no legacy client. + +WSL uses the Linux installation path and remains experimental. Its Linux audio +path, user manager and graphical environment need separate verification; +Windows package discovery does not provide Linux playback tools. + +## BSD And Additional Runtimes + +Install a native Node 22+ runtime and matching npm first, then install the same +npm package. These are package instructions, with verification status in the +[platform matrix](../platforms.mdx). No new background alarm adapter is provided +for these systems. + +| Environment | Explicit prerequisite path | Limitations | +| --- | --- | --- | +| FreeBSD | As root: `pkg install -y node22 npm-node22 mpv` | Confirm packages for the OS/CPU; ffplay needs FFmpeg's SDL option. | +| OpenBSD | `doas pkg_add -I node mpv` | Packaged Node must be 22+; use the release's own repository. | +| NetBSD/pkgsrc | Install the matching X11 base set, then `pkgin -y install 'nodejs>=22<23' mpv` | The binary mpv package links to OS X11 libraries even for headless audio. Configure the correct pkgsrc repository; run as root or through a privilege helper. | +| Android/Termux | `pkg install -y nodejs-lts npm mpv` | Run as the app user without sudo/root; Android audio/background limits apply. | +| Haiku | `pkgman install -y mpv` | HaikuPorts' Node 20 is below the minimum; a working custom Node 22+ port is required before RadioCLI can run. | +| illumos/SmartOS | `pkgin -y install 'nodejs>=22<23' mpv` | Requires a configured compatible pkgsrc catalog; IPS `pkg` is a different tool and is not used. | +| AIX | Provision a compatible native Node 22/24 runtime manually | No verified mpv/ffplay package recipe or RadioCLI runtime/audio evidence. DNF availability alone is insufficient. | + +Setup detects FreeBSD `pkg`, OpenBSD `pkg_add`, pkgsrc `pkgin`, Haiku `pkgman`, +and a distinct `termux-pkg` recipe that invokes Termux's `pkg`. For example: + +```bash +radiocli setup --only=mpv --dry-run +radiocli setup --package-manager=termux-pkg --only=mpv --dry-run +``` + +Do not select the FreeBSD `pkg` recipe on Termux or illumos. Setup checks the host +before invoking these recipes and uses root, sudo or doas where applicable. +Termux's main FFmpeg package omits ffplay; enabling an X11 repository and its +display/audio services remains a manual choice. Haiku's `ffmpeg8_tools` recipe +does not resolve its Node runtime blocker. pkgsrc uses versioned FFmpeg/ffplay +pairs, so setup reports that component as manual rather than guessing a package. +On a minimal NetBSD installation, select the matching X11 base distribution set +through the OS installation tools before installing mpv. See NetBSD's +[distribution-set instructions](https://www.netbsd.org/docs/guide/en/chap-exinst.html); +the package manager cannot supply missing base-system X11 libraries itself. + +The recorded BSD guests installed a local packed tarball into fresh projects, +omitting the optional native macOS AirPlay sender. After providing the matching +Node/npm/player prerequisites, a manual registry installation uses: + +```bash +npm install -g --omit=optional @ciphore/radiocli +``` + +## Player Paths And Constrained Sessions + +Set an absolute executable path when the player is outside the standard search +locations. The value is a single executable, with no shell or appended arguments: + +```bash +RADIOCLI_MPV_PATH='/opt/Audio tools/mpv' radiocli doctor +``` + +PowerShell uses `$env:RADIOCLI_MPV_PATH = 'C:\Audio tools\mpv.exe'` before the +command. The other overrides are `RADIOCLI_FFPLAY_PATH`, `RADIOCLI_VLC_PATH` and +`RADIOCLI_FFMPEG_PATH`. Invalid explicit paths produce diagnostics instead of +silently choosing another executable. Run `radiocli alarm sync` after changing +the paths retained by native alarm jobs. + +For SSH, use a terminal allocation such as `ssh -t` for the TUI, or use CLI/MCP +commands and explicit headless playback. Browser, clipboard and terminal +reopening depend on the actual desktop session. See +[troubleshooting](../troubleshooting.mdx) for ASCII/color controls, offline caches, +proxies, IPv6 and writable data directories. ## Optional ffplay Fallback @@ -248,8 +338,9 @@ control. In `ffplay` mode RadioCLI labels the backend as `ffplay fallback`, shows limited-control footer text, and `radiocli doctor` reports `controls=limited`. -CI covers command-mode typecheck, tests, builds, package checks, and fresh -install smoke checks on Ubuntu, macOS, and Windows. +The required CI matrix covers Node 22/24 on Ubuntu, macOS and Windows with native +x64/arm64 jobs and packed-install mpv checks. BSD guest jobs are opt-in/weekly. +The platform matrix records actual results and the limits of those checks. ## Alarm Scheduling Requirements @@ -260,7 +351,7 @@ RadioCLI on supported hosts: - Native Windows uses Task Scheduler interactive-token tasks. - Linux requires a working systemd user manager and `systemctl --user`. -Linux systems without systemd user scheduling are reported as unsupported; WSL +Background alarms are unavailable without systemd user scheduling on Linux; WSL must provide a working user manager and an interactive audio path to use alarms. All platforms require a logged-in audio session and local speakers when the job runs. The terminal itself may close after an alarm is saved and registered. diff --git a/apps/docs/content/docs/getting-started/tui-controls.mdx b/apps/docs/content/docs/getting-started/tui-controls.mdx index 03f6dc5..151b411 100644 --- a/apps/docs/content/docs/getting-started/tui-controls.mdx +++ b/apps/docs/content/docs/getting-started/tui-controls.mdx @@ -1,6 +1,6 @@ --- title: TUI Controls -description: Navigation, playback, settings, command palette, and learned media keys. +description: Navigation, playback, terminal rendering, accessibility, and learned media keys. --- RadioCLI keeps shortcuts at the bottom of the terminal. When playback is active, @@ -26,7 +26,7 @@ volume or mute state, and an active sleep timer. | `q` or `Ctrl+C` twice | Quit cleanly | Pause, mute, volume, and play/pause media-key control require the `mpv` backend. -When only `ffplay` is active, RadioCLI keeps playback and station skipping +When `ffplay` or VLC is active, RadioCLI keeps playback and station skipping available but labels interactive playback controls as limited and shows an `Install mpv` warning instead of pretending the control worked. @@ -56,7 +56,8 @@ Explore keeps an aspect-correct Braille world map in full, compact, and micro layouts. Compact windows always place stations beside the map, while true micro places the selected station below it. Long selected station and metadata lines scroll after a short pause in compact and micro layouts. Enable Reduce Motion to -keep those lines truncated instead. +keep those lines truncated instead. ASCII mode substitutes plain characters for +the map's decorative glyphs. Press `?` from any screen to open Help, a full reference of keybindings and `:` commands. In the command palette, `Tab` completes the command name. @@ -175,17 +176,63 @@ Useful command entries: ## Display And Compatibility Modes Settings includes toggles for terminals and devices that need them, each also -available as a command: +available as a command. These choices are saved in your local library: - **Resume last station on launch** (`:resume on`) re-tunes your most recent station when RadioCLI starts, like a radio powering back on. - **Transparent background** (`:background on`) drops the dark panel fill so - light-themed terminals keep their own background. RadioCLI also honors the - [`NO_COLOR`](https://no-color.org) convention automatically. + light-themed terminals keep their own background. - **ASCII-safe display** (`:ascii on`) maps braille, block, and box-drawing - glyphs to ASCII for terminals and fonts without those glyphs. + glyphs, borders, and decorative punctuation to ASCII. International station + names and metadata remain intact; this is not a transliteration setting. - **Reduce motion** (`:motion on`) freezes the receiver and loading animations - to save CPU and bandwidth over SSH and on low-power devices. + and keeps long lines truncated instead of scrolling. + +RadioCLI retains its Unicode receiver display on terminals that support it, +including UTF-8 sessions over SSH. Locale detection checks the first non-empty +value in `LC_ALL`, `LC_CTYPE`, then `LANG`. A bare `C` or `POSIX` locale selects +ASCII decoration; UTF-8 locales such as `C.UTF-8` do not. SSH alone does not +change the display mode. + +Environment variables override rendering for that launch without rewriting +saved appearance settings: + +| Variable | Effect | +| --- | --- | +| `RADIOCLI_ASCII=1` | Force ASCII decoration | +| `RADIOCLI_UNICODE=1` | Force Unicode decoration unless ASCII is also explicitly enabled or `TERM=dumb` | +| `NO_COLOR=1` | Remove foreground colors and background fills; any non-empty value enables this | +| `FORCE_COLOR=0`, `1`, `2`, or `3` | Select no color, 16 colors, 256 colors, or truecolor | +| `RADIOCLI_DISABLE_ANIMATION=1` | Freeze receiver and loading animations and stop scrolling text | +| `RADIOCLI_SCREEN_READER=1` or `INK_SCREEN_READER=true` | Enable screen-reader output with text status, no receiver visualizers, no animation, and no mouse reporting | +| `TERM=dumb` | Show static frames with ASCII decoration and no color or animation | + +Detected 16-color terminals retain their own background; 256-color and truecolor +terminals can use the dark receiver panels. `NO_COLOR`, screen-reader mode, and +`TERM=dumb` take precedence over `FORCE_COLOR`. + +For one launch in a POSIX shell: + +```bash +RADIOCLI_ASCII=1 RADIOCLI_DISABLE_ANIMATION=1 radiocli +``` + +In PowerShell, set the variables for the current shell before launching: + +```powershell +$env:RADIOCLI_SCREEN_READER = '1' +radiocli +``` + +Screen-reader mode keeps keyboard navigation, station and playback labels, +selection, and command entry available. Decorative receiver frames are omitted, +and long labels are exposed as text without a scrolling marquee. + +The TUI requires interactive keyboard input, including with `TERM=dumb`. +Use [CLI commands](cli.mdx) such as `radiocli doctor --json` and +`radiocli search "jazz"` in pipelines or scripts. For display problems, see +[Troubleshooting](../troubleshooting.mdx); runtime and optional feature coverage +is listed under [Platforms](/docs/platforms). ## Source-List Queues diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 6ba0170..9865d97 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -3,11 +3,13 @@ "pages": [ "index", "getting-started", + "platforms", "alarms", "demo", "architecture", "design", "reliability", + "troubleshooting", "privacy-security", "contributing", "release-packaging", diff --git a/apps/docs/content/docs/platforms.mdx b/apps/docs/content/docs/platforms.mdx new file mode 100644 index 0000000..3b2f8e0 --- /dev/null +++ b/apps/docs/content/docs/platforms.mdx @@ -0,0 +1,220 @@ +--- +title: Platforms +description: Runtime requirements, installation routes, native feature limits, and the evidence behind RadioCLI's platform tiers. +--- + +RadioCLI requires Node.js 22 or newer. Local audio needs a working player. +The reference matrix below covers Node.js 22 and 24. Runtime eligibility, +installed tools, and verified RadioCLI behavior are tracked separately for each +OS version, CPU, libc, and Node release line. + +This evidence snapshot was reviewed on **September 7, 2026**. Native runner +and full BSD guest results are recorded separately below. Run +`radiocli doctor --json` for the capabilities detected on your own machine; +use `radiocli setup --dry-run` to inspect its playback installation plan. + +## Project Tiers And Current Evidence + +| Tier | Required evidence | +| --- | --- | +| First-class | A repeatable installation with verified prerequisites, packed runtime and playback checks on the target, and maintained required CI whose failures block releases | +| Supported | A verified, repeatable runtime, packed installation, and playback path without required CI coverage | +| Experimental | A code path or package route exists, but project verification is incomplete | +| Unsupported | A known requirement blocks the configuration, such as Node.js below 22 or an incompatible OS/CPU runtime | + +A package recipe, a mocked platform test, or a downloaded binary does not +establish a verified installation. A Linux container exercises its host kernel; +a full BSD guest exercises a BSD kernel. Foreign-CPU emulation is recorded as +emulation. Each result applies to the configuration and artifact actually tested. + +The upstream Node rating is a separate axis: repeatable project evidence can +establish a supported configuration on a community runtime. Known runtime +requirements still apply. + +Doctor reports `support.scope=current-installation`. It checks runtime +requirements and available tools without running packed-install or audio +acceptance tests, and does not import a reference machine's CI evidence. Its +`support.tier` therefore remains experimental for an unverified local installation, +even when the reference configuration below is supported or first-class. A known +runtime blocker is reported as unsupported. Individual capability results explain +what can be used in the current session. + +| Configuration | Recorded evidence | Current project tier | +| --- | --- | --- | +| macOS 26.5.2, Darwin 25.5.0, arm64; Node 22.23.2 and 24.20.0 | Local packed installs passed with normal dependencies and with `--omit=optional`; real mpv decoded a generated WAV with `ao=null`, and IPC volume, pause, resume, and stop passed. MCP checks exposed 31 tools. | Supported for the recorded local builds | +| macOS 26.6.2 / Darwin 25.6.0, arm64; Node 22.23.2 and 24.20.0 | Required native CI passed the complete tests and both packed-install/playback modes. | First-class for these reference configurations and recorded capabilities | +| Ubuntu 24.04.4 glibc, kernel 6.17.0-1022-azure, x64 and arm64; Node 22.23.2 and 24.20.0 | All four required native combinations passed complete tests, both packed-install/playback modes, and separate source playback jobs. | First-class for these reference configurations and recorded capabilities | +| macOS 15.7.9 / Darwin 24.6.0, Intel x64; Node 22.23.2 and 24.19.0 | Required native CI passed complete tests and both packed-install/playback modes. | First-class for these reference configurations and recorded capabilities | +| Windows Server 2025 Datacenter, kernel 10.0.26100, x64; Node 22.23.2 and 24.19.0 | Required native CI passed regression tests, real PowerShell literal-argument and independent-console TTY checks, and both packed-install/mpv named-pipe playback modes. | First-class for these reference configurations and recorded capabilities; Windows 10/11 x64 desktop editions need separate verification | +| Windows 11 Enterprise, kernel 10.0.26200, arm64; Node 22.23.2 and 24.20.0 | Required native CI passed the same Windows tests and both packed-install/playback modes using a native arm64 mpv binary. | First-class for these reference configurations and recorded capabilities | +| FreeBSD 14.4-RELEASE x64, Node 22.23.2/npm 11.19.1; OpenBSD 7.9 x64, Node 22.23.2/npm 10.9.8 | Repeated full QEMU guest runs passed packed CLI/cache/import/export/MCP checks and real mpv 0.41 null-output IPC playback with `--omit=optional`. | Supported for this explicit core package route; desktop/TUI applications and background native integrations remain unverified or unavailable | +| NetBSD 10.1 x64, Node 22.23.0/npm 10.9.8 | Repeated full QEMU guest runs passed the same core checks with `--omit=optional`, after installing the matching OS X11 base libraries. | Supported for this explicit core package route; desktop/TUI applications and background native integrations remain unverified or unavailable | +| Alpine/musl, WSL, other Linux CPUs, other BSD CPUs, Termux, illumos, AIX | Runtime or package routes have been reviewed; no matching RadioCLI installation and playback result is recorded here. | Experimental; not yet verified | +| Standard HaikuPorts Node 20 installation; Node below 22; Windows XP; 32-bit macOS; Windows x86 with Node 24 | The normal runtime route does not meet RadioCLI's requirement. A Haiku player package does not resolve its Node version blocker. | Unsupported for these configurations | + +The successful expanded workflow contains 12 native OS/CPU/Node combinations: macOS +arm64 and Intel, Linux x64 and arm64, and Windows x64 and arm64, each on Node +22 and 24. All required jobs and three BSD guests passed at the `fd57fda` code +checkpoint. Required checks must remain release gates; merely configuring a job +does not establish first-class status. +Windows Server runners do not verify Windows 10/11 desktop behavior; a Node 22 +x86 process under WoW64 would not verify a native 32-bit Windows installation. +See the [GitHub runner definitions](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) +and the [recorded expanded CI run](https://github.com/MrSeizy/RadioCLI/actions/runs/34180703652). +The [draft PR](https://github.com/Ciphore/RadioCLI/pull/36) links the full +verification record, exact npm versions and tarball hashes, and verification +of its final documentation commit. + +The recorded mpv checks verify decoding and IPC through a null audio output. +Physical audio devices, terminal applications, screen-reader applications, +desktop window opening, and actual wake delivery require their own verification. +The [578 terminal captures](design.mdx#visual-regression-captures) verify +renderer output at fixed sizes. + +## Node.js Runtime Reference + +The version numbers here are upstream binary or build baselines. Node.js also +excludes OS releases whose vendor support has ended, even if they meet a listed +minimum. A newer Node major may meet RadioCLI's package minimum while remaining +outside this Node 22/24 reference matrix. The primary references are the +[Node 22 platform list](https://github.com/nodejs/node/blob/v22.x/BUILDING.md#platform-list) +and [Node 24 platform list](https://github.com/nodejs/node/blob/v24.x/BUILDING.md#platform-list). + +| System and CPU | OS / libc baseline | Node 22 upstream status | Node 24 upstream status | +| --- | --- | --- | --- | +| macOS x64 / arm64 | macOS 11 for Node 22; macOS 13.5 for Node 24 | Tier 1 | Tier 1 | +| Windows x64 | Windows 10 / Server 2016 or newer | Tier 1 | Tier 1 | +| Windows arm64 | Windows 10 or newer | Tier 2 | Tier 2 | +| Windows x86 | Windows 10 or newer; upstream tests use WoW64 | Binaries provided; running is Tier 1, native compilation experimental | No Windows x86 runtime | +| glibc Linux x64 / arm64 | Kernel 4.18+, glibc 2.28+ | Tier 1 | Tier 1 | +| glibc Linux ARMv7 | Kernel 4.18+, glibc 2.28+ | Tier 1 | Experimental; community build needed | +| glibc Linux ppc64le / s390x | Kernel 4.18+, glibc 2.28+; POWER8+ for ppc64le | Tier 2 | Tier 2 | +| glibc Linux x86 | Kernel 3.10+, glibc 2.17+ | Experimental | Experimental | +| glibc Linux riscv64 | Node 24 reference: kernel 5.19+, glibc 2.36+ | Not listed; community route needs verification | Experimental | +| Linux big-endian ppc64 | A compatible community Node 22+ port must be established | No standard upstream route listed | No standard upstream route listed | +| musl Linux x64, including Alpine | Kernel 3.10+, musl 1.1.19+; use a musl build | Experimental | Experimental | +| musl Linux arm64 / s390x; ARMv7 on Node 22 | Distribution-specific build and runtime libraries | Distribution route; verify the selected package | Distribution route for arm64 / s390x; ARMv7 unverified | +| WSL | A compatible Linux runtime inside the selected WSL distribution | WSL-specific issues are outside upstream native Linux support | Same limitation | + +The glibc release binaries also need a compatible libstdc++; the reference +symbol version is `GLIBCXX_3.4.25`, or `GLIBCXX_3.4.28` for Node 22 ARMv7. +Linux ppc64le is little-endian; it is distinct from both Linux big-endian ppc64 +and the big-endian POWER runtime used by AIX. A generic `arm` label alone does +not establish ARMv7 compatibility. + +Node 24 now publishes x64-musl archives while retaining an experimental musl +rating. Its current release files do not include ARMv7 binaries. Use the +[Node 24 release manifest](https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt) +and [distribution image architecture records](https://github.com/nodejs/docker-node/blob/main/versions.json) +to check the available build; a glibc Linux archive is not a musl or Android +Bionic archive. Distribution images do not establish RadioCLI audio or desktop +verification. + +### Additional OS Runtime Routes + +| System | OS / CPU reference | Node route and limit | +| --- | --- | --- | +| FreeBSD | Upstream experimental baseline: FreeBSD 13.2+ x64; verified guest: 14.4 x64 | Node 22/24 ports exist. Other CPUs use port-specific routes; an architecture allowlist does not establish a binary package for every release. | +| OpenBSD | Release and CPU must provide Node 22+; verified guest: 7.9 x64 | The current Node port is 24.x and lists amd64, aarch64, i386, powerpc64, and riscv64. Release binary repositories can supply an older Node; the recorded 7.9 guest supplied Node 22.23.2. | +| NetBSD | Release-specific pkgsrc packages; selected guest: 10.1 x64 | Node 22/24 pkgsrc recipes exist. The guest supplied Node 22.23.0. Other CPUs need separate package and runtime verification. | +| Android / Termux | Android 7+ with Termux's Bionic packages | `nodejs-lts` currently provides Node 24. Recipe targets include aarch64, arm, i686, and x86_64; device execution is unverified. Android outside Termux has no established RadioCLI install route. | +| SmartOS | Upstream reference: SmartOS 18+ x64 | Node 22/24 list Tier 2; use a suitable maintained distribution or pkgsrc build. | +| Other illumos distributions | x64 package routes depend on the distribution | Generic SunOS kernel 5.11 does not identify SmartOS or prove its version requirements. Verify the selected Node port and backend. | +| AIX | AIX 7.2 TL04+, big-endian POWER8+ | Node 22/24 list Tier 2; Node 24 also needs libstdc++12. A native player installation route remains unverified. | +| Haiku | A custom working Node 22+ port would be required | The inspected HaikuPorts catalog provides Node 20.15.1, below RadioCLI's minimum. The normal package route is blocked. | + +These routes come from the [FreeBSD Node ports](https://github.com/freebsd/freebsd-ports/blob/main/www/node22/Makefile), +[OpenBSD Node port](https://github.com/openbsd/ports/blob/master/lang/node/Makefile), +[pkgsrc Node 22](https://github.com/NetBSD/pkgsrc/blob/trunk/lang/nodejs22/Makefile) +and [Node 24 recipes](https://github.com/NetBSD/pkgsrc/blob/trunk/lang/nodejs24/Makefile), +[Termux installation requirements](https://github.com/termux/termux-app#installation), +[Termux Node LTS recipe](https://github.com/termux/termux-packages/blob/master/packages/nodejs-lts/build.sh), +and [HaikuPorts Node catalog](https://github.com/haikuports/haikuports/tree/master/net-libs/nodejs). +They describe available routes, not completed RadioCLI tests. + +## Installation And Playback Prerequisites + +After installing a compatible Node runtime and npm, the standard package command +is `npm install -g @ciphore/radiocli`. `mpv` provides IPC playback controls; +`ffplay` and VLC are limited-control fallbacks. Custom player installations can +use the [absolute path overrides](troubleshooting.mdx#playback-tool-installed-but-not-found). + +The recipes below were checked against package sources. Only the recorded +configurations in the evidence table have completed RadioCLI verification. +System package commands need the host's appropriate administrator access; +Termux `pkg` and Haiku `pkgman` run without `sudo`. + +| System | Playback / runtime route | Packaging limits and primary source | +| --- | --- | --- | +| macOS | Compatible Node plus npm; Homebrew `mpv`, or `sudo port install mpv` with MacPorts | Homebrew's OS/CPU policy is narrower than Node's. [MacPorts mpv](https://ports.macports.org/port/mpv/) is a separate route. | +| Windows | x64: `winget install --id shinchiro.mpv -e`; arm64: Scoop's `mpv`; x86: a matching native i686 build | The checked [WinGet mpv manifest](https://github.com/microsoft/winget-pkgs/blob/master/manifests/s/shinchiro/mpv/0.41.0/shinchiro.mpv.installer.yaml) is x64-only. [Scoop](https://github.com/ScoopInstaller/Extras/blob/master/bucket/mpv.json) lists x64 and arm64; [mpv release assets](https://github.com/mpv-player/mpv/releases/tag/v0.41.0) also include i686. | +| glibc Linux | `mpv` from the configured distribution catalog; setup selects apt, dnf, pacman, or zypper | Verify the actual Node version and media repository. A distribution's `nodejs` package can still be below 22. See [mpv's package references](https://mpv.io/installation/). | +| Alpine / musl | `apk add nodejs npm mpv`, with a compatible repository | The inspected [Alpine 3.24 x64 Node package](https://pkgs.alpinelinux.org/package/v3.24/main/x86_64/nodejs) is 24.x. Other releases/CPUs need separate checks. | +| FreeBSD | `pkg install -y node22 npm-node22 mpv`, or a matching Node 24/npm pair | Node ports omit npm, so install the matching npm package. [FFmpeg's SDL option](https://github.com/freebsd/freebsd-ports/blob/main/multimedia/ffmpeg/Makefile) controls whether `ffplay` is present. | +| OpenBSD | `doas pkg_add -I node mpv` | Check that the selected release supplies Node 22+. The [`pkg_add` manual](https://man.openbsd.org/pkg_add) defines `-I` as noninteractive. | +| NetBSD / pkgsrc | Install the matching NetBSD X11 base set, then `pkgin -y install 'nodejs>=22<23' mpv`, when that line exists in the repository | The binary mpv package links to system X11 libraries even for null output; a minimal OS image can lack them. Binary Node packages are named `nodejs`, with npm included. Use an available Node 24 range if needed. [pkgin](https://pkgin.net/) manages the catalog; [pkgsrc ffplay](https://github.com/NetBSD/pkgsrc/blob/trunk/multimedia/ffplay7/Makefile) uses versioned packages and executables such as `ffplay7`. | +| SmartOS / illumos | Bootstrap the appropriate pkgsrc environment, then use its `pkgin` Node/mpv packages | RadioCLI's setup recipe uses existing `pkgin`; it does not treat IPS `pkg` as FreeBSD `pkg`. See [SmartOS packages](https://docs.smartos.org/working-with-packages/) and [illumos binary packages](https://pkgsrc.smartos.org/). | +| Termux | `pkg install -y nodejs-lts npm mpv` as the normal app user | Setup identifies this as `termux-pkg`. Its [main FFmpeg recipe](https://github.com/termux/termux-packages/blob/master/packages/ffmpeg/build.sh) omits `ffplay`; the [separate ffplay package](https://github.com/termux/termux-packages/blob/master/x11-packages/ffplay/build.sh) needs `x11-repo` and its display/audio environment. | +| Haiku | `pkgman install -y mpv`; `ffmpeg8_tools` provides FFmpeg/ffplay | These [player recipes](https://github.com/haikuports/haikuports/tree/master/media-video) do not resolve the Node 20 runtime blocker. No working normal RadioCLI install is claimed. | +| AIX | Provision a compatible Node runtime and native player manually | No verified `mpv` or `ffplay` package recipe was established. The [IBM Toolbox catalog](https://www.ibm.com/support/pages/aix-toolbox-open-source-software-downloads-alpha) is not evidence that a `dnf install mpv` command exists. | + +As of the review date, Homebrew classifies Intel macOS as Tier 3 and has stopped +building new Intel bottles. Its [support policy](https://docs.brew.sh/Support-Tiers) +is independent of Node's x64 runtime eligibility. Likewise, a Windows x64 media +package is not evidence of native ARM64 or x86 playback; use a matching executable. + +The optional AirPlay sender has native installation dependencies. A package +installed with `--omit=optional` retains the normal local-player path but omits +that sender. Installs with and without optional dependencies are separate +acceptance checks; neither establishes an AirPlay receiver connection. +The recorded BSD guests installed the packed tarball with `--omit=optional` +in fresh temporary projects and executed the generated npm bin link. For a +manual registry installation, use `npm install -g --omit=optional @ciphore/radiocli`; +that global registry route and normal optional-dependency installation were +not the recorded guest checks. + +## Implemented Playback And Desktop Paths + +These tables describe implemented adapters and their prerequisites. Tool +discovery is checked separately from successful execution. Shared TUI code +supports Unicode, ASCII decoration, limited color, screen-reader output, and +reduced motion; it still needs interactive keyboard input. See +[TUI Controls](getting-started/tui-controls.mdx) for rendering options. + +| Host | Local playback / TUI | Browser and clipboard | Terminal reopening | +| --- | --- | --- | --- | +| macOS | mpv Unix-socket IPC; ffplay/VLC fallback; shared TUI | `open` and `pbcopy` in a local desktop session | Supported installed macOS terminal; Apple Terminal/iTerm2 may require Automation consent | +| Windows | mpv named-pipe IPC; ffplay/VLC fallback; shared TUI | `explorer` and `clip` in a local desktop session | Windows Terminal or a PowerShell console | +| Linux, glibc or musl | Shared player/TUI paths; actual backend and audio service required | `xdg-open`; `wl-copy`, `xclip`, or `xsel`, with an appropriate desktop session | An installed supported GUI terminal and display session | +| WSL | Linux player/TUI paths require separate WSL verification | Linux session helpers; Windows desktop access is not inferred | Linux terminal path; depends on the guest's display/session | +| FreeBSD / OpenBSD / NetBSD | Shared player/TUI paths; exact guest results above do not establish a desktop terminal application | `xdg-open` and the available Wayland/X11 clipboard helper | Unix terminal adapter; installed terminal and display required, launch unverified | +| Termux | Bionic runtime and Termux mpv path; device/TUI/audio execution unverified | `termux-open-url`; `termux-clipboard-set` needs Termux:API | No separate terminal-reopening adapter | +| illumos / AIX / custom Haiku or other ports | Core execution requires a separately verified Node/player pair | No automatic browser or clipboard adapter | No terminal-reopening adapter | + +The Termux helper contracts are defined by +[`termux-open-url`](https://github.com/termux/termux-tools/blob/master/scripts/termux-open-url.in) +and [`termux-clipboard-set`](https://github.com/termux/termux-api-package/blob/master/scripts/termux-clipboard-set.in). +Clipboard access also requires the helper package and a compatible, correctly +signed [Termux:API app](https://github.com/termux/termux-api#installation); +Android permission and execution checks still apply. + +## Alarm, Power, And Output Paths + +| Host | Background alarms | System volume | Sleep inhibition and wake | AirPlay | +| --- | --- | --- | --- | --- | +| macOS | Per-user launchd LaunchAgent | macOS output-volume scripting | `caffeinate` for idle sleep; no privileged exact-wake event | Experimental; FFmpeg, Bonjour, usable sender, and explicit receiver selection required | +| Windows | Task Scheduler with interactive user token | Core Audio through PowerShell | `SetThreadExecutionState`; `WakeToRun` can be requested, subject to hardware and policy | No adapter | +| Linux, including musl | Accessible systemd user manager and timer required | Working `wpctl`, `pactl`, or `amixer` | Live `systemd-inhibit` lease; no privileged `WakeSystem` request | No adapter | +| WSL | Linux systemd user path only when its session is usable; unverified | Linux mixer path, not Windows host volume | Linux inhibitor path; Windows wake behavior is not inferred | No adapter | +| FreeBSD / OpenBSD / NetBSD | No background scheduler adapter | No system-volume adapter | No sleep-inhibitor or wake adapter | No adapter | +| Termux / illumos / AIX / Haiku / other ports | No background scheduler adapter | No system-volume adapter | No sleep-inhibitor or wake adapter | No adapter | + +Finding `systemctl` does not prove a working systemd user manager. Finding a +mixer or inhibitor command does not prove it can control this session. Player +volume remains separate from system output volume, and local playback can remain +usable when optional desktop or scheduling features are unavailable. + +Use `radiocli alarm doctor` and **Verify alarm setup** for the current machine. +The latter performs native actions and bounded sample playback. The +[Alarms guide](alarms.mdx), [Reliability](reliability.mdx), and +[Troubleshooting](troubleshooting.mdx) describe repair paths and delivery limits. diff --git a/apps/docs/content/docs/privacy-security.mdx b/apps/docs/content/docs/privacy-security.mdx index 83c064c..aceceb7 100644 --- a/apps/docs/content/docs/privacy-security.mdx +++ b/apps/docs/content/docs/privacy-security.mdx @@ -8,11 +8,17 @@ RadioCLI does not require secrets or accounts. ## Local Agent Control Agent control is opt-in. Its MCP transport is stdio, and active playback uses a -short-lived authenticated HTTP endpoint bound only to `127.0.0.1`. The random -bearer token and port are stored in a user-private runtime file. Requests are -serialized, stale discovery records are removed, and a single-owner lock keeps -two local agents from starting competing players. The endpoint is not exposed -to the LAN or internet. +short-lived authenticated HTTP endpoint bound to literal loopback: `127.0.0.1`, +with `::1` as the fallback when IPv4 loopback is unavailable. The random bearer +token, address, and port are stored in a user-private runtime file. Requests +are serialized, and a single-owner lock keeps two local agents from starting +competing players. The endpoint is not exposed to the LAN or internet. + +Agent, alarm, and Guard clients accept only those literal loopback addresses. +They make direct Node HTTP requests with `agent: false`, so public HTTP proxy +settings cannot forward local control or bearer tokens to a proxy. Loopback +requests still require authentication; their local address does not replace +the token check. MCP and `radiocli agent` can play only stations saved by RadioCLI or opaque IDs returned by its search and browse operations. The agent interface does not @@ -28,7 +34,7 @@ external terminal windows or app-control prompts. ## Privacy Notes - Nearby location is enabled by default for new libraries, but no location - request is made until you open Nearby. + request is made until you open Nearby; offline mode blocks that request. - Location lookup uses approximate IP-based location from `ipapi.co`. Disable it with `l`, Settings, or `:location off`. - Explore uses only the cursor coordinate you move in the terminal. @@ -37,10 +43,42 @@ external terminal windows or app-control prompts. - RadioCLI does not proxy audio. It resolves public stream URLs and hands playback to `mpv`, `ffplay`, or the experimental macOS AirPlay worker. - AirPlay discovery uses Bonjour on your local network. AirPlay streaming runs - through a local worker and the bundled sender bridge. + through a local worker and the bundled sender bridge. Offline mode blocks + receiver discovery. Low-bandwidth mode skips automatic discovery but permits + an explicit refresh. - Favoriting a Radio Browser station sends a best-effort vote to that public directory by default. Disable **Share favorite votes with Radio Browser** in - Settings to keep favorites local-only. + Settings to keep favorites local-only. Offline mode also prevents the vote + request. + +## Public Network Controls + +`RADIOCLI_OFFLINE=1` blocks public HTTP requests made by RadioCLI's Node client. +It can still read cached directory data and hand a saved direct URL to the +native player when the user asks to tune. The player's stream connection is +outside this directory-request policy; offline mode is not an OS firewall or +an audio-download feature. + +`RADIOCLI_LOW_BANDWIDTH=1` avoids downloading the geotagged station atlas and +uses a cached copy when available. Smaller searches, location lookups, health +checks, and manual controls remain available, so it is not a privacy substitute +for offline mode or disabling location and directory voting. + +Both flags suppress automatic AirPlay discovery, and offline mode also blocks +manual receiver discovery before starting Bonjour. Existing authenticated +loopback controls and the stdio MCP transport continue to work offline. + +Public HTTP(S) proxies require a compatible Node release and +`NODE_USE_ENV_PROXY=1` at process startup. `NO_PROXY` and the proxy URLs are +handled by Node; RadioCLI does not install a global dispatcher or change TLS +verification. Invalid proxy configuration errors identify the setting without +echoing credentials. SOCKS and `ALL_PROXY` alone are unsupported. See +[Reliability](./reliability.mdx#network-requests) for version requirements and +the distinction between public requests and local control. + +Homepage actions validate HTTP(S) URLs before opening or displaying them. +Stream-copy fallbacks validate media targets and sanitize terminal text, so a +rejected URL is not echoed into the terminal. ## Alarm Data And Local Control @@ -61,22 +99,44 @@ that machine's native jobs. Alarm playback resolves the configured public statio and connects to its stream just like normal playback; RadioCLI does not upload the alarm or proxy its audio. -While an alarm is playing, the detached runner opens an HTTP control endpoint on -`127.0.0.1` only. Discovery files and 32-byte random bearer tokens use private -machine-local storage permissions where the OS supports POSIX modes. Dismiss, -snooze, keep-playing, and Guard ownership requests must authenticate; there is no -network-facing control server. The detached runner may open the locally saved -terminal emulator to expose those controls. It does not pass the bearer token on -the command line; the TUI discovers it through the private machine-local file. +While an alarm is playing, the detached runner opens its own authenticated +loopback endpoint using the same IPv4-to-IPv6 fallback. New control files store +32-byte random bearer tokens with mode `0600`; new directories request mode +`0700` where the filesystem enforces POSIX modes. Windows relies on private +user-directory ACLs; POSIX mode arguments do not configure those ACLs. Dismiss, +snooze, keep-playing, and Guard ownership requests must authenticate. The detached +runner may open the locally saved terminal emulator to expose those controls. +It does not pass the bearer token on the command line; the TUI discovers it +through the private machine-local file. + +A Guard PID is insufficient evidence of ownership. If its authenticated +challenge fails while the process is still alive, or its ownership record is +unreadable, RadioCLI retains the record for repair and does not signal that PID. +An ambiguous native-job removal likewise retains the definition and artifacts +instead of hiding a possibly active registration. + +## Local Data Integrity + +Library access failures are reported without exposing the private storage path. +Permission and I/O errors do not rename intact data as corrupt or reset the +library. A corrupt library is reset only after its original bytes have been +preserved in a backup. Failed atomic writes leave the previous saved state +intact; readable read-only libraries remain available to the TUI, with warnings +for optional history that could not be saved. + +The platform changes preserve the library and backup schemas, existing +`RADIOCLI_HOME` and `RADIO_ATLAS_HOME` overrides, legacy Radio Atlas data paths, +and existing runtime namespaces. See [Architecture](./architecture.mdx#persistence) +for the separation between portable library data and machine-local records. ## Location Lookup Nearby stations are useful, but IP-based location lookup is privacy-sensitive. For new libraries the feature is on, and opening Nearby requests an approximate -location from `ipapi.co`. RadioCLI uses the returned city, region, country, -latitude, and longitude to sort the local geotagged station atlas. Turn it off -before opening Nearby with Settings or `:location off` if you do not want that -request; the choice is stored locally. +location from `ipapi.co` unless offline mode is active. RadioCLI uses the +returned city, region, country, latitude, and longitude to sort the local +geotagged station atlas. Turn it off before opening Nearby with Settings or +`:location off` if you do not want that request; the choice is stored locally. ## Reporting Security Issues diff --git a/apps/docs/content/docs/release-packaging.mdx b/apps/docs/content/docs/release-packaging.mdx index 655868f..338a53b 100644 --- a/apps/docs/content/docs/release-packaging.mdx +++ b/apps/docs/content/docs/release-packaging.mdx @@ -14,9 +14,10 @@ packages from lifecycle scripts. The explicit, user-invoked `radiocli setup` command may install selected native tools after displaying and confirming its plan. Native playback and interactive controls come from `mpv`, with `ffplay` from FFmpeg as an optional playback-only fallback. -Linux users install `mpv` through their distro package manager. Windows users -install Node.js and `mpv` through `winget` or Scoop, then install RadioCLI -through npm. +Install a compatible Node runtime and a native player before using local audio. +The [platform matrix](platforms.mdx) records the verified OS/CPU combinations, +runtime minimums, package routes, and limitations. npm installation alone does +not establish native playback or desktop integration. ## NPM Release @@ -58,15 +59,51 @@ bun add -g @ciphore/radiocli radiocli doctor ``` -Cross-platform release smoke checks should cover macOS, Linux, and Windows -startup paths: +The fresh-install harness builds one tarball, installs it into separate temporary +projects with normal dependencies and with `--omit=optional`, and executes only +that installed artifact. It checks CLI/doctor, offline cached providers, library +import/export, and MCP status. Its temporary paths contain +spaces and non-ASCII characters. Require a real native mpv for release evidence: ```bash -npm run fresh:check # macOS/Linux -npm run fresh:check # Windows, from PowerShell -radiocli doctor +npm run fresh:check -- --require-mpv +node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs ``` +The playback check generates a local WAV, uses mpv's null audio output, and +verifies readiness, IPC volume, pause, resume, and stop. It does not prove physical +audio-device output, a receiver connection, wake delivery, or a particular +terminal application. Set `RADIOCLI_SMOKE_EVIDENCE_DIR` to preserve JSON records +containing the artifact SHA-256, runtime identity, installation mode, and results. +Set `RADIOCLI_MPV_PATH` when the verified native executable is outside `PATH`. + +### Release Gates And Experimental Checks + +The required CI matrix preserves Ubuntu, macOS, and Windows on Node 22/24 and +adds Linux arm64, macOS Intel, and Windows 11 arm64. All 12 jobs require packed +mpv smoke checks in both installation modes; Linux also has source playback +checks. Documentation checks run on Node 22/24. All required jobs must pass on +the release candidate before publication; a failed required job blocks release. +Inspect the recorded runner OS/version and CPU before carrying evidence into +a release. Repository protection settings and publication remain owner-managed. + +All workflow actions use immutable commit pins. macOS and Windows playback +fixtures come from explicitly selected official mpv release assets, checked +against pinned SHA-256 values and native CPU headers before execution. Linux +uses its runner's distribution package. These are CI fixtures, not packages +installed by npm or a new public distribution channel. + +Weekly or manually requested FreeBSD, OpenBSD, and NetBSD checks boot full QEMU +guest kernels, install the Linux-built JavaScript tarball with guest-native Node +and mpv, and label the result as guest verification. They do not infer BSD +behavior from a Linux container, and they do not establish foreign-CPU or desktop +audio support. The [evidence matrix](platforms.mdx#project-tiers-and-current-evidence) +records actual outcomes and remaining verification gaps. + +Do not publish a release with a failed required job. An experimental job failure +must remain visible and its platform tier must retain the corresponding limit. +This initiative changes no external tap, registry, or release ownership. + ## Homebrew Tap The public tap is `Ciphore/homebrew-tap`, and users install with: @@ -103,7 +140,7 @@ radiocli doctor ## Windows And Linux Native Playback -Windows setup uses native Node.js and native `mpv`: +Windows x64 setup uses native Node.js and native `mpv`: ```powershell winget install --id OpenJS.NodeJS.LTS -e @@ -117,6 +154,9 @@ directory to `PATH`. RadioCLI resolves that standard installation directly and also checks registered Windows application locations. Doctor should report an `mpv_path`, its `mpv_discovery` source, and `mpv_launch=ready`. +The inspected WinGet `mpv` recipe is x64-only. Scoop's `mpv` recipe includes x64 and +arm64 builds; verify the installed executable matches the intended runtime. +Windows x86 remains a Node 22 route requiring separate player and native evidence. Scoop users can install `mpv` with: ```powershell @@ -139,8 +179,9 @@ smoke checks should treat it as playback-only. `radiocli doctor` reports `controls=limited` when only `ffplay` is available and `controls=full` when `mpv` is available. -AirPlay is experimental on macOS. The npm package bundles the sender bridge, -and the Homebrew formula installs FFmpeg so the one-command macOS path exposes -AirPlay without extra user setup. Release checks should keep high/critical +AirPlay is experimental on macOS. Its optional sender dependency must install +and load successfully, and Bonjour and FFmpeg must be usable. The Homebrew +formula installs FFmpeg; that does not establish a working receiver connection. +Release checks should keep high/critical sender dependency advisories out of the shipped tree. AirPlay should remain an explicit session output rather than the automatic startup fallback. diff --git a/apps/docs/content/docs/reliability.mdx b/apps/docs/content/docs/reliability.mdx index dd86f53..6f7a8fd 100644 --- a/apps/docs/content/docs/reliability.mdx +++ b/apps/docs/content/docs/reliability.mdx @@ -15,6 +15,8 @@ requirement. - station codecs can be mislabeled - ICY metadata can be absent, malformed, or delayed - terminal dimensions can change mid-session +- a data directory can become read-only, inaccessible, or full +- a native service can fail even when its command is installed ## Current Defenses @@ -22,7 +24,7 @@ requirement. - sharded, bounded provider cache with per-entry corruption isolation and stale fallback - cached geotagged station atlas for Explore and Nearby, with local distance-first ranking -- bounded network calls for provider and location lookups +- public HTTP deadlines that cover headers and response bodies - tune timeout - skip-to-next on failed tune when enabled - `mpv` IPC readiness check @@ -34,8 +36,9 @@ requirement. playback states - metadata polling through `mpv`, avoiding a second stream listener - user-controllable approximate-location lookup, requested only when Nearby opens -- corrupt store/cache backups +- required corrupt-library backups and separate best-effort cache recovery - locked, atomic local JSON writes with concurrent-process merge protection +- nonfatal optional TUI history saves, with explicit errors for failed edits - defensive playlist import - functional compact navigation for tiny terminals - native per-user alarm registration on launchd, Windows Task Scheduler, and @@ -45,6 +48,62 @@ requirement. - authenticated loopback controls for alarms that began while no TUI was open - best-effort playback sleep inhibition and optional pre-alarm Guard +## Network Requests + +Radio Browser mirror attempts share a bounded deadline. A response that sends +headers but stalls while sending JSON cannot keep a request open indefinitely. +Usable stale cache entries remain available after a request fails, and a failed +cache write does not turn a successful response into a directory outage. + +RadioCLI's public HTTP client supports HTTP(S) proxies through Node's native +`fetch`. On Node 22.21 or newer in the 22 release line, or Node 24 or newer, set +`NODE_USE_ENV_PROXY=1` before starting RadioCLI along with `HTTP_PROXY` or +`HTTPS_PROXY`; Node also reads `NO_PROXY`. A configured proxy without the +required startup opt-in is reported instead of silently using a direct +connection. See [Node's proxy configuration documentation](https://nodejs.org/learn/http/enterprise-network-configuration#proxy-configuration). + +SOCKS proxies and `ALL_PROXY` as the sole proxy configuration are unsupported by +this client and produce an actionable error. Proxy support leaves Node's TLS +verification and certificate configuration unchanged. It does not configure +the external player's networking. Authenticated local control uses literal +loopback addresses and direct HTTP requests regardless of proxy settings. + +| Setting | Behavior | +| --- | --- | +| `RADIOCLI_OFFLINE=1` | Block RadioCLI's public Node HTTP requests, including directory, location, vote, and update requests; use available cached directory results and saved direct stream URLs | +| `RADIOCLI_LOW_BANDWIDTH=1` | Use only a cached geotagged atlas for Explore and Nearby; smaller searches, health checks, location lookups, and manual controls remain available | + +A missing cache entry is reported rather than fetched in offline mode. These +flags do not put the operating system or native player offline: a requested +direct stream can still connect to its station. They do not download audio for +offline listening. + +Both flags skip automatic AirPlay receiver discovery. Offline mode also blocks +manual discovery and reports the setting; low-bandwidth mode allows an explicit +receiver refresh. Existing authenticated loopback controls and the stdio MCP +transport remain available offline. + +## Library Write Failures + +Library access errors preserve the existing bytes. An unreadable file is not +renamed as corrupt or replaced with defaults, and an inaccessible current +library does not trigger a switch to an older legacy file. If corrupt +JSON cannot be backed up, RadioCLI leaves it intact and reports the backup +failure. + +Atomic replacement requires a writable library file and destination directory. +A failed lock, write, permission change, or rename leaves the previous committed +state intact. Cleanup errors cannot replace the original failure or make a +completed replacement appear to have failed. + +When the library can be read but cannot be written, the TUI can still tune, +navigate, stop, and quit. Optional history failures produce a library warning; +they do not turn successful playback into a failed tune or prevent a sleep +timer or alarm takeover from stopping it. Settings and favorite edits remain +explicit saves and report failure without changing the saved state. Check the +directory's permissions or choose a private writable location with +`RADIOCLI_HOME`. + ## Scheduled Radio Delivery Saving an enabled alarm resolves its next occurrence and registers one native @@ -70,14 +129,27 @@ that audio. | Windows | Task Scheduler interactive-token task | `StartWhenAvailable` | `WakeToRun` is requested when enabled, but hardware and power policy decide | | Linux with systemd user session | systemd user timer | persistent timer catches up after the user session resumes | No `WakeSystem` claim without system privileges | | Linux without systemd user scheduling | Unsupported | None | None | +| BSD, Termux, and other experimental targets | No verified alarm adapter | None claimed | None claimed | + +An installed scheduler executable alone is insufficient. Registration and +diagnostics check the user service; a Linux host without a reachable systemd +user manager cannot register reliable background alarms. RadioCLI does not +substitute cron or a process-local timer for an unsupported native scheduler. +The [platform support matrix](./platforms.mdx) separates runtime eligibility +from verified RadioCLI behavior. Terminal reopening supports Apple Terminal, iTerm2, WezTerm, Ghostty, and kitty -on macOS; Windows Terminal with Command Prompt fallback on Windows; and common +on macOS; Windows Terminal or a PowerShell console on Windows; and common GUI terminal emulators on Linux. Integrated terminals that cannot be launched independently fall back to the platform terminal where possible. If a graphical terminal is unavailable, audio still plays and opening `radiocli` manually reveals the authenticated ringing controls. +The Windows console launcher waits for its temporary PowerShell bootstrap to +finish creating a separate console. Successful bootstrap completion still does +not establish TUI readiness; the caller verifies the expected session. The +console adapter requires PowerShell to permit .NET/native interop. + All three supported adapters require the computer to be powered on and the user to have a logged-in interactive audio session with usable local speakers. A powered-off machine cannot run RadioCLI. Exact behavior across ordinary sleep, @@ -95,6 +167,13 @@ power and does not override shutdown, hibernate, explicit sleep, lid policy, logout, or other OS decisions. Guard state is machine-local and is reconciled again after each alarm change. +If an inhibitor helper exits unexpectedly, runtime health reports lost sleep +protection while playback can continue. If Guard ownership cannot be verified, +its record is retained for repair and its PID is not signaled. A failed native +job removal is accepted only after the exact job is confirmed absent; otherwise +the alarm definition and repair artifacts remain available. RadioCLI reports +these cases as unresolved rather than claiming protection or cleanup succeeded. + Recurring alarms use minute precision, ISO weekdays, and an explicit IANA timezone. In a spring daylight-saving gap, RadioCLI uses the first valid minute later on that civil date. In a fall overlap, it fires the first matching instant @@ -104,7 +183,8 @@ definitions keep their own IANA timezone, while the platform job contains the already-resolved next instant. When a native job starts late, the missed-run grace decides whether it still -plays. A due alarm tries its primary station twice, with a short bounded backoff, +plays. An early launch does not mark the occurrence complete or consume its due +dispatch. A due alarm tries its primary station twice, with a short bounded backoff, then tries the configured fallback. It stops after the requested number of minutes unless playback is transferred into the interactive TUI. True fade-in requires `mpv`; `ffplay` and VLC start at the target volume because they lack dependable runtime @@ -112,9 +192,11 @@ volume control. Scheduled audio is local-speaker only; unattended AirPlay is intentionally disabled. Before starting the player, RadioCLI also checks the default local system output. -If it is muted or below the alarm's configured volume, the runner unmutes it and -raises it to at least that level, then restores the previous device state when -the alarm ends. This uses macOS output-volume scripting, Windows Core Audio +If it is muted or below the alarm's configured volume, the runner attempts to +unmute it and raise it to at least that level. It attempts to restore the prior +state when the alarm ends. A partial mixer change triggers a rollback attempt; +a failed restore is reported and remains retryable. This uses macOS +output-volume scripting, Windows Core Audio through built-in PowerShell, or `wpctl`, `pactl`, or `amixer` on Linux. If the host exposes none of those controls, player volume still applies and runtime diagnostics report that the system output could not be raised. diff --git a/apps/docs/content/docs/roadmap.mdx b/apps/docs/content/docs/roadmap.mdx index d10b0b7..16a587e 100644 --- a/apps/docs/content/docs/roadmap.mdx +++ b/apps/docs/content/docs/roadmap.mdx @@ -25,12 +25,66 @@ description: Shipped scope and focused follow-up work for RadioCLI. - optional OS wake requests where available and machine-local Alarm Guard idle sleep inhibition -## Follow-up Work +## Cross-platform Initiative: Implemented And Verified -- native scheduling adapters for Linux systems without systemd user sessions -- broader wake-from-sleep capability detection as OS and hardware APIs allow -- a signed macOS app bundle and Service Management helper so Background Activity - can identify RadioCLI instead of the Node runtime -- unattended remote audio outputs; alarms currently use local speakers only +The [platform matrix](platforms.mdx#project-tiers-and-current-evidence) is the +source for exact tested versions, architectures, and current tiers. Changes on +the cross-platform branch are not represented here as a published release. + +- Centralized runtime identity, capability decisions, executable and package + discovery, storage paths, desktop/terminal plans, IPC, and network policy. +- Regression coverage for scheduler cleanup, alarm ownership, authenticated + loopback control, mpv readiness, and permission failures that previously + interrupted optional history or presence registration. +- Deterministic full, compact, and micro terminal captures, ASCII decoration, + limited color and `NO_COLOR`, screen-reader rendering, reduced motion, and + offline/low-bandwidth provider behavior. +- Packed-install acceptance checks with and without optional dependencies, + real mpv decoding and IPC through null audio, and explicit evidence records. +- Required native CI on both Node 22/24 lines for macOS Intel/Apple Silicon, + Ubuntu x64/arm64, Windows Server x64, and Windows 11 arm64. All 12 reference + configurations passed full tests and both packed-install/playback modes; + exact versions and capability limits are in the [platform matrix](platforms.mdx). +- FreeBSD 14.4, OpenBSD 7.9, and NetBSD 10.1 x64 core packed CLI and mpv IPC + playback with `--omit=optional`, executed in full guest kernels. NetBSD's + verified package route includes the matching OS X11 base libraries. + +These tests establish their stated behavior; they do not establish every desktop, +audio device, screen reader, or background scheduler on every target. + +## Implemented Paths With Experimental Verification + +- BSD desktop helpers and terminal applications beyond the verified core guest + route; display sessions, physical audio, and other CPUs need separate checks. +- Non-systemd Linux playback and core TUI behavior, with background scheduling + explicitly unavailable when no usable systemd user manager exists. +- Termux package and executable discovery, browser and clipboard helper plans; + device audio, Android permissions, and background limits still need native runs. +- Haiku package guidance, illumos/pkgsrc discovery, and AIX runtime diagnostics; + each keeps its actual runtime or native-player limitation visible. +- musl, WSL, ARMv7, x86, ppc64 variants, s390x, and riscv64 runtime assessments; + a compatible package route is not a verified RadioCLI deployment. + +## Work Blocked On Runtime, CI, Hardware, Or Upstream Support + +- Establish native installation, packed playback, and repeatable regression + evidence for the experimental configurations before promoting their tiers. +- Verify Windows desktop editions, older supported macOS releases, Raspberry Pi, + physical audio devices, named terminal applications, and screen-reader apps + beyond the exact native runner configurations already recorded. +- A maintained Node 22+ Haiku port and a verified AIX native player route. +- Equivalent reliable background alarms for non-systemd Linux, BSD, Termux, and + other ports. Cron, `at`, Android JobScheduler, and generic service supervision + have not demonstrated the existing occurrence, session, retry, and catch-up + contract; no replacement adapter is claimed. +- SOCKS support for public provider/update requests requires a suitable transport + implementation. Current diagnostics explain unsupported proxy configuration. +- Broader wake capability verification against actual hardware and OS policy. +- A signed macOS app bundle and Service Management helper so Background Activity + can identify RadioCLI instead of the Node runtime. +- Unattended remote audio outputs; alarms currently use local speakers only. + +Windows XP, obsolete Windows runtimes, and a separate legacy client are outside +the roadmap. See [Changelog](./changelog.mdx) for the full release history. diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx new file mode 100644 index 0000000..c18f9ec --- /dev/null +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -0,0 +1,194 @@ +--- +title: Troubleshooting +description: Diagnose playback tools, terminal rendering, local storage, and optional desktop integrations. +--- + +Start with the report for the feature that is failing: + +```bash +radiocli doctor +radiocli doctor --json +radiocli check +radiocli alarm doctor +``` + +`doctor` reports local playback tools and individual platform capabilities. +`check` adds provider health and the library path. `alarm doctor` reports native +scheduling and recorded delivery health. An available player does not establish that a +desktop session, system mixer, or scheduler is usable. See +[Platforms](/docs/platforms) for runtime requirements and verified coverage. + +## Playback Tool Installed But Not Found + +`radiocli doctor` prints the resolved `mpv` path, discovery method, version, and +launch result. RadioCLI checks `PATH`, known package-manager locations, and +supported application directories. An executable found outside `PATH` can be +used directly. + +For a custom installation, set an absolute path to the native executable: + +| Variable | Tool | +| --- | --- | +| `RADIOCLI_MPV_PATH` | `mpv` | +| `RADIOCLI_FFPLAY_PATH` | `ffplay` | +| `RADIOCLI_VLC_PATH` | `vlc` or `cvlc` | +| `RADIOCLI_FFMPEG_PATH` | FFmpeg, including the decoder used for macOS AirPlay | + +The value is a file path, with no command arguments. Windows overrides must +point to a native `.exe` or `.com` file; `.cmd` and `.bat` wrappers are not player +executables. A configured path that is invalid or cannot run is reported instead +of silently selecting another installation of that tool. Correct or remove the +override, then run Doctor again. + +Replace the example path with your installed executable. In a POSIX shell: + +```bash +RADIOCLI_MPV_PATH="/path with spaces/mpv" radiocli doctor +``` + +In PowerShell: + +```powershell +$env:RADIOCLI_MPV_PATH = 'C:\path with spaces\mpv.exe' +radiocli doctor +``` + +If no player is installed, use `radiocli setup --dry-run` to inspect the proposed +installation, or follow [Install](getting-started/install.mdx). `ffplay` and VLC +provide fallback playback, but pause, mute, interactive volume, metadata, and +play/pause media keys require `mpv`. + +On minimal NetBSD installations, the packaged mpv also needs the matching OS +X11 base set. Missing libraries under `/usr/X11R7/lib` can make the package +installation fail or prevent the executable from starting, even with null audio +and video. Install the matching distribution set through NetBSD's OS tools, +then retry the player package and `mpv --version` before checking RadioCLI. + +## Missing Glyphs, Incorrect Colors, Or Too Much Motion + +Use **Settings → Appearance** or the command palette: + +```text +:ascii on +:background on +:motion on +``` + +ASCII mode replaces decorative glyphs while preserving station names and +metadata. Transparent Background removes the panel fill. Reduce Motion freezes +receiver and loading animations and stops scrolling long labels. + +For a single launch, use `RADIOCLI_ASCII=1`, `NO_COLOR=1`, or +`RADIOCLI_DISABLE_ANIMATION=1`. Any non-empty `NO_COLOR` value removes colors and +fills, even when `FORCE_COLOR` is set. Sixteen-color terminals automatically +keep their own background. + +If ASCII appeared unexpectedly, check `LC_ALL`, `LC_CTYPE`, and `LANG`, in that +order. The first non-empty value determines the locale check: bare `C` and +`POSIX` select ASCII decoration, while UTF-8 locales retain Unicode. An explicit +`RADIOCLI_ASCII=1` also selects ASCII; `RADIOCLI_UNICODE=1` restores Unicode when +ASCII is not explicitly enabled and the terminal is not `dumb`. SSH alone does +not select ASCII or reduced motion. + +Set `RADIOCLI_SCREEN_READER=1` or `INK_SCREEN_READER=true` for screen-reader +output. Receiver visualizers, mouse reporting, and scrolling labels are +disabled; keyboard navigation and commands remain available. All +environment overrides leave your saved appearance settings intact. See +[TUI Controls](getting-started/tui-controls.mdx) for the complete list. + +## TUI Input Or Desktop Actions Are Unavailable + +Open `radiocli` from a terminal with interactive keyboard input. `TERM=dumb` +uses static frames with ASCII decoration and no color or animation. The TUI +still needs interactive stdin. Use command mode for redirected input or scripts: + +```bash +radiocli doctor --json +radiocli search "jazz" +radiocli alarm list --json +``` + +Browser opening, clipboard access, and opening another terminal also depend on +the current desktop session. On Linux, clipboard integration needs a working +Wayland or X11 session and an available helper such as `wl-copy`, `xclip`, or +`xsel`. When automatic copying is unavailable, RadioCLI displays the stream URL +for manual copying. Keyboard navigation remains available without mouse support. + +For agent playback on a machine without a usable graphical terminal, explicitly +choose headless mode with `radiocli setup --mcp --headless-agent`. See +[CLI Usage](getting-started/cli.mdx) for agent controls. + +## Offline Directories, Proxies, And IPv6 + +Check the network policy in `radiocli doctor --json`. `RADIOCLI_OFFLINE=1` +blocks RadioCLI's public directory, location, vote, update, and receiver-discovery +requests. Previously cached directories and saved or imported direct station +URLs remain usable. A cache miss stays a cache miss until you run online again; +offline mode does not download stations or audio in the background. + +`RADIOCLI_LOW_BANDWIDTH=1` uses the cached geotagged atlas and skips automatic +receiver discovery. Smaller searches and manual requests remain available. +Both settings leave the external player's networking separate: tuning a live +stream still needs a working route to that station. + +For public HTTP(S) proxies, use a compatible Node release and set +`NODE_USE_ENV_PROXY=1` before starting RadioCLI, together with `HTTP_PROXY` or +`HTTPS_PROXY` and any required `NO_PROXY` exceptions. Node 22 needs 22.21 or newer +for this public-fetch route; Node 24 also provides it. A missing startup opt-in, +invalid proxy URL, SOCKS proxy, or `ALL_PROXY`-only configuration produces a +diagnostic. Correct that configuration before retrying. The setting does not +configure mpv or another player's proxy behavior. See +[Network requests](reliability.mdx#network-requests). + +Authenticated local controls bind to `127.0.0.1`, falling back to `::1` when +IPv4 loopback is unavailable, and bypass public proxy settings. Public directory +and media connectivity still depend on each endpoint's addresses and the host's +DNS and routing. A successful local IPv6 control check does not establish public +IPv6 reachability. Diagnose the provider request and the external player's +stream connection separately; neither requires exposing local control to the LAN. + +## Library Is Read-Only Or Cannot Be Saved + +A readable library remains usable for browsing and playback. If a history write +fails, the TUI shows **Library not saved** and continues tuning, navigation, +stop, and quit. Failed writes do not become saved listening history, and +explicit favorite, setting, or alarm saves report their failure. + +Use the library path from `radiocli check` to inspect the file and its parent +directory. Atomic saves need write access to both. Check filesystem permissions, +Windows ACLs, or a read-only mount as appropriate. Windows ACLs are not configured +by POSIX `chmod` modes. + +You can set `RADIOCLI_HOME` to a private writable directory. This selects a +separate data location; it does not move the existing library automatically. +Preserve your existing data before changing locations, and restart RadioCLI +after correcting the path or permissions. + +## Optional Native Features Need Attention + +- **macOS:** terminal reopening through Apple Terminal or iTerm2 may need + Automation consent from the launching app. Experimental AirPlay also needs + Bonjour, FFmpeg, a usable sender, and an explicitly selected receiver. Local + playback remains a separate output option. +- **Windows:** scheduled alarms use Task Scheduler with the interactive user + token. A requested `WakeToRun` setting still depends on hardware and power + policy. Use native Windows RadioCLI for Windows agent and terminal setup; + WSL has a separate Linux environment. The PowerShell console, system-volume, + and sleep-inhibitor adapters require permitted .NET/native interop; constrained + PowerShell policy can prevent those optional operations. +- **Linux:** background alarms require an accessible systemd user manager. + A machine without that session can still use normal playback when its player + and audio device are available. A found `systemctl` executable alone does not + establish working user scheduling. + +Use **Verify alarm setup** in the Alarms screen to check registration, terminal +delivery, sleep protection, system volume, and bounded sample playback on the +current machine. Player volume and system output volume are separate: when a +system mixer cannot be controlled, set the output device volume manually. +macOS and Linux do not request privileged exact-wake events. Alarm Guard holds +off idle sleep only while its inhibitor is alive. + +Follow the [Alarms guide](alarms.mdx) and +[Reliability](reliability.mdx) for repair steps and wake limitations. Optional +feature failures are reported individually; the platform name alone does not +guarantee their availability. diff --git a/docs/cross-platform/audit.md b/docs/cross-platform/audit.md index 48e3ce3..1378ef5 100644 --- a/docs/cross-platform/audit.md +++ b/docs/cross-platform/audit.md @@ -37,11 +37,11 @@ Repository configuration, README, security/contribution guidance, architecture, design, reliability, installation, alarm, packaging, roadmap, and CI documents were inspected before implementation. No repository-specific `AGENTS.md` or shared repository memory was present. The initial local-only editor configuration was -removed at the owner's subsequent request for a clean checkout. +removed as requested for a clean checkout. The initial local test counts also included 216 duplicate tests from an ignored legacy security-audit scratch copy. It was discovered during comparison with -the isolated staged tree and removed under the owner's clean-checkout request. +the isolated staged tree and removed under the clean-checkout request. The actual original repository contains 690 tests; the committed baseline tree passes all 1,268 tests (690 original plus 578 captures) on Node 22.23.2. @@ -68,7 +68,7 @@ On native macOS 26.5.2 arm64 with Node 26.8.1/npm 11.19.0: Node 26 is supplementary evidence. Node 22/24 runs, native playback checks, deterministic visual captures, and the final package matrix are recorded in the -verification report as they are completed. An interrupted playback attempt during +[verification report](verification.md). An interrupted playback attempt during dependency replacement is not playback evidence. A second baseline on Node 22.23.2 reproduced the alarm-test race (888 passed, @@ -157,3 +157,123 @@ scheduler support remains a separate capability from general CLI/TUI support. Haiku, illumos/SmartOS, AIX, musl, and uncommon architectures require exact runtime and playback evidence before any promotion in tier. Windows XP and separate legacy applications are outside the initiative. + +## Implementation and compatibility review + +The final platform layer lives in `src/platform/`. Runtime identity and upstream +runtime eligibility are separate from verified project support. Capability +results keep playback, storage, desktop integration, terminal reopening, +scheduling, power, volume, rendering, and AirPlay independent. Doctor reports +the current installation's evidence scope; it does not claim acceptance tests +were run merely because a reference CI machine passed. + +The operation owners remain in `player`, `providers`, `storage`, `alarms`, +`agent`, and `ui`. Existing launchd, Task Scheduler, and systemd adapters remain +in place. They now preserve cleanup evidence on actual failure, check a usable +systemd user manager, retain unverified live Guard ownership records, and avoid +consuming an alarm occurrence when a scheduler invokes it early. Terminal launch +requests preserve literal arguments, necessary player/session environment, and +the existing saved descriptors. Windows uses an encoded Node bootstrap across +PowerShell's native-argument boundary; console creation is tested separately +from a successful process request. + +Storage changes preserve the schema, current/legacy path precedence, library +filenames, and settings defaults. Writes stage the next in-memory state and +publish it only after atomic persistence succeeds. Failed writes keep the old +file and in-memory state; an error in optional history, cache, presence, or +listening statistics is reported without stopping unrelated playback. No data +migration is needed. Explicit ASCII, screen-reader, offline, and reduced-motion +environment overrides do not rewrite saved preferences. + +FreeBSD, OpenBSD, NetBSD, and Termux have explicit package and executable plans. +BSD desktop helpers require the actual display and tool. Other Unix package +routes retain their runtime or native-player limits. Non-systemd Linux and the +new OS targets report background scheduling as unavailable; no cron, `at`, or +Android substitute weakens the existing delivery contract. + +All 578 renderer captures remain covered. Compared with the first committed +baseline, all 335 Unicode/ANSI captures are unchanged. Of 243 ASCII captures, +160 intentionally replace remaining decorative Unicode (including bars, bullets, +and ellipses), while 83 are unchanged. User-supplied station names and metadata +are preserved. See [before-and-after terminal captures](visual-before-after.txt) +and the [complete snapshot fixture](../../src/ui/__snapshots__/visual-baseline.test.tsx.snap). +These are renderer captures, not photographs of native terminal applications. +Keyboard, focus, provider caching, imports/exports, storage recovery, player +controls, and alarm lifecycle tests remain in the suite. + +## Dependency and security audit + +- `package.json`, the root lockfile, and the documentation lockfile retain the + original dependency versions. No runtime dependency, lifecycle package + installation, registry publication, or external packaging change was added. +- Required-runtime and documentation dependency audit gates remain enabled, + together with strict TypeScript, unused/export/whitespace checks, and package + lint. Normal local installs still report the existing two low-severity + optional-tree advisories; required-runtime and docs audit gates pass. +- Authenticated local control uses a direct HTTP agent and IPv4/IPv6 loopback + binding. Regression tests exercise actual local proxy listeners and confirm + private control tokens are not sent through environment proxies. Ownership + tokens are not inserted into terminal command lines or diagnostic output. +- Public provider/update requests retain TLS verification and bounded deadlines + through response-body consumption. Native HTTP(S) proxy opt-in is diagnosed; + unsupported SOCKS/ALL_PROXY configurations fail explicitly. External player + networking remains separate. Offline mode suppresses public directory and + update requests, and low-bandwidth mode reduces background discovery. +- Every CI action is pinned to a full immutable commit. New vmactions guest + providers run with read-only repository permissions and no repository secrets. + Their maintained VM image downloads remain an external supply dependency, + distinct from the pinned action source. Native macOS/Windows mpv fixtures use + fixed official release assets, pinned SHA-256 values, and CPU-header checks. + Linux/BSD package catalogs remain the selected distribution's trust boundary. + NetBSD's minimal guest additionally installs its matching official X11 base + set with a pinned SHA-512 digest, because the native mpv package links to those + OS libraries even with null output. + +The final review covers the complete diff from the original merge base as well +as the native-CI corrections. Review findings are accepted only after checking +the affected contract and reproducing the problem. In particular, an upstream +community runtime route does not imply upstream Tier 1 support, and a screen +reader label is evaluated against Ink's actual accessible output behavior. + +## Risks, limits, and rollback + +The broadest risk is interaction with real desktop/session policy: terminal +permissions, task registration, audio devices, wake policy, and screen-reader +applications need checks beyond deterministic rendering and null-audio IPC. +Native tests do not establish every Windows desktop edition, older macOS release, +WSL distribution, or Linux init/audio combination. The exact verification +matrix and remaining experimental targets are recorded in the +[platform guide](../../apps/docs/content/docs/platforms.mdx). + +The package still requires Node 22 or newer. Standard HaikuPorts Node 20 remains +blocked; AIX playback, Termux devices, illumos, musl, and additional CPU targets +retain their stated verification gaps. AirPlay stays an optional experimental +macOS feature; loading its sender and finding Bonjour/FFmpeg do not establish a +receiver connection. Unsupported optional capabilities cannot be promoted by +mocked host tests or a successful build. + +Rollback can revert this initiative's commits on an owner-managed branch or +restore the previously installed RadioCLI package. There is no schema downgrade +or lockfile migration to reverse. Back up the library before changing versions. +If this version registered alarm tasks, use its normal alarm removal/repair +commands while its CLI remains installed, confirm native cleanup, and then +re-register with the selected older version. Preserve failed-cleanup health and +ownership records for diagnosis; do not delete live control state or kill a PID +based only on an unverified record. Terminal and rendering environment overrides +can be removed independently. The repository owner retains all merge and release +decisions. + +## Reviewer checklist + +- Check each reference platform tier against the recorded native or full-guest + result, Node version, install mode, and artifact hash. +- Confirm rich rendering against the baseline and inspect intentional ASCII + changes, screen-reader output, and small-terminal keyboard behavior. +- Review quoting and environment propagation at POSIX, systemd, Windows XML, + PowerShell, and terminal boundaries. +- Exercise optional-feature failures and alarm cleanup on a representative real + desktop before expanding its advertised feature coverage. +- Check direct authenticated loopback control, proxy diagnostics, private file + permissions, failed-write preservation, and unchanged data compatibility. +- Keep required CI and package/playback gates in the release process; retain + explicit limits for the extended platforms. diff --git a/docs/cross-platform/verification.md b/docs/cross-platform/verification.md new file mode 100644 index 0000000..48b1b91 --- /dev/null +++ b/docs/cross-platform/verification.md @@ -0,0 +1,148 @@ +# Cross-platform verification record + +This record accompanies the [single draft cross-platform PR](https://github.com/Ciphore/RadioCLI/pull/36). +The original merge +base is `6d8cc2dc7ef8a02e2bf8a2406800e6f6cbce3ab9`; baseline details, security +review, compatibility, risks, and rollback are in [the audit](audit.md). +The [platform guide](../../apps/docs/content/docs/platforms.mdx) contains runtime +minimums, installation routes, native capability limits, and evidence-based tiers. + +## Local acceptance + +Local execution used native Apple Silicon on macOS 26.5.2, Darwin 25.5.0, with +separately installed Node 22.23.2/npm 10.9.8 and Node 24.20.0/npm 11.19.0. +Each runtime's clean dependency installation finished before tests using that +dependency tree. Node 26.8.1 was supplementary regression evidence, outside the +maintained Node 22/24 matrix. + +The following commands passed on both maintained runtime lines: + +```sh +npm ci +npm run check +npm run lint +npm run test +npm run build +node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs +npm run audit:runtime +npm run smoke:data +npm run smoke:mcp +npm run smoke:playback +npm run pack:check +npm run fresh:check -- --require-mpv +npm run check:package +npm --prefix apps/docs ci +npm run docs:check +npm run docs:build +npm run audit:docs +``` + +At the `fd57fda` runtime-code checkpoint, each full local suite passed 1,841 +tests, with three Windows-only native checks skipped on macOS. The standalone +package-harness suite passed 12 tests. Required-runtime and documentation audit +gates reported no vulnerabilities; the normal dependency tree retained the +original two low-severity optional-tree advisories. + +Live data smoke used Radio Browser and resolved a real station URL. MCP smoke +started the built stdio server and checked 31 tools. Local playback smoke used +a generated silence WAV and a real native mpv executable. The separately verified +mpv 0.41.0 release fixture was checked against its pinned SHA-256 and Mach-O CPU +header. Playback uses null audio for repeatability and is not evidence of sound +from speakers or of a working AirPlay receiver. + +The packed harness installs one actual npm tarball into two fresh temporary +projects, with normal dependencies and with `--omit=optional`. It exercises the +installed executable and npm bin wrapper, CLI/doctor, stale offline cache hits +and misses, direct URLs, Unicode/space-containing playlist paths, import/export, +MCP status, and real mpv readiness/volume/pause/resume/stop over native IPC. +A fetch guard fails the fixture if an offline CLI check attempts live network +access. Temporary paths also contain literal `#` and `%`; preloads use file URLs +so URL delimiters and Windows drive letters retain their path meaning. Each +successful record contains the exact artifact SHA-256 and runtime. + +## Native and guest execution + +The expanded workflow preserves the original six Ubuntu/macOS/Windows Node +22/24 combinations and adds Linux arm64, macOS Intel, and Windows 11 arm64 on +both Node lines. Each required job now installs and exercises the packed artifact +in both dependency modes with real native mpv. Four Linux jobs also run the +source playback smoke; two jobs check/build/audit the documentation. + +All 22 jobs passed in [run 34180703652](https://github.com/MrSeizy/RadioCLI/actions/runs/34180703652) +at code checkpoint `fd57fda75dfed9de547293b9fb28f5fc30f5f0fa`: 12 required native +verification jobs, four Linux playback jobs, two documentation jobs, the guest +artifact build, and three BSD guest jobs. Every native verification job passed +both packed installation modes and real mpv IPC playback. Windows also passed +real PowerShell literal-argument, environment, and independent-console tests; +the console child had actual interactive stdin, stdout, and stderr handles. + +| Reference environment | Kernel | CPU | Node / npm pairs | +| --- | --- | --- | --- | +| macOS 26.6.2 | Darwin 25.6.0 | arm64 | 22.23.2 / 10.9.8; 24.20.0 / 11.19.0 | +| macOS 15.7.9 | Darwin 24.6.0 | x64 | 22.23.2 / 10.9.8; 24.19.0 / 11.17.0 | +| Ubuntu 24.04.4 LTS, glibc | 6.17.0-1022-azure | x64 and arm64, each tested | 22.23.2 / 10.9.8; 24.20.0 / 11.19.0 | +| Windows Server 2025 Datacenter | 10.0.26100 | x64 | 22.23.2 / 10.9.8; 24.19.0 / 11.17.0 | +| Windows 11 Enterprise | 10.0.26200 | arm64 | 22.23.2 / 10.9.8; 24.20.0 / 11.19.0 | +| FreeBSD full guest | 14.4-RELEASE | x64 | 22.23.2 / 11.19.1 | +| OpenBSD full guest | 7.9 | x64 | 22.23.2 / 10.9.8 | +| NetBSD full guest | 10.1 | x64 | 22.23.0 / 10.9.8 | + +Each macOS/Linux suite passed 1,841 tests and skipped three Windows-only native +probes. Windows Node 22 passed 1,828 with 16 platform-specific skips; Windows +Node 24 passed 1,826 with 18 skips, including two optional AirPlay source checks +whose dependency files were absent. POSIX permission and helper fixtures are +not represented as native Windows checks. All Windows PowerShell/console probes +and shared capability tests ran. No existing assertion, capacity limit, or test +deadline was relaxed to obtain these results. + +The repeatable BSD checks install the Linux-built JavaScript tarball inside +full FreeBSD, OpenBSD, and NetBSD guest kernels. Node and mpv come from each +guest's native package repository. The x86_64 guests use QEMU on same-CPU Linux +hosts; they are full guest runtime verification, not Linux containers or +foreign-CPU verification. The BSD route explicitly uses `--omit=optional` and +does not establish the optional native AirPlay sender, a GUI terminal, physical +audio, or a background scheduler. + +CI evidence is retained as artifacts for native jobs and structured +`packed_smoke` records in guest logs. A build result alone is never substituted +for guest execution. These are the exact tested tarball SHA-256 values at the +recorded code checkpoint; normal and omitted-optional installations on each +native target used the same target tarball: + +| Tarball build and execution | SHA-256 | +| --- | --- | +| macOS/Linux builds; Linux-built artifact also executed in all three BSD guests | `70342ce5a1b4c9b06a34d9b90c915ff585422392e10a7f683a01f8269ff3c4ef` | +| Windows builds, executed on x64 and arm64 | `018fa6656d868c2e27007d7e688814a8768526c154e7cfcb9928a53315ba6463` | + +The final documentation commit changes the tarball contents. Its fresh-install +hashes and complete CI results are recorded in the linked PR, keeping this +committed report tied to the independently identifiable code checkpoint. + +## Visual and interaction evidence + +- The 578 deterministic Ink snapshots cover major screens, settings, receivers, + and full/compact/micro layouts at 100×30, 50×16, and 24×8. +- All 335 Unicode/ANSI snapshots match the first committed baseline. Of 243 ASCII + snapshots, 160 intentionally replace decorative Unicode and 83 remain the same. +- [Before-and-after terminal captures](visual-before-after.txt) show representative + full, compact, and micro changes. The complete snapshots remain under test in + `src/ui/__snapshots__/visual-baseline.test.tsx.snap`. +- Additional integration tests cover ASCII/color/locale/dumb-terminal policy, + screen-reader semantics, keyboard/focus behavior, offline startup, read-only + storage, and playback-gated or disabled animations. +- A real local PTY invocation with stdout redirected to a file produced no ANSI + escape sequences and exited through the normal keyboard quit path. This + separately checks the actual CLI entrypoint's stream detection. + +The renderer and mocked-host fixtures validate their specific code contracts. +They do not claim another OS's terminal fonts, a physical display, a screen-reader +application, audible output, or actual sleep/wake delivery. + +Rendering fixtures retain their original assertions, dimensions, and five-second +deadlines. A paired comparison of the two existing screen-rendering suites kept +all 144 frames identical while closing every mounted Ink view and removing host +terminal-size subprocesses. The alarm journeys' per-test footer-layout cache +delegates each distinct input to the real function; all 124 paired journey frames +remained identical. Polling tests assert that the active clock actually polls. +The 500-alarm capacity test retains its original limit and deadline; timezone +canonicalization now performs one native validation per value instead of two. diff --git a/docs/cross-platform/visual-before-after.txt b/docs/cross-platform/visual-before-after.txt new file mode 100644 index 0000000..40e2385 --- /dev/null +++ b/docs/cross-platform/visual-before-after.txt @@ -0,0 +1,106 @@ +ASCII rendering changes against bc40d1c +160 ASCII-only snapshots updated; all 335 Unicode/ANSI snapshots are unchanged. + +screen baseline / full / ascii > 'home' 1 +BEFORE +RADIOCLI ██████████████████ +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent · 1 favorites · 1 imported + + + + + + + + + + +AFTER +RADIOCLI ################## +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent . 1 favorites . 1 imported + + + + + + + + + + + +receiver baseline / compact / ascii > pulse-grid 1 +BEFORE +RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o @ @ o o o o ∘ . . ∘ o o o +o o @ o . . . ∘ @ o o o o o o @ o . . . ∘ @ o o +o o o o ∘ . . ∘ o @ o o o o o @ ∘ . . . ∘ @ o o +o o o @ ∘ . . . ∘ o @ @ o @ @ ∘ . . . . o @ o o +Bjork - Joga + +AFTER +RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga + + +screen baseline / micro / ascii > 'settings' 1 +BEFORE +RC / Settings · Choose … + +> Playback … 7 settin… + Appearance 6 settin… + Discovery… 4 settin… + Data & li… 2 settin… + +AFTER +RC / Settings . Choose . + +> Playback . 7 settin. + Appearance 6 settin. + Discovery. 4 settin. + Data & li. 2 settin. + + +END OF CAPTURES From ed01e858e97e1c1776fe2aa4af29d00ab2bfdacc Mon Sep 17 00:00:00 2001 From: MrSeizy <228224+PonchoPig@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:52:44 -0500 Subject: [PATCH 23/25] fix: address cross-platform reliability review findings --- .github/workflows/ci.yml | 16 +- apps/docs/content/docs/privacy-security.mdx | 6 + apps/docs/content/docs/reliability.mdx | 21 +- .../2026-09-07-adversarial-remediation.md | 55 +++++ scripts/packed-smoke.mjs | 2 +- src/agent/launcher.test.ts | 19 +- src/agent/launcher.ts | 12 +- src/agent/mcp-install.ts | 32 +-- src/alarms/active-session.test.ts | 34 ++++ src/alarms/active-session.ts | 3 +- src/alarms/inhibitor.ts | 2 +- src/alarms/runner.test.ts | 26 ++- src/alarms/runner.ts | 4 +- src/alarms/scheduler.test.ts | 188 +++++++++++++++++- src/alarms/scheduler.ts | 72 ++++++- src/alarms/system-volume-native.test.ts | 105 ++++++++++ src/alarms/system-volume-ownership.test.ts | 100 ++++++++++ src/alarms/system-volume-ownership.ts | 129 ++++++++++++ src/alarms/system-volume-process.test.ts | 102 ++++++++++ src/alarms/system-volume.test.ts | 148 +++++++++++++- src/alarms/system-volume.ts | 115 ++++++++--- src/alarms/terminal-launcher.test.ts | 24 ++- src/alarms/terminal-launcher.ts | 10 +- src/cli.tsx | 2 +- .../executable-locations.test.ts} | 2 +- src/platform/executables.test.ts | 8 + src/platform/launch-command.test.ts | 162 +++++++++++++++ src/platform/launch-command.ts | 80 +++++++- src/platform/packages.test.ts | 39 +++- src/platform/packages.ts | 151 ++++---------- src/platform/paths.ts | 7 + src/platform/runtime.ts | 5 +- src/platform/terminals.test.ts | 68 ++++++- src/platform/terminals.ts | 42 +--- src/player/airplay-discovery.test.ts | 2 +- src/player/airplay-discovery.ts | 2 +- src/player/backend-install.test.ts | 40 ++-- src/player/backend-install.ts | 19 +- src/player/command-diagnostics.ts | 2 +- src/player/command.ts | 4 - src/player/player-controller.test.ts | 16 +- src/player/player-controller.ts | 12 +- src/setup.test.ts | 59 +++++- src/setup.ts | 27 ++- src/ui/alarm-tui-integration.test.tsx | 75 +++++++ src/ui/display-context.test.ts | 8 +- src/ui/display-context.ts | 6 - src/ui/system-actions.test.ts | 64 ++++-- src/ui/system-actions.ts | 61 ++++-- src/ui/use-alarm-tui.ts | 44 +++- 50 files changed, 1827 insertions(+), 405 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-07-adversarial-remediation.md create mode 100644 src/alarms/system-volume-native.test.ts create mode 100644 src/alarms/system-volume-ownership.test.ts create mode 100644 src/alarms/system-volume-ownership.ts create mode 100644 src/alarms/system-volume-process.test.ts rename src/{player/command.test.ts => platform/executable-locations.test.ts} (97%) create mode 100644 src/platform/launch-command.test.ts delete mode 100644 src/player/command.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25036e1..15f5bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,23 +168,9 @@ jobs: run: npm ci - name: Playback smoke + # Packed normal/omit-optional installs for this OS/CPU/Node matrix run in verify. run: npm run smoke:playback - - name: Packed mpv playback and portable install smoke - env: - RADIOCLI_EXPECTED_ARCH: ${{ matrix.architecture }} - RADIOCLI_SMOKE_EXECUTION_METHOD: GitHub-hosted native Linux VM (${{ matrix.os }}), real mpv with null audio - RADIOCLI_SMOKE_EVIDENCE_DIR: playback-evidence - run: npm run fresh:check -- --require-mpv - - - name: Preserve playback evidence - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: playback-${{ matrix.os }}-${{ matrix.node-version }} - path: playback-evidence/*.json - if-no-files-found: warn - docs: name: Docs if: github.event_name != 'schedule' diff --git a/apps/docs/content/docs/privacy-security.mdx b/apps/docs/content/docs/privacy-security.mdx index aceceb7..707892c 100644 --- a/apps/docs/content/docs/privacy-security.mdx +++ b/apps/docs/content/docs/privacy-security.mdx @@ -93,6 +93,12 @@ The following state is deliberately machine-local and excluded from backups: - scheduler and power-inhibitor health records - Alarm Guard process/lease state and occurrence locks - active-alarm discovery files and their random bearer tokens +- shared system-volume snapshots and participating process IDs + +System-volume ownership lives in `.radiocli/system-volume` under the OS account's +home directory. It is shared across `RADIOCLI_HOME` and XDG profiles so separate +alarm runners cannot restore the same output device independently. The saved +snapshot is removed after successful final restoration or playback handoff. After restoring a backup on another machine, run `radiocli alarm sync` to create that machine's native jobs. Alarm playback resolves the configured public station diff --git a/apps/docs/content/docs/reliability.mdx b/apps/docs/content/docs/reliability.mdx index 6f7a8fd..db46902 100644 --- a/apps/docs/content/docs/reliability.mdx +++ b/apps/docs/content/docs/reliability.mdx @@ -171,7 +171,10 @@ If an inhibitor helper exits unexpectedly, runtime health reports lost sleep protection while playback can continue. If Guard ownership cannot be verified, its record is retained for repair and its PID is not signaled. A failed native job removal is accepted only after the exact job is confirmed absent; otherwise -the alarm definition and repair artifacts remain available. RadioCLI reports +the alarm definition and repair artifacts remain available. Deleting an alarm +first saves it as disabled. If that save fails, deletion stops before native +cleanup; if cleanup or the final save fails, the disabled definition remains +available for repair or another deletion attempt. RadioCLI reports these cases as unresolved rather than claiming protection or cleanup succeeded. Recurring alarms use minute precision, ISO weekdays, and an explicit IANA @@ -193,14 +196,24 @@ intentionally disabled. Before starting the player, RadioCLI also checks the default local system output. If it is muted or below the alarm's configured volume, the runner attempts to -unmute it and raise it to at least that level. It attempts to restore the prior -state when the alarm ends. A partial mixer change triggers a rollback attempt; -a failed restore is reported and remains retryable. This uses macOS +unmute it and raise it to at least that level. Overlapping alarm runners share +the original output state across library profiles: only the last runner to +release it attempts restoration. Transferring playback to the interactive TUI +preserves the current output level. A partial mixer change triggers a rollback +attempt; a failed restore is reported and remains retryable. If every runner +is forcibly terminated, recovery occurs on a later system-volume operation +after any surviving mixer helper exits. An incomplete helper record blocks +automatic adjustment and preserves the saved output state. This uses macOS output-volume scripting, Windows Core Audio through built-in PowerShell, or `wpctl`, `pactl`, or `amixer` on Linux. If the host exposes none of those controls, player volume still applies and runtime diagnostics report that the system output could not be raised. +If diagnostics report an unknown mixer helper after a crash, restart the +computer before clearing `native-write.json` and the `lock` directory from +`.radiocli/system-volume` under the OS account's home directory. Keep +`state.json`: it contains the output state needed for the next recovery attempt. + ## Alarm Troubleshooting Use the following in order: diff --git a/docs/superpowers/plans/2026-09-07-adversarial-remediation.md b/docs/superpowers/plans/2026-09-07-adversarial-remediation.md new file mode 100644 index 0000000..605618f --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-adversarial-remediation.md @@ -0,0 +1,55 @@ +# Adversarial review remediation + +**Goal:** Resolve original review findings 1–9 on `codex/comprehensive-cross-platform-support`, based on reviewed commit `7762f93`. + +**Architecture:** Keep platform policy separate from native mechanics. Preserve launch identity through one explicit environment contract; use structured native absence/failure results; make deletion one awaited operation that persists the disabled state before native cleanup. Share the original output-volume baseline across runners, serialize mutations, and journal native write helpers through actual process exit. Consolidate package recipes and remove obsolete code. + +**Execution:** Use the subagent-driven-development and dispatching-parallel-agents workflows for independent platform tasks, with local integration and adversarial review. Work in the existing feature checkout. Changes remain reviewable without automatic commits or publication. + +## Original findings and acceptance criteria + +- [x] **1 — High / Bug:** `use-alarm-tui.ts` abandons a deletion promise whose recovery write can throw. Cover native failure plus storage failure and native success plus storage failure. Keep playback and navigation available, report native/persistence failures accurately, retain repairable definitions, and avoid unhandled rejections. +- [x] **2 — Medium / Bug:** `launchEnvironment` omits legacy/XDG storage selectors. Parent and child must select identical library, cache, and runtime paths with clean child environments, preserving supported historical namespaces. Do not persist arbitrary shell environment or secrets. +- [x] **3 — Medium / Bug:** Windows task removal recognizes absence only through English text. Remove/query only the named RadioCLI job, distinguish structured absence from access denial or unknown failure, and retain repair artifacts on unverified removal. +- [x] **4 — Medium / Bug:** `x-terminal-emulator` can invoke QTerminal while bypassing its command-string quoting fix. Preserve exact argv through supported aliases, including spaced executable paths, apostrophes, shell metacharacters, flags, and multiple arguments. +- [x] **5 — Medium / Architecture:** Independent system-volume leases restore conflicting snapshots. Share one baseline across runner processes, serialize mutations, and restore only after the final participant releases. Track orphan native helpers through exit before recovering a dead runner. Cover failure, retry, and overlap without touching real audio devices. +- [x] **6 — Low / Bug:** Desktop helper timeout resolves after SIGTERM without ensuring termination. Add bounded escalation and lifecycle cleanup; test a real owned process that ignores SIGTERM. +- [x] **7 — Low / Redundancy:** Setup recipes and textual hints have parallel platform/package branches. Generate ordinary hints from the selected package recipe; retain explicit manual prerequisites. Consolidate duplicate OS-release reading. +- [x] **8 — Low / Dead Code:** Remove `noColorRequested`, `openExternalCommand`, and `clipboardCommands` production exports used only by tests. Keep behavioral coverage on actual entry points. Migrate internal consumers and remove redundant compatibility forwarding modules. +- [x] **9 — Low / Redundancy:** Both Linux matrices run identical packed installs. Retain one packed-install matrix and the distinct playback smoke. + +## Implementation sequence + +1. Add behavioral regression tests for deletion; run them against the unfixed implementation; implement awaited recovery and rerun focused UI/service tests. +2. Independently implement launch identity/alias forwarding, structured Windows task cleanup, and package/CI consolidation, each with failing-first regression coverage. +3. Implement shared system-volume ownership with short serialized transactions, testing separate controllers/processes, failed acquisition, failed restoration, release retry, handoff contention, and orphan native helpers. +4. Strengthen desktop helper cleanup with actual process-exit verification; remove obsolete exports and migrate compatibility imports. +5. Review each independent change for requirements and code quality; resolve integration failures without weakening assertions. + +## Validation and final review + +- [x] Run focused tests while developing and record the original failure and corrected behavior. +- [x] Run full Vitest suite and Node package-smoke harness tests. +- [x] Run `npm run check`, `npm run lint`, `npm run build`, `npm run check:package`, and repository-standard smoke/package checks that apply. +- [x] Run docs checks/build if documentation or generated usage contracts change. +- [x] Search for deleted symbols/imports and inspect `git diff --check` and the complete diff. +- [x] Re-run review reproductions for all nine findings. +- [x] Perform fresh adversarial review of recovery, native resource scope, clean launch environments, volume ownership, and real helper teardown; fix any new defects. +- [x] Report concrete fixes, deletions, regression coverage, actual validation results, remaining limitations, and merge verdict. + +## Final review corrections + +- Canonicalize relative and empty path selectors against the invoking directory. +- Preserve long-lived browser openers while fully stopping owned clipboard helpers and launch bootstraps. +- Commit volume participants only after native adjustment and persistence succeed. +- Await handoff ownership changes before runner cleanup and allow the client to wait through lock contention. +- Journal native writes before spawn; retain uncertain state and wait for surviving helpers before crash recovery. +- Migrate the packed playback harness's dynamically constructed import after deleting the executable forwarding module. + +## Validation results + +Final native macOS ARM64 runs on Node 22.23.2 and 24.20.0 each passed all 1,983 tests across 95 files, with 9 native-platform skips. Both runtime lines passed normal and omit-optional fresh package installation, CLI/doctor/offline cache/import/export, 31 MCP tools, and real mpv readiness/volume/pause/resume/stop using null audio output. + +The following checks also passed: `npm run check`, `npm run lint`, `npm run build`, `npm run check:package`, `npm run pack:check`, `npm run smoke:data`, `npm run smoke:mcp`, `npm run smoke:playback`, `npm run docs:check`, `npm run docs:build`, `npm run audit:runtime`, `npm run audit:docs`, `actionlint .github/workflows/ci.yml`, and `git diff --check`. The two Node package-harness files passed all 12 tests. A built CLI setup dry run in isolated storage printed the expected Homebrew plan without installing packages. + +Live Windows/BSD/Linux scheduler and desktop integration, native OS mixer behavior, and audible hardware playback remain unverified on this macOS host. Forced-exit recovery waits for any surviving recorded mixer helper; an unknown helper journal requires the documented restart/repair procedure. PID reuse is handled conservatively. Manual root review and fresh independent review found no further concrete defects after the corrections above. diff --git a/scripts/packed-smoke.mjs b/scripts/packed-smoke.mjs index 7a09a7d..ad4fbc2 100644 --- a/scripts/packed-smoke.mjs +++ b/scripts/packed-smoke.mjs @@ -237,7 +237,7 @@ export async function smokeMcp({cli, cwd, env, nodeArgs = []}) { async function exercisePlayback(packageRoot, home) { const {PlayerController} = await import(pathToFileURL(join(packageRoot, 'dist', 'player', 'player-controller.js')).href); const {JsonLibraryStore} = await import(pathToFileURL(join(packageRoot, 'dist', 'storage', 'store.js')).href); - const {resolveCommand} = await import(pathToFileURL(join(packageRoot, 'dist', 'player', 'command.js')).href); + const {resolveCommand} = await import(pathToFileURL(join(packageRoot, 'dist', 'platform', 'executables.js')).href); const mpv = resolveCommand('mpv'); assert.ok(mpv, 'mpv executable is required.'); console.log(runCommand(mpv, ['--version']).stdout.split('\n')[0]); diff --git a/src/agent/launcher.test.ts b/src/agent/launcher.test.ts index 5523d6c..fa4c966 100644 --- a/src/agent/launcher.test.ts +++ b/src/agent/launcher.test.ts @@ -1,18 +1,13 @@ import {EventEmitter} from 'node:events'; import {spawn as nodeSpawn,type ChildProcess,type SpawnOptions} from 'node:child_process'; -import {mkdtempSync,mkdirSync,rmSync,writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; -import {join} from 'node:path'; import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; -import {launchRadioTui,resolveExecutable} from './launcher.js'; +import {launchRadioTui} from './launcher.js'; vi.mock('node:child_process',async importOriginal=>({...await importOriginal(),spawn:vi.fn()})); -const roots:string[]=[]; const calls:Array<{command:string;args:readonly string[];options?:SpawnOptions}>=[]; const spawn=vi.fn((command:string,args:readonly string[],options?:SpawnOptions)=>{calls.push({command,args,options});const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',0);});return child;}); beforeEach(()=>{calls.length=0;spawn.mockClear();vi.mocked(nodeSpawn).mockImplementation(spawn as unknown as typeof nodeSpawn);}); -afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); -function fixture(){const root=mkdtempSync(join(tmpdir(),'radiocli-launcher-'));roots.push(root);return root;} +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();}); function nodeInvocation(args: readonly string[], environment: NodeJS.ProcessEnv = {}): {command: string; args: string[]} { const encodedScript = args[args.indexOf('-EncodedCommand') + 1]; const script = Buffer.from(encodedScript!, 'base64').toString('utf16le'); @@ -25,10 +20,6 @@ function nodeInvocation(args: readonly string[], environment: NodeJS.ProcessEnv } describe('agent graphical launcher',()=>{ - it('does not resolve a directory as a command',()=>{const root=fixture();const path=join(root,'radiocli-test-command');mkdirSync(path);expect(resolveExecutable(path,{PATH:root},'linux')).toBeUndefined();}); - it.skipIf(process.platform==='win32')('requires executable permission for Unix commands',()=>{const root=fixture();const path=join(root,'radiocli-test-command');writeFileSync(path,'test',{mode:0o600});expect(resolveExecutable(path,{PATH:root},'linux')).toBeUndefined();}); - it('preserves the existing executable lookup API for runnable files',()=>{const root=fixture();const path=join(root,`radiocli-test-command${process.platform==='win32'?'.exe':''}`);writeFileSync(path,'test',{mode:0o700});expect(resolveExecutable('radiocli-test-command',{PATH:root},process.platform)).toBe(path);}); - it.each(['win32:console','win32:windows-terminal'])('encodes hostile paths and the data home without cmd interpolation in %s',async terminal=>{ const home='C:\\Data %PATH%! & "quoted" \' 单播';const nodePath='C:\\Node %TEMP%! & 单播\\node.exe';const cliPath='C:\\Radio %PATH%! & 单播\\cli.js'; await launchRadioTui(nodePath,cliPath,'encoded-agent-command',{platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:terminal,RADIOCLI_HOME:home},spawn,resolve:command=>command}); @@ -45,5 +36,9 @@ describe('agent graphical launcher',()=>{ }); it('does not launch a graphical terminal in a headless Unix session',async()=>{await expect(launchRadioTui('/node','/cli.js','encoded',{platform:'linux',env:{RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},spawn,resolve:command=>command})).rejects.toThrow(/graphical|display|headless/i);expect(spawn).not.toHaveBeenCalled();}); - it.each(['freebsd','openbsd','netbsd'] as const)('requests the same agent session command in a supported %s desktop',async platform=>{await launchRadioTui('/node','/cli.js','encoded',{platform,env:{DISPLAY:':0',TERMINAL:'xterm'},spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined});expect(calls[0]).toMatchObject({command:'/usr/local/bin/xterm',args:['-e','/node','/cli.js','agent-ui','encoded']});}); + it.each(['freebsd','openbsd','netbsd'] as const)('requests the same agent session command in a supported %s desktop',async platform=>{ + await launchRadioTui('/node','/cli.js','encoded',{platform,env:{DISPLAY:':0',TERMINAL:'xterm'},spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined}); + expect(calls[0]!.command).toBe('/usr/local/bin/xterm');expect(calls[0]!.args.slice(0,3)).toEqual(['-e','/node','-e']); + expect(JSON.parse(Buffer.from(calls[0]!.args.at(-1)!,'base64url').toString('utf8'))).toEqual({args:['/cli.js','agent-ui','encoded'],environment:{}}); + }); }); diff --git a/src/agent/launcher.ts b/src/agent/launcher.ts index 203a62f..9d02026 100644 --- a/src/agent/launcher.ts +++ b/src/agent/launcher.ts @@ -1,6 +1,6 @@ import {spawn} from 'node:child_process'; -import {resolveCommandDetails} from '../platform/executables.js'; -import {launchTerminalCommand, waitForLaunch, type TerminalOptions} from '../platform/terminals.js'; +import {launchTerminalCommand, type TerminalOptions} from '../platform/terminals.js'; +import {waitForLaunch} from '../platform/launch-command.js'; /** The agent service verifies the authenticated session after this request. */ export async function launchRadioTui(nodePath: string, cliPath: string, encodedCommand: string, options: TerminalOptions = {}): Promise { @@ -10,11 +10,3 @@ export async function launchRadioTui(nodePath: string, cliPath: string, encodedC export async function launchHeadlessHost(nodePath: string, cliPath: string): Promise { await waitForLaunch(spawn(nodePath, [cliPath, 'agent-host'], {detached: true, stdio: 'ignore', windowsHide: false})); } - -export function resolveExecutable( - input: string, - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform -): string | undefined { - return resolveCommandDetails(input, {platform, env}).path ?? undefined; -} diff --git a/src/agent/mcp-install.ts b/src/agent/mcp-install.ts index 6ea248c..b427a55 100644 --- a/src/agent/mcp-install.ts +++ b/src/agent/mcp-install.ts @@ -6,7 +6,7 @@ import {applyEdits, modify, parse, type FormattingOptions} from 'jsonc-parser'; import type {Writable} from 'node:stream'; import {JsonLibraryStore} from '../storage/store.js'; import {defaultAgentControlSettings} from '../types.js'; -import {resolveExecutable} from './launcher.js'; +import {resolveCommandDetails} from '../platform/executables.js'; import type {AgentRuntime} from './service.js'; export type McpInstallResult = {client: string; status: 'configured' | 'removed' | 'not-found' | 'failed' | 'inherited'; detail: string}; @@ -41,7 +41,7 @@ export async function configureMcpIntegrations( : ['mcp', 'remove', 'radiocli'], ['mcp', 'remove', 'radiocli'])); } else results.push(notFound('Codex')); - const claude = resolveExecutable('claude'); + const claude = resolveCommandDetails('claude', {env: process.env}).path; if (claude) { results.push(await configureCliClient('Claude Code', claude, enabled ? ['mcp', 'add', '--scope', 'user', 'radiocli', '--', ...command] @@ -49,7 +49,7 @@ export async function configureMcpIntegrations( } else results.push(notFound('Claude Code')); const openCodePath = openCodeConfigPath(); - if (resolveExecutable('opencode') || existsSync(openCodePath)) { + if (resolveCommandDetails('opencode', {env: process.env}).path || existsSync(openCodePath)) { try { updateOpenCodeConfig(openCodePath, enabled, command); results.push({client: 'OpenCode', status: enabled ? 'configured' : 'removed', detail: openCodePath}); @@ -61,14 +61,14 @@ export async function configureMcpIntegrations( configureJsonClient(results, 'Cursor', cursorDetected(), join(homedir(), '.cursor', 'mcp.json'), ['mcpServers', 'radiocli'], enabled, { command: command[0], args: command.slice(1) }); - configureJsonClient(results, 'Gemini CLI', Boolean(resolveExecutable('gemini')), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, { + configureJsonClient(results, 'Gemini CLI', Boolean(resolveCommandDetails('gemini', {env: process.env}).path), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, { command: command[0], args: command.slice(1) }); configureJsonClient(results, 'VS Code / Copilot Agent Host', vsCodeDetected(), join(homedir(), '.copilot', 'mcp-config.json'), ['servers', 'radiocli'], enabled, { type: 'stdio', command: command[0], args: command.slice(1) }); - if (resolveExecutable('orca')) { + if (resolveCommandDetails('orca', {env: process.env}).path) { results.push({client: 'Orca', status: 'inherited', detail: 'Orca exposes MCP integrations through its configured Codex/Claude agent runtimes.'}); } else results.push(notFound('Orca')); @@ -195,7 +195,7 @@ export function probeMcpServer(command: string[], timeoutMs = 5_000): Promise<{o } function matchingRadioCliShim(runtime: AgentRuntime, platform: NodeJS.Platform): string | undefined { - const candidate = resolveExecutable('radiocli', process.env, platform); + const candidate = resolveCommandDetails('radiocli', {env: process.env, platform}).path; if (!candidate) return undefined; if (!usableDirectLauncher(candidate, platform)) return undefined; try { @@ -216,16 +216,16 @@ async function mcpClientStates(command: string[]): Promise { +async function claudeClientState(executable: string | null | undefined, expected: string[]): Promise { if (!executable) return managedClientState(false); const result = await run(executable, ['mcp', 'get', 'radiocli']); const detail = `${result.stdout}\n${result.stderr}`.trim(); diff --git a/src/alarms/active-session.test.ts b/src/alarms/active-session.test.ts index e61adde..b2d6caf 100644 --- a/src/alarms/active-session.test.ts +++ b/src/alarms/active-session.test.ts @@ -4,10 +4,44 @@ import {join} from 'node:path'; import {createServer} from 'node:http'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {connectActiveAlarm,connectActiveAlarms, startActiveAlarmSession} from './active-session.js'; +import {createSystemVolumeController} from './system-volume.js'; +import {SystemVolumeOwnership} from './system-volume-ownership.js'; const dirs:string[]=[]; afterEach(()=>dirs.splice(0).forEach(path=>rmSync(path,{recursive:true,force:true}))); +it('waits for handoff ownership contention beyond the short discovery deadline',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-handoff-lock-'));dirs.push(root); + const directory=join(root,'volume'); + const run=async(_command:string,args:string[])=>({code:0,stdout:args.includes('set s to get volume settings')?'20,true':'',stderr:''}); + const lease=await createSystemVolumeController('darwin',run,()=>null,{directory}).acquireMinimum(70); + let unlock:()=>void=()=>{};let lockReady:()=>void=()=>{}; + const ready=new Promise(resolve=>{lockReady=resolve;}); + const holding=new SystemVolumeOwnership(directory).transaction(async()=>{lockReady();await new Promise(resolve=>{unlock=resolve;});}); + await ready; + let server:Awaited>|undefined; + let timer:NodeJS.Timeout|undefined; + let startedHandoff:()=>void=()=>{}; + const started=new Promise(resolve=>{startedHandoff=resolve;}); + let handedOff=false; + try{ + const file=join(root,'active.json'); + server=await startActiveAlarmSession({alarmId:'a',scheduledAt:'2030-01-01T00:00:00Z',stationName:'A',startedAt:'2030-01-01T00:00:00Z'},{ + filePath:file,onDismiss:vi.fn(),onSnooze:vi.fn(),onKeepPlaying:vi.fn(), + async onHandoff(){startedHandoff();await lease.release({preserve:true});handedOff=true;} + }); + const client=await connectActiveAlarm(file); + const handoff=client!.handoff().then(()=>({ok:true}),error=>({error})); + await started; + timer=setTimeout(unlock,1_200); + expect(await handoff).toEqual({ok:true}); + expect(handedOff).toBe(true); + }finally{ + clearTimeout(timer);unlock();await holding; + await server?.close();await lease.release(); + } +}); + describe('active alarm IPC',()=>{ it('discovers, authenticates, controls, and removes a local session',async()=>{ const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-'));dirs.push(root);const file=join(root,'active.json'); diff --git a/src/alarms/active-session.ts b/src/alarms/active-session.ts index bb3e5c6..16a1752 100644 --- a/src/alarms/active-session.ts +++ b/src/alarms/active-session.ts @@ -45,7 +45,8 @@ export async function connectActiveAlarm(filePath?:string):Promise{const payload=body===undefined?'':JSON.stringify(body);return new Promise((resolve,reject)=>{const req=request({host:discovery.host,agent:false,port:discovery.port,path,method,headers:{authorization:`Bearer ${discovery.token}`,'content-type':'application/json','content-length':Buffer.byteLength(payload)}},res=>{let text='';res.on('data',value=>text+=String(value));res.on('end',()=>res.statusCode&&res.statusCode<300?resolve(text?JSON.parse(text):{}):reject(new Error('Alarm control request failed.')));});req.once('error',reject);req.setTimeout(1000,()=>req.destroy(new Error('Alarm control request timed out.')));req.end(payload);});}; + // Handoff can wait for the shared output lock (30s); discovery keeps its short deadline. + const call=async(method:string,path:string,body?:unknown)=>{const payload=body===undefined?'':JSON.stringify(body);return new Promise((resolve,reject)=>{const req=request({host:discovery.host,agent:false,port:discovery.port,path,method,headers:{authorization:`Bearer ${discovery.token}`,'content-type':'application/json','content-length':Buffer.byteLength(payload)}},res=>{let text='';res.on('data',value=>text+=String(value));res.on('end',()=>res.statusCode&&res.statusCode<300?resolve(text?JSON.parse(text):{}):reject(new Error('Alarm control request failed.')));});req.once('error',reject);req.setTimeout(path==='/handoff'?35_000:1000,()=>req.destroy(new Error('Alarm control request timed out.')));req.end(payload);});}; try{await call('GET','/status');}catch{if(!processAlive(discovery.pid)||discoveryAgeMs(filePath,discovery)>5*60_000)removeDiscoveryIfOwned(filePath,discovery);return null;} return {status:async()=>await call('GET','/status') as ActiveAlarmStatus,dismiss:async()=>{await call('POST','/dismiss');},snooze:async minutes=>{await call('POST','/snooze',{minutes});},keepPlaying:async()=>{await call('POST','/keep-playing');},handoff:async()=>{await call('POST','/handoff');}}; } diff --git a/src/alarms/inhibitor.ts b/src/alarms/inhibitor.ts index b1ea0b5..23578a9 100644 --- a/src/alarms/inhibitor.ts +++ b/src/alarms/inhibitor.ts @@ -1,5 +1,5 @@ import {spawn as nodeSpawn} from 'node:child_process'; -import {resolveCommandDetails} from '../player/command.js'; +import {resolveCommandDetails} from '../platform/executables.js'; import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; type InhibitorStatus = {supported: boolean; active: boolean; message: string}; diff --git a/src/alarms/runner.test.ts b/src/alarms/runner.test.ts index 429837a..53cc257 100644 --- a/src/alarms/runner.test.ts +++ b/src/alarms/runner.test.ts @@ -166,7 +166,31 @@ describe('alarm runner',()=>{ it('reconciles the next daily occurrence before keep-playing can run indefinitely',async()=>{const recurring:Alarm={...alarm,schedule:{type:'recurring',time:'08:00',weekdays:[1,2,3,4,5,6,7],timezone:'UTC'}};let signal:()=>void=()=>{};const sync=vi.fn(async()=>null);const d=deps({store:{...deps().store,getAlarm:vi.fn(()=>recurring)},scheduler:{sync},createSession:vi.fn(async(_status,handlers)=>{handlers.onKeepPlaying();return{update:vi.fn(),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>new Promise(()=>{})),subscribeSignals:handler=>{signal=handler;return vi.fn();}});const running=runAlarm('a',scheduledAt,d);for(let attempt=0;attempt<10&&sync.mock.calls.length===0;attempt+=1)await Promise.resolve();expect(sync).toHaveBeenCalledWith(recurring);signal();await running;}); - it('hands playback to the interactive player without restoring the alarm output level',async()=>{const release=vi.fn(async()=>{});const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(status,handlers)=>{expect(status.station).toEqual(station);return{update:vi.fn(change=>{if(change.state==='playing')queueMicrotask(()=>handlers.onHandoff?.());}),close:vi.fn(async()=>{})};})});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('played');expect(result.message).toContain('Handed off');expect(release).not.toHaveBeenCalled();expect(d.player.stop).toHaveBeenCalled();}); + it('hands playback to the interactive player and releases shared ownership while preserving output',async()=>{const release=vi.fn(async()=>{});const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(status,handlers)=>{expect(status.station).toEqual(station);return{update:vi.fn(change=>{if(change.state==='playing')queueMicrotask(()=>handlers.onHandoff?.());}),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>new Promise(()=>{}))});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('played');expect(result.message).toContain('Handed off');expect(release).toHaveBeenCalledExactlyOnceWith({preserve:true});expect(d.player.stop).toHaveBeenCalled();}); + + it('does not accept a handoff if preserving shared output ownership fails',async()=>{ + let handlers:Parameters[1]|undefined; + let playing:()=>void=()=>{};const started=new Promise(resolve=>{playing=resolve;}); + const release=vi.fn().mockRejectedValueOnce(new Error('ownership disk full')).mockResolvedValue(undefined); + const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(_status,value)=>{handlers=value;return{update:vi.fn(change=>{if(change.state==='playing')playing();}),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>new Promise(()=>{}))}); + const running=runAlarm('a',scheduledAt,d);await started; + await expect(Promise.resolve().then(()=>handlers!.onHandoff!())).rejects.toThrow('ownership disk full'); + await handlers!.onDismiss();expect((await running).status).toBe('dismissed'); + expect(release).toHaveBeenNthCalledWith(1,{preserve:true});expect(release).toHaveBeenNthCalledWith(2); + }); + + it('waits for an in-progress handoff before timeout cleanup can restore shared output',async()=>{ + let handlers:Parameters[1]|undefined; + let playing:()=>void=()=>{};const started=new Promise(resolve=>{playing=resolve;}); + let preserved:()=>void=()=>{};const preserving=new Promise(resolve=>{preserved=resolve;}); + let timedOut:()=>void=()=>{};const timeout=new Promise(resolve=>{timedOut=resolve;}); + const release=vi.fn(async(options?:{preserve?:boolean})=>{if(options?.preserve)await preserving;}); + const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(_status,value)=>{handlers=value;return{update:vi.fn(change=>{if(change.state==='playing')playing();}),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>timeout)}); + const running=runAlarm('a',scheduledAt,d);await started;const handoff=handlers!.onHandoff!();timedOut(); + try{for(let turn=0;turn<20;turn+=1)await Promise.resolve();expect(release).toHaveBeenCalledExactlyOnceWith({preserve:true});} + finally{preserved();await handoff;await running;} + expect(release).toHaveBeenCalledExactlyOnceWith({preserve:true}); + }); it('claims the alarm before resolving and preempts interactive playback before raising output or tuning',async()=>{const events:string[]=[];const base=deps();const d=deps({providers:{resolve:vi.fn(async()=>{events.push('resolve');return{url:'https://main'};})},preemptInteractivePlayback:vi.fn(async()=>{events.push('preempt');}),systemVolume:{acquireMinimum:vi.fn(async()=>{events.push('system-volume');return{message:'raised',release:vi.fn(async()=>{})};})},player:{...base.player,play:vi.fn(async()=>{events.push('play');})},createSession:vi.fn(async(status,handlers)=>{events.push(`claim-${status.state}`);return{update:vi.fn(change=>{events.push(`update-${change.state}`);queueMicrotask(()=>handlers.onDismiss());}),close:vi.fn(async()=>{})};})});await runAlarm('a',scheduledAt,d);expect(events).toEqual(['claim-starting','resolve','preempt','system-volume','play','update-playing']);}); diff --git a/src/alarms/runner.ts b/src/alarms/runner.ts index 9fd615b..247f405 100644 --- a/src/alarms/runner.ts +++ b/src/alarms/runner.ts @@ -46,6 +46,7 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR const recordRunnerWarning=(message:string)=>{runnerWarnings.add(message);try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:listening,message:[...runnerWarnings].join(' ')});}catch{}}; let preserveNextOverride=false; let preserveSystemVolume=false; + let handoffOutput:Promise|undefined; let validTerminalOccurrence=false; let completeLock=true; let signalReceived=false;let resolveEarlySignal:()=>void=()=>{};let onPlaybackSignal:()=>void=()=>{};const earlySignal=new Promise(resolve=>{resolveEarlySignal=resolve;});const unsubscribeSignals=deps.subscribeSignals?.(()=>{signalReceived=true;resolveEarlySignal();onPlaybackSignal();void deps.player.stop().catch(()=>undefined);}); @@ -64,7 +65,7 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR onDismiss:()=>{action='dismissed';resolveAction('dismissed');resolveEarlySignal();}, onSnooze:minutes=>{deps.store.snoozeAlarm(alarmId,new Date(deps.now().getTime()+minutes*60_000));preserveNextOverride=true;action='snoozed';resolveAction('snoozed');resolveEarlySignal();}, onKeepPlaying:()=>{keepPlaying=true;session?.update({keepPlaying:true});}, - onHandoff:()=>{if(!listening)throw new Error('Alarm playback is still starting.');preserveSystemVolume=true;action='handoff';resolveAction('handoff');} + onHandoff:async()=>{if(!listening)throw new Error('Alarm playback is still starting.');handoffOutput=(systemVolumeLease?.release({preserve:true})??Promise.resolve()).then(()=>{preserveSystemVolume=true;});await handoffOutput;action='handoff';resolveAction('handoff');} });void creatingSession.then(created=>{if(signalReceived)void created.close().catch(()=>undefined);}).catch(()=>{});const created=await Promise.race([creatingSession.then(value=>({value})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in created){outcome=finish('dismissed','Alarm interrupted while local controls were starting.');return{status:'dismissed',message:outcome.message};}session=created.value; const claimed=deps.store.getAlarm(alarmId);if(claimed?.enabled&&claimed.schedule.type==='recurring'){if(deps.scheduler.syncClaimed)await deps.scheduler.syncClaimed(claimed,scheduledAt);else await deps.scheduler.sync(claimed);} let resolvedStation=alarm.station;let lastError:unknown; @@ -97,6 +98,7 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR listening=false; unsubscribeSignals?.(); try{await deps.player.stop();}catch{} + await handoffOutput?.catch(()=>undefined); if(!preserveSystemVolume)try{await systemVolumeLease?.release();}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Previous system output volume could not be restored: ${errorMessage(error)}`});}catch{}} if(historyStarted){ try{deps.store.checkpointActiveListeningSession(deps.now());}catch(error){recordRunnerWarning(`Listening history could not be checkpointed: ${errorMessage(error)}`);} diff --git a/src/alarms/scheduler.test.ts b/src/alarms/scheduler.test.ts index 155b8be..da6ae20 100644 --- a/src/alarms/scheduler.test.ts +++ b/src/alarms/scheduler.test.ts @@ -1,10 +1,12 @@ import {describe, expect, it, vi} from 'vitest'; import {existsSync,mkdirSync,mkdtempSync,readdirSync,rmSync,writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; -import {basename,dirname,join} from 'node:path'; +import {basename,dirname,join,posix,win32} from 'node:path'; import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; import type {Alarm} from '../types.js'; import {createSchedulerAdapter, SchedulerService,shouldRunLaunchdOccurrence,type SchedulerAdapter} from './scheduler.js'; +import {pathEnvironmentKeys, platformPaths} from '../platform/paths.js'; const alarm: Alarm = { id: 'wake<&"', label: 'Morning <&>', enabled: true, @@ -26,6 +28,35 @@ function windowsTaskInvocation(body: string): {command: string; args: string[]} return JSON.parse(Buffer.from(encoded!, 'base64').toString('utf8')); } +const decodeXml = (value: string) => value.replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll(''', "'").replaceAll('&', '&'); +function schedulerInvocation(body: string, platform: 'darwin' | 'linux' | 'win32'): {command: string; args: string[]} { + if (platform === 'win32') return windowsTaskInvocation(body); + const values: string[] = platform === 'darwin' + ? [.../ProgramArguments<\/key>([\s\S]*?)<\/array>/.exec(body)![1]!.matchAll(/([\s\S]*?)<\/string>/g)].map(match => decodeXml(match[1]!)) + : [.../^ExecStart=:(.+)$/m.exec(body)![1]!.matchAll(/"(?:[^"\\]|\\.)*"/g)].map(match => (JSON.parse(match[0]) as string).replaceAll('%%', '%')); + return {command: values[0]!, args: values.slice(1)}; +} +function schedulerPayload(body: string, platform: 'darwin' | 'linux' | 'win32'): {args: string[]; environment: Record} { + return JSON.parse(Buffer.from(schedulerInvocation(body, platform).args.at(-1)!, 'base64url').toString('utf8')); +} + +function schedulerEnvironment(body: string, platform: 'darwin' | 'linux' | 'win32'): Record { + if (platform === 'win32') return {}; + if (platform === 'darwin') { + const environment = /EnvironmentVariables<\/key>([\s\S]*?)<\/dict>/.exec(body)![1]!; + return Object.fromEntries([...environment.matchAll(/([^<]+)<\/key>([^<]*)<\/string>/g)].map(match => [decodeXml(match[1]!), decodeXml(match[2]!)])); + } + return Object.fromEntries([...body.matchAll(/^Environment=(.+)$/gm)].map(match => { + const entry = (JSON.parse(match[1]!) as string).replaceAll('%%', '%'); + return [entry.slice(0, entry.indexOf('=')), entry.slice(entry.indexOf('=') + 1)]; + })); +} + +const windowsTaskPath = (id: string) => `\\RadioCLI\\io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`; +function windowsLookupResult(overrides: Record = {}) { + return {code: 0, stdout: JSON.stringify({taskPath: windowsTaskPath(alarm.id), stage: 'lookup', status: 'error', hresult: -2147024894, message: 'El sistema no puede encontrar el archivo especificado.', ...overrides}), stderr: ''}; +} + describe('native alarm schedulers', () => { it('generates safely escaped launchd artifacts and an argv-only command', async () => { const write = vi.fn(); const run = vi.fn(async () => ({code: 0, stdout: '', stderr: ''})); @@ -37,14 +68,14 @@ describe('native alarm schedulers', () => { expect(xml).toContain('/Node A/node'); expect(xml).not.toContain('Year'); expect(xml).not.toContain('Second'); - expect(xml).toContain('internal-launchd'); + expect(schedulerPayload(xml, 'darwin').args).toEqual(['/App A/dist/cli.js', 'alarm', 'internal-launchd', alarm.id, '2030-02-03T14:05:00.000Z']); expect(xml).toContain('RADIOCLI_HOME/Data & A'); expect(xml).toContain('RADIOCLI_ALARM_TERMINALdarwin:apple-terminal'); expect((run.mock.calls as unknown as Array<[string,string[]]>).every(call => Array.isArray(call[1]))).toBe(true); }); it('makes an early annual launchd calendar match a true no-op',()=>{expect(shouldRunLaunchdOccurrence('2035-02-03T14:05:00Z',new Date('2030-02-03T14:05:00Z'))).toBe(false);expect(shouldRunLaunchdOccurrence('2035-02-03T14:05:00Z',new Date('2035-02-03T14:05:00Z'))).toBe(true);}); - it('installs the next launchd occurrence without booting out the current runner',async()=>{const writes:Array<[string,string]>=[];const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='print'?1:0,stdout:'',stderr:''}));const adapter=createSchedulerAdapter({platform:'darwin',home:'/Users/a',nodePath:'/node',cliPath:'/cli.js',writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run,env:{}});const recurring:Alarm={...alarm,schedule:{type:'recurring',time:'14:05',weekdays:[1,2,3,4,5,6,7],timezone:'UTC'}};const service=new SchedulerService(adapter,()=>new Date('2030-02-03T14:06:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[])} as never);await expect(service.syncClaimed(recurring,new Date('2030-02-03T14:05:00Z'))).resolves.toEqual(new Date('2030-02-04T14:05:00Z'));expect(run.mock.calls.map(call=>call[1]?.[0])).not.toContain('bootout');expect(run).toHaveBeenCalledWith('launchctl',expect.arrayContaining(['bootstrap']));expect(writes[0]?.[0]).toMatch(/io\.radiocli\.alarm\.[a-f0-9]+\.[a-f0-9]+\.plist$/);expect(writes[0]?.[1]).toContain('2030-02-04T14:05:00.000Z');}); + it('installs the next launchd occurrence without booting out the current runner',async()=>{const writes:Array<[string,string]>=[];const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='print'?1:0,stdout:'',stderr:''}));const adapter=createSchedulerAdapter({platform:'darwin',home:'/Users/a',nodePath:'/node',cliPath:'/cli.js',writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run,env:{}});const recurring:Alarm={...alarm,schedule:{type:'recurring',time:'14:05',weekdays:[1,2,3,4,5,6,7],timezone:'UTC'}};const service=new SchedulerService(adapter,()=>new Date('2030-02-03T14:06:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[])} as never);await expect(service.syncClaimed(recurring,new Date('2030-02-03T14:05:00Z'))).resolves.toEqual(new Date('2030-02-04T14:05:00Z'));expect(run.mock.calls.map(call=>call[1]?.[0])).not.toContain('bootout');expect(run).toHaveBeenCalledWith('launchctl',expect.arrayContaining(['bootstrap']));expect(writes[0]?.[0]).toMatch(/io\.radiocli\.alarm\.[a-f0-9]+\.[a-f0-9]+\.plist$/);expect(schedulerPayload(writes[0]![1],'darwin').args).toContain('2030-02-04T14:05:00.000Z');}); it.each([['nonzero',false],['rejected',true]] as const)('retains a stable launchd artifact when bootout is %s and the job is still loaded',async(_label,rejected)=>{const remove=vi.fn();const run=vi.fn(async(_command:string,args:string[])=>{if(args[0]==='bootout'){if(rejected)throw new Error('transport');return{code:5,stdout:'',stderr:'busy'};}return{code:0,stdout:'loaded',stderr:''};});const adapter=createSchedulerAdapter({platform:'darwin',home:'/Users/a',nodePath:'/node',cliPath:'/cli.js',writeFile:vi.fn(),removeFile:remove,run,env:{}});await expect(adapter.completeOccurrence!(alarm.id,new Date(alarm.schedule.type==='once'?alarm.schedule.at:''))).rejects.toThrow(/still loaded/i);expect(remove).not.toHaveBeenCalled();}); @@ -64,7 +95,7 @@ describe('native alarm schedulers', () => { await adapter.install(alarm, new Date('2030-02-03T14:05:00.000Z')); expect(writes.map(item => item[1]).join('\n')).toContain('Persistent=true'); expect(writes.map(item => item[1]).join('\n')).toContain('AccuracySec=1s'); - expect(writes.map(item => item[1]).join('\n')).toContain('"/opt/radio cli/dist/cli.js"'); + expect(schedulerPayload(writes.find(([path]) => path.endsWith('.service'))![1], 'linux').args).toEqual(['/opt/radio cli/dist/cli.js', 'alarm', 'internal-run', alarm.id, '2030-02-03T14:05:00.000Z']); expect(writes.map(item => item[1]).join('\n')).toContain('RADIOCLI_ALARM_TERMINAL=linux:unsupported'); }); @@ -104,7 +135,7 @@ describe('native alarm schedulers', () => { expect(artifact).toContain('encoding="UTF-8"'); const invocation=windowsTaskInvocation(artifact); expect(invocation.command).toBe('C:\\Node A\\node.exe'); - expect(JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')).environment).toEqual({RADIOCLI_HOME:'C:\\Profile & A',RADIOCLI_ALARM_TERMINAL:'win32:console'}); + expect(JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')).environment).toEqual({LOCALAPPDATA:'C:\\Data A',RADIOCLI_HOME:'C:\\Profile & A',RADIOCLI_ALARM_TERMINAL:'win32:console'}); expect(artifact).not.toContain('&amp;'); expect(adapter.capabilities().exactWake).toBe(false); expect((run.mock.calls as unknown as Array<[string,string[]]>)[0]?.[1]).toContain('/XML'); @@ -135,7 +166,8 @@ describe('native alarm schedulers', () => { const adapter=createSchedulerAdapter({platform:'linux',home:'/home/a',nodePath:'/Node $ ${CUSTOM} % 单播/node',cliPath:'/app-${CUSTOM}/% spaced 单播/cli.js',env:{RADIOCLI_HOME:'/data $ ${CUSTOM} % 单播'},commandExists:()=>true,writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); const service=writes.find(([path])=>path.endsWith('.service'))![1]; - expect(service).toContain('ExecStart=:"/Node $ ${CUSTOM} %% 单播/node" "/app-${CUSTOM}/%% spaced 单播/cli.js"'); + expect(service).toContain('ExecStart=:"/Node $ ${CUSTOM} %% 单播/node" "-e"'); + expect(schedulerPayload(service, 'linux').args[0]).toBe('/app-${CUSTOM}/% spaced 单播/cli.js'); expect(service).toContain('Environment="RADIOCLI_HOME=/data $ ${CUSTOM} %% 单播"'); expect(service).not.toContain('$$'); }); @@ -150,6 +182,34 @@ describe('native alarm schedulers', () => { expect(artifact).not.toMatch(/proxy-user|private-password|private-secret|HTTPS_PROXY|ARBITRARY_SECRET/); }); + it.each(['darwin', 'linux', 'win32'] as const)('runs the generated %s scheduler command with its saved identity in clean and contaminated environments', async platform => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-scheduler-identity-')); + try { + const cliPath = join(root, "inspect $ % & ' 単播.cjs"); + writeFileSync(cliPath, `process.stdout.write(JSON.stringify({args:process.argv.slice(2),env:Object.fromEntries(${JSON.stringify(pathEnvironmentKeys)}.filter(key=>process.env[key]!==undefined).map(key=>[key,process.env[key]]))}))`); + const path = platform === 'win32' ? win32 : posix; + const dataRoot = platform === 'win32' ? 'C:\\Users\\scheduler-fixture' : root; + const configured = {HOME: dataRoot, USERPROFILE: path.join(dataRoot, 'profile'), APPDATA: '', LOCALAPPDATA: path.join(dataRoot, 'local'), RADIO_ATLAS_HOME: path.join(dataRoot, "alias $ % & ' 単播"), XDG_DATA_HOME: path.join(dataRoot, 'data'), XDG_CACHE_HOME: path.join(dataRoot, 'cache'), XDG_RUNTIME_DIR: path.join(dataRoot, 'runtime')}; + const writes: Array<[string, string]> = []; + const adapter = createSchedulerAdapter({platform, home: root, nodePath: process.execPath, cliPath, env: {...configured, HTTPS_PROXY: 'private-proxy-secret'}, commandExists: () => true, writeFile: (path, body) => writes.push([path, body]), removeFile: vi.fn(), run: vi.fn(async () => ({code: 0, stdout: '', stderr: ''}))}); + await adapter.install(alarm, new Date('2030-02-03T14:05:00Z')); + const artifact = writes.find(([path]) => platform !== 'linux' || path.endsWith('.service'))![1]; + const invocation = schedulerInvocation(artifact, platform); + const clean = {...process.env}; + for (const key of pathEnvironmentKeys) delete clean[key]; + const contaminated = {...clean, ...Object.fromEntries(pathEnvironmentKeys.map(key => [key, `${root}/stale-${key}`]))}; + for (const env of [clean, contaminated]) { + const observed = JSON.parse(execFileSync(invocation.command, invocation.args, {env: {...env, ...schedulerEnvironment(artifact, platform)}, encoding: 'utf8', windowsHide: true})); + expect(observed.args).toEqual(['alarm', platform === 'darwin' ? 'internal-launchd' : 'internal-run', alarm.id, '2030-02-03T14:05:00.000Z']); + // An empty APPDATA root denotes the invoking directory. The snapshot + // retains that location even if the native manager has another cwd. + expect(observed.env).toEqual({...configured, APPDATA: path.resolve(process.cwd())}); + expect(platformPaths({platform, home: root, env: observed.env})).toEqual(platformPaths({platform, home: root, env: configured})); + } + expect(artifact).not.toContain('private-proxy-secret'); + } finally {rmSync(root, {recursive: true, force: true});} + }); + it.each(['darwin','linux','win32'] as const)('rejects control characters in an approved %s job environment before writing artifacts',async platform=>{ const writeFile=vi.fn();const adapter=createSchedulerAdapter({platform,home:'/home/a',nodePath:'/node',cliPath:'/cli.js',env:{RADIOCLI_MPV_PATH:'/tools/mpv\nInjected=value'},commandExists:()=>true,writeFile,removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); await expect(adapter.install(alarm,new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/RADIOCLI_MPV_PATH.*control/i);expect(writeFile).not.toHaveBeenCalled(); @@ -205,10 +265,10 @@ describe('native alarm schedulers', () => { it.each(['darwin', 'linux', 'win32'] as const)('accepts failed %s removal only after a targeted absence response', async platform => { const removeFile = vi.fn(); - const run = vi.fn(async (_command: string, args: string[]) => { + const run = vi.fn(async (command: string, args: string[]) => { if (args[0] === 'print') return {code: 113, stdout: '', stderr: `Could not find service "${args[1]!.split('/').at(-1)}" in domain for user gui: 501`}; if (args.includes('show')) return {code: 1, stdout: 'LoadState=not-found\nActiveState=inactive\n', stderr: ''}; - if (args[0] === '/Query') return {code: 1, stdout: '', stderr: 'ERROR: The system cannot find the file specified.'}; + if (command === 'powershell.exe') return windowsLookupResult(); if (args.includes('daemon-reload')) return {code: 0, stdout: '', stderr: ''}; return {code: 1, stdout: '', stderr: 'already gone'}; }); @@ -284,7 +344,117 @@ describe('native alarm schedulers', () => { it.each(['darwin','linux','win32'] as const)('retains %s repair artifacts when native commands reject instead of returning status',async platform=>{const removeFile=vi.fn();const adapter=createSchedulerAdapter({platform,env:{},home:'/missing-radiocli-test-home',writeFile:vi.fn(),removeFile,commandExists:()=>true,run:async()=>{throw new Error('native transport failed');}});await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*removal/i);expect(removeFile).not.toHaveBeenCalled();}); - it('retains Windows repair metadata when a localized absence message cannot be verified',async()=>{const removeFile=vi.fn();const adapter=createSchedulerAdapter({platform:'win32',env:{},home:'/missing-radiocli-test-home',writeFile:vi.fn(),removeFile,run:async()=>({code:1,stdout:'',stderr:'ERROR: El sistema no puede encontrar el archivo especificado.'})});await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*absence/i);expect(removeFile).not.toHaveBeenCalled();}); + it.each([ + {hresult: -2147024894, message: 'El sistema no puede encontrar el archivo especificado.'}, + {hresult: -2147024894, message: 'Das System kann die angegebene Datei nicht finden.'}, + {hresult: -2147024893, message: '指定されたパスが見つかりません。'} + ])('removes Windows repair metadata after localized absence HRESULT $hresult: $message', async result => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult(result) : {code: 1, stdout: '', stderr: result.message}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).resolves.toBeUndefined(); + expect(removeFile).toHaveBeenCalledOnce(); + expect(run).toHaveBeenCalledTimes(2); + }); + + it.each([ + {label: 'access denied', hresult: -2147024891, message: 'Zugriff verweigert.'}, + {label: 'unknown failure', hresult: -2147467259, message: 'Erreur non spécifiée.'}, + {label: 'missing COM service', hresult: -2147024894, message: 'Servicio no encontrado.', stage: 'connect'}, + {label: 'inaccessible root folder', hresult: -2147024893, message: 'Chemin introuvable.', stage: 'root'} + ])('retains Windows repair metadata and diagnostics for $label', async ({label: _label, ...result}) => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult(result) : {code: 1, stdout: '', stderr: 'delete failed'}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + const failure = await adapter.remove(alarm.id).catch(error => error as Error); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(result.message); + expect((failure as Error).message).toContain('delete failed'); + expect((failure as Error).message).toContain(`0x${(result.hresult >>> 0).toString(16)}`); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it.each([ + {label: 'empty response', result: {code: 0, stdout: '', stderr: ''}}, + {label: 'plain localized text', result: {code: 1, stdout: '', stderr: 'El sistema no puede encontrar el archivo especificado.'}}, + {label: 'malformed JSON', result: {code: 0, stdout: '{broken', stderr: ''}}, + {label: 'null response', result: {code: 0, stdout: 'null', stderr: ''}}, + {label: 'array response', result: {code: 0, stdout: '[]', stderr: ''}}, + {label: 'string HRESULT', result: windowsLookupResult({hresult: '-2147024894'})}, + {label: 'unsupported status', result: windowsLookupResult({status: 'absent'})}, + {label: 'missing diagnostic', result: windowsLookupResult({message: null})}, + {label: 'unrelated task', result: windowsLookupResult({taskPath: `${windowsTaskPath(alarm.id)}.other`})}, + {label: 'failed process with valid JSON', result: {...windowsLookupResult(), code: 1}}, + {label: 'extra output', result: {...windowsLookupResult(), stdout: `notice\n${windowsLookupResult().stdout}`}} + ])('does not infer Windows task absence from $label', async ({result}) => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? result : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*absence|verify.*removal/i); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it('retains Windows repair metadata when successful deletion is followed by a still-present task', async () => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult({status: 'present', hresult: 0, message: ''}) : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(/still registered/i); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it('waits for native Windows absence verification before removing the artifact', async () => { + const removeFile = vi.fn(); + let finishLookup!: (result: ReturnType) => void; + const lookup = new Promise>(resolve => {finishLookup = resolve;}); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? lookup : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + const pending = adapter.remove(alarm.id); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2)); + expect(removeFile).not.toHaveBeenCalled(); + finishLookup(windowsLookupResult()); + await expect(pending).resolves.toBeUndefined(); + expect(removeFile).toHaveBeenCalledOnce(); + }); + + it('limits Windows deletion and lookup to the exact named task and retains unrelated artifacts', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-windows-remove-')); + try { + const id = "../other; $env:HOME & ' 単播"; + const target = windowsTaskPath(id); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult({taskPath: target}) : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', home: root, env: {LOCALAPPDATA: root}, run}); + await adapter.install({...alarm, id}, new Date('2030-02-03T14:05:00Z')); + await adapter.install(alarm, new Date('2030-02-03T14:05:00Z')); + run.mockClear(); + await adapter.remove(id); + const calls = run.mock.calls as unknown as Array<[string, string[]]>; + expect(calls[0]).toEqual(['schtasks.exe', ['/Delete', '/TN', target, '/F']]); + expect(calls[1]![0]).toBe('powershell.exe'); + const args = calls[1]![1]; + expect(args.slice(0, -1)).toEqual(['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand']); + const script = Buffer.from(args.at(-1)!, 'base64').toString('utf16le'); + expect(script).not.toContain(id); + expect(script).not.toMatch(/DeleteFolder|DeleteTask|GetTasks|Unregister-ScheduledTask/i); + const encodedPath = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(Buffer.from(encodedPath!, 'base64').toString('utf8')).toBe(target); + expect(readdirSync(join(root, 'RadioCLI', 'scheduler'))).toEqual([`${windowsTaskPath(alarm.id).split('\\').at(-1)}.xml`]); + } finally {rmSync(root, {recursive: true, force: true});} + }); + + it('bounds a stalled Windows deletion and absence probe without removing repair artifacts', async () => { + vi.useFakeTimers(); + try { + const removeFile = vi.fn(); + const run = vi.fn(() => new Promise>(() => {})); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + const pending = adapter.remove(alarm.id); + const assertion = expect(pending).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(20_000); + await assertion; + expect(run).toHaveBeenCalledTimes(2); + expect(removeFile).not.toHaveBeenCalled(); + } finally {vi.useRealTimers();} + }); it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('does not create or claim native alarm registration on %s',async platform=>{const writeFile=vi.fn();const run=vi.fn();const commandExists=vi.fn(()=>true);const adapter=createSchedulerAdapter({platform,env:{},writeFile,run,commandExists});expect(adapter.capabilities()).toMatchObject({supported:false,exactWake:false,catchUpAfterWake:false});await expect(adapter.install(alarm,new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/not supported/i);expect(writeFile).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();expect(commandExists).not.toHaveBeenCalled();}); }); diff --git a/src/alarms/scheduler.ts b/src/alarms/scheduler.ts index 435cce7..8d75fd7 100644 --- a/src/alarms/scheduler.ts +++ b/src/alarms/scheduler.ts @@ -9,9 +9,9 @@ import {nextOccurrenceForAlarm} from './schedule.js'; import {AlarmRuntimeHealthStore} from './runtime-health.js'; import {AlarmGuardService} from './guard.js'; import {AlarmPowerGuardStore} from './power-guard-store.js'; -import {detectAlarmTerminal} from './terminal-launcher.js'; +import {detectGraphicalTerminal} from '../platform/terminals.js'; import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; -import {resolveCommandDetails} from '../player/command.js'; +import {resolveCommandDetails} from '../platform/executables.js'; import {launchEnvironment, nodeLaunchCommand} from '../platform/launch-command.js'; import {powershellCommand} from '../platform/shell.js'; @@ -107,7 +107,7 @@ export function createSchedulerAdapter(deps: SchedulerDeps = {}): SchedulerAdapt const resolve = (command:string) => resolveCommandDetails(command,{platform,env,home}).path; const run = deps.run ?? ((command,args) => runCommand(resolve(command)??command,args)); const commandExists = deps.commandExists ?? (command => resolve(command)!==null); - const common = {home, nodePath, cliPath, env, terminal: detectAlarmTerminal(platform, env), write, removeFile, run}; + const common = {platform, home, nodePath, cliPath, env, terminal: detectGraphicalTerminal(platform, env), write, removeFile, run}; if (policy.scheduler === 'launchd') return launchdAdapter(common); if (policy.scheduler === 'task-scheduler') return windowsAdapter(common); if (policy.scheduler === 'systemd') { @@ -117,9 +117,9 @@ export function createSchedulerAdapter(deps: SchedulerDeps = {}): SchedulerAdapt return unsupportedAdapter(`Alarm scheduling is not supported on ${host.id==='unknown'?host.platform:host.id}.`); } -type Common = {home: string; nodePath: string; cliPath: string; env: NodeJS.ProcessEnv; terminal:string; write: (p:string,c:string)=>void; removeFile:(p:string)=>void; run:(c:string,a:string[])=>Promise}; +type Common = {platform: NodeJS.Platform; home: string; nodePath: string; cliPath: string; env: NodeJS.ProcessEnv; terminal:string; write: (p:string,c:string)=>void; removeFile:(p:string)=>void; run:(c:string,a:string[])=>Promise}; function runtimeEnvironment(common:Common):Record{ - return launchEnvironment(common.env, {includeDesktop: true, terminal: common.terminal}); + return launchEnvironment(common.env, {platform: common.platform, includeDesktop: true, terminal: common.terminal}); } const jobName = (id: string) => `io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`; const invocationArgs = (common: Common, id: string, occurrence: Date,internalCommand='internal-run') => [common.cliPath, 'alarm', internalCommand, id, occurrence.toISOString()]; @@ -131,7 +131,7 @@ function launchdAdapter(common: Common): SchedulerAdapter { const occurrenceLabel=(id:string,occurrence:Date)=>`${jobName(id)}.${createHash('sha256').update(occurrence.toISOString()).digest('hex').slice(0,12)}`; const occurrencePath=(id:string,occurrence:Date)=>join(directory,`${occurrenceLabel(id,occurrence)}.plist`); const pathsFor=(id:string)=>{const prefix=`${jobName(id)}.`;let extras:string[]=[];try{extras=readdirSync(directory).filter(name=>name.startsWith(prefix)&&name.endsWith('.plist')).map(name=>join(directory,name));}catch{}return[pathFor(id),...extras];}; - const installJob=async(alarm:Alarm,occurrence:Date,label:string,path:string,replace:boolean)=>{const local=localParts(occurrence);const entries=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`${xml(key)}${xml(value)}`).join('');const envArgs=`EnvironmentVariables${entries}`;const args=[common.nodePath,...invocationArgs(common,alarm.id,occurrence,'internal-launchd')].map(value=>`${xml(value)}`).join('');common.write(path,`\n\nLabel${label}ProgramArguments${args}${envArgs}StartCalendarIntervalMonth${local.month}Day${local.day}Hour${local.hour}Minute${local.minute}ProcessTypeInteractiveStandardOutPath/dev/nullStandardErrorPath/dev/null\n`);if(replace)await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);ensureSuccess(await common.run('launchctl',['bootstrap',`gui/${process.getuid?.()??''}`,path]),'launchctl bootstrap');}; + const installJob=async(alarm:Alarm,occurrence:Date,label:string,path:string,replace:boolean)=>{const local=localParts(occurrence);const entries=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`${xml(key)}${xml(value)}`).join('');const envArgs=`EnvironmentVariables${entries}`;const args=nodeLaunchCommand(common.nodePath,invocationArgs(common,alarm.id,occurrence,'internal-launchd'),runtimeEnvironment(common)).map(value=>`${xml(value)}`).join('');common.write(path,`\n\nLabel${label}ProgramArguments${args}${envArgs}StartCalendarIntervalMonth${local.month}Day${local.day}Hour${local.hour}Minute${local.minute}ProcessTypeInteractiveStandardOutPath/dev/nullStandardErrorPath/dev/null\n`);if(replace)await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);ensureSuccess(await common.run('launchctl',['bootstrap',`gui/${process.getuid?.()??''}`,path]),'launchctl bootstrap');}; const removeJob=async(path:string)=>{ const label=path.split('/').at(-1)!.slice(0,-6); let failure:unknown; @@ -168,7 +168,7 @@ function systemdAdapter(common: Common): SchedulerAdapter { return { capabilities, probeCapabilities, - async install(alarm, occurrence) { const readiness=await probeCapabilities();if(!readiness.supported)throw new Error(readiness.message);const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=systemdExecCommand([common.nodePath,...invocationArgs(common,alarm.id,occurrence)]); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, + async install(alarm, occurrence) { const readiness=await probeCapabilities();if(!readiness.supported)throw new Error(readiness.message);const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=systemdExecCommand(nodeLaunchCommand(common.nodePath,invocationArgs(common,alarm.id,occurrence),runtimeEnvironment(common))); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, async remove(id){ const n=names(id); await removeWithVerification(()=>common.run('systemctl',['--user','disable','--now',n.timer]),()=>common.run('systemctl',['--user','show',n.timer,'--property=LoadState','--property=ActiveState']),result=>/^LoadState=not-found\r?$/m.test(result.stdout)&&/^ActiveState=inactive\r?$/m.test(result.stdout),'systemd timer'); @@ -195,11 +195,67 @@ function windowsAdapter(common: Common): SchedulerAdapter { common.write(path, body); ensureSuccess(await common.run('schtasks.exe', ['/Create', '/TN', name, '/XML', path, '/F']), 'schtasks create'); }, - async remove(id){const name=`\\RadioCLI\\${jobName(id)}`;await removeWithVerification(()=>common.run('schtasks.exe',['/Delete','/TN',name,'/F']),()=>common.run('schtasks.exe',['/Query','/TN',name]),result=>result.code===1&&/^ERROR: The system cannot find the (?:file|path) specified\.$/.test(`${result.stderr}\n${result.stdout}`.trim()),'Task Scheduler job');common.removeFile(xmlPath(id));}, + async remove(id){ + const name=`\\RadioCLI\\${jobName(id)}`; + const removal=await settledOperation(async()=>{ + const result=await boundedWindowsCommand(()=>common.run('schtasks.exe',['/Delete','/TN',name,'/F']),'deletion'); + if(result.code!==0)throw new Error(`Task Scheduler job removal failed: ${windowsCommandMessage(result)}`); + }); + let state:WindowsTaskState; + try{ + const result=await boundedWindowsCommand(()=>common.run('powershell.exe',windowsTaskLookupCommand(name)),'absence lookup'); + state=windowsTaskState(result,name); + }catch(error){throw new Error(`Unable to verify Task Scheduler job removal; repair artifacts were retained. ${removal.error??''} ${errorMessage(error)}`);} + if(state.state!=='absent')throw new Error(`Unable to verify Task Scheduler job absence; repair artifacts were retained. ${removal.error??''} ${state.message}`); + common.removeFile(xmlPath(id)); + }, async status(id){const result=await common.run('schtasks.exe',['/Query','/TN',`\\RadioCLI\\${jobName(id)}`]);const artifact=existsSync(xmlPath(id));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:result.code===0&&artifact,healthy:result.code===0&&artifact&&executable,message:result.code!==0?'task not registered':!artifact?'task XML artifact missing':!executable?'RadioCLI executable missing':'registered and executable'};} }; } +function windowsTaskLookupCommand(taskPath:string):string[]{ + // Only GetTask failures can establish absence. A missing COM service or root + // folder must remain a repairable error, regardless of the UI language. + const encodedPath=Buffer.from(taskPath,'utf8').toString('base64'); + const script=[ + "$ErrorActionPreference='Stop'", + "$ProgressPreference='SilentlyContinue'", + '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)', + `$radiocliTaskPath=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`, + "$radiocliResult=@{taskPath=$radiocliTaskPath;stage='connect';status='error';hresult=0;message=''}", + "try{$radiocliService=New-Object -ComObject 'Schedule.Service';$null=$radiocliService.Connect();$radiocliResult.stage='root';$radiocliFolder=$radiocliService.GetFolder('\\');$radiocliResult.stage='lookup';$null=$radiocliFolder.GetTask($radiocliTaskPath);$radiocliResult.status='present'}catch{$radiocliException=$_.Exception;$radiocliResult.message=$radiocliException.ToString();for($radiocliDepth=0;$null -ne $radiocliException.InnerException -and $radiocliDepth -lt 16;$radiocliDepth++){$radiocliException=$radiocliException.InnerException};$radiocliResult.hresult=$radiocliException.HResult}", + '[Console]::WriteLine(($radiocliResult | ConvertTo-Json -Compress))', + 'exit 0' + ].join(';'); + return ['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')]; +} + +type WindowsTaskState={state:'absent'|'present'|'unknown';message:string}; +function windowsTaskState(result:CommandResult,taskPath:string):WindowsTaskState{ + const unknown=(message:string):WindowsTaskState=>({state:'unknown',message:`${message} ${windowsCommandMessage(result)}`}); + if(result.code!==0)return unknown('Task Scheduler lookup process failed.'); + let value:unknown; + try{value=JSON.parse(result.stdout);}catch{return unknown('Malformed Task Scheduler lookup response.');} + if(!value||typeof value!=='object'||Array.isArray(value))return unknown('Malformed Task Scheduler lookup response.'); + const response=value as Record; + if(response.taskPath!==taskPath||!['connect','root','lookup'].includes(String(response.stage))|| + !['present','error'].includes(String(response.status))||typeof response.hresult!=='number'|| + !Number.isInteger(response.hresult)||response.hresult< -2147483648||response.hresult>2147483647||typeof response.message!=='string'){ + return unknown('Invalid or unrelated Task Scheduler lookup response.'); + } + if(response.status==='present'&&response.stage==='lookup'&&response.hresult===0)return{state:'present',message:'Task Scheduler job is still registered.'}; + // HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND), from the + // exact GetTask lookup. Never interpret schtasks exit 1 or localized text. + if(response.status==='error'&&response.stage==='lookup'&&[-2147024894,-2147024893].includes(response.hresult))return{state:'absent',message:response.message}; + return unknown(`Task Scheduler ${response.stage} HRESULT 0x${(response.hresult>>>0).toString(16).padStart(8,'0')}: ${response.message}`); +} +function windowsCommandMessage(result:CommandResult):string{return [`exit ${result.code}`,result.stderr.trim(),result.stdout.trim()].filter(Boolean).join(' ');} +async function boundedWindowsCommand(work:()=>Promise,label:string):Promise{ + let timer:NodeJS.Timeout|undefined; + try{return await Promise.race([Promise.resolve().then(work),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(`Task Scheduler ${label} timed out.`)),10_000);})]);} + finally{if(timer)clearTimeout(timer);} +} + function unsupportedAdapter(message:string):SchedulerAdapter{return{capabilities:()=>({supported:false,exactWake:false,catchUpAfterWake:false,message}),install:async()=>{throw new Error(message);},remove:async()=>{},status:async()=>({installed:false,healthy:false,message})};} function unavailableAdapter(message: string): SchedulerAdapter { return {...unsupportedAdapter(message), remove: async () => {throw new Error(message);}}; diff --git a/src/alarms/system-volume-native.test.ts b/src/alarms/system-volume-native.test.ts new file mode 100644 index 0000000..9cc2a2d --- /dev/null +++ b/src/alarms/system-volume-native.test.ts @@ -0,0 +1,105 @@ +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import {EventEmitter} from 'node:events'; +import {PassThrough} from 'node:stream'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import {createSystemVolumeController} from './system-volume.js'; + +vi.mock('node:child_process',async importOriginal=>({...await importOriginal()})); +vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); +let directory:string; +beforeEach(()=>{directory=fs.mkdtempSync(join(tmpdir(),'radiocli-native-volume-'));}); +afterEach(()=>{vi.restoreAllMocks();fs.rmSync(directory,{recursive:true,force:true});}); + +describe('native system-output command lifetime',()=>{ + it('waits for an orphan native helper before recovering the saved baseline',async()=>{ + const output=join(directory,'fake-output.json');const gate=join(directory,'gate');const helperFile=join(directory,'helper.json'); + fs.writeFileSync(output,JSON.stringify({volume:20,muted:true})); + const helperSource=` + const fs=require('node:fs');const [output,gate,helperFile,text]=process.argv.slice(1);const args=JSON.parse(text); + if(args.includes('set s to get volume settings')){const state=JSON.parse(fs.readFileSync(output,'utf8'));process.stdout.write(state.volume+','+state.muted);} + else{ + const match=/set volume output volume (\\d+) (with|without) output muted/.exec(args.join(' ')); + const state={volume:Number(match[1]),muted:match[2]==='with'}; + if(state.volume===70){fs.writeFileSync(helperFile,JSON.stringify({pid:process.pid}));const timer=setInterval(()=>{if(fs.existsSync(gate)){fs.writeFileSync(output,JSON.stringify(state));clearInterval(timer);}},10);setTimeout(()=>process.exit(3),8000).unref();} + else fs.writeFileSync(output,JSON.stringify(state)); + } + `; + const source=` + import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module'; + const [directory,output,gate,helperFile]=process.argv.slice(1);const original=cp.spawn; + cp.spawn=(_command,args,options)=>original(process.execPath,['-e',${JSON.stringify(helperSource)},output,gate,helperFile,JSON.stringify(args)],options); + syncBuiltinESMExports(); + const {createSystemVolumeController}=await import(${JSON.stringify(new URL('./system-volume.ts',import.meta.url).href)}); + await createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70); + `; + const parent=childProcess.spawn(process.execPath,['--import','tsx','--input-type=module','-e',source,directory,output,gate,helperFile],{stdio:'ignore',windowsHide:true,timeout:10_000,killSignal:'SIGKILL'}); + const parentClosed=new Promise(resolve=>parent.once('close',()=>resolve())); + let helperPid:number|undefined;let acquired=false;let acquisition:ReturnType['acquireMinimum']>|undefined; + try{ + await until(()=>fs.existsSync(helperFile));helperPid=(JSON.parse(fs.readFileSync(helperFile,'utf8')) as {pid:number}).pid; + expect(JSON.parse(fs.readFileSync(join(directory,'native-write.json'),'utf8'))).toMatchObject({helperPid}); + parent.kill('SIGKILL');await parentClosed;expect(()=>process.kill(helperPid!,0)).not.toThrow(); + const run=async(_command:string,args:string[])=>{ + const before=JSON.parse(fs.readFileSync(output,'utf8')) as {volume:number;muted:boolean}; + if(args.includes('set s to get volume settings'))return{code:0,stdout:`${before.volume},${before.muted}`,stderr:''}; + const match=/set volume output volume (\d+) (with|without) output muted/.exec(args.join(' ')); + fs.writeFileSync(output,JSON.stringify({volume:Number(match![1]),muted:match![2]==='with'}));return{code:0,stdout:'',stderr:''}; + }; + acquisition=createSystemVolumeController('darwin',run,()=>null,{directory,lockTimeoutMs:2_000}).acquireMinimum(80).then(lease=>{acquired=true;return lease;}); + await new Promise(resolve=>setTimeout(resolve,80));expect(acquired).toBe(false); + fs.writeFileSync(gate,'go');const lease=await acquisition; + expect(JSON.parse(fs.readFileSync(output,'utf8'))).toEqual({volume:80,muted:false}); + await lease.release();expect(JSON.parse(fs.readFileSync(output,'utf8'))).toEqual({volume:20,muted:true}); + expect(fs.existsSync(join(directory,'native-write.json'))).toBe(false); + }finally{ + fs.writeFileSync(gate,'go');if(parent.exitCode===null&&parent.signalCode===null)parent.kill('SIGKILL');await parentClosed; + if(acquisition)await acquisition.then(lease=>lease.release()).catch(()=>{}); + if(helperPid)try{process.kill(helperPid,'SIGKILL');}catch{} + } + }); + + it('kills and awaits an owned helper when persisting its PID fails',async()=>{ + const writes:ReturnType[]=[];let saves=0;const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'native-write.json')&&++saves===2)throw new Error('PID disk failure');return rename(source,destination);}); + vi.spyOn(childProcess,'spawn').mockImplementation((_command,args)=>{ + const child=fakeChild();const reading=args?.includes('set s to get volume settings'); + if(reading)queueMicrotask(()=>{child.stdout.write('20,true');child.emit('close',0,null);}); + else{writes.push(child);if(writes.length>1)queueMicrotask(()=>child.emit('close',0,null));} + return child as unknown as childProcess.ChildProcess; + }); + let settled=false;const acquisition=createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70).finally(()=>{settled=true;}); + const rejected=expect(acquisition).rejects.toThrow('PID disk failure'); + await until(()=>writes.length>0);expect(writes[0]!.kill).toHaveBeenCalledWith('SIGKILL'); + expect(settled).toBe(false);expect(fs.existsSync(join(directory,'lock'))).toBe(true); + expect(JSON.parse(fs.readFileSync(join(directory,'native-write.json'),'utf8')).helperPid).toBeNull(); + writes[0]!.emit('close',null,'SIGKILL');await rejected; + expect(fs.existsSync(join(directory,'lock'))).toBe(false);expect(fs.existsSync(join(directory,'native-write.json'))).toBe(false); + }); + + it('clears the native journal when spawning throws before creating a helper',async()=>{ + vi.spyOn(childProcess,'spawn').mockImplementation((_command,args)=>{ + if(!args?.includes('set s to get volume settings'))throw new Error('helper could not start'); + const child=fakeChild();queueMicrotask(()=>{child.stdout.write('20,true');child.emit('close',0,null);});return child as unknown as childProcess.ChildProcess; + }); + await expect(createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70)).rejects.toThrow('helper could not start'); + expect(fs.existsSync(join(directory,'native-write.json'))).toBe(false); + expect(fs.existsSync(join(directory,'state.json'))).toBe(true); + }); + + it('does not start a mutating helper if its intent cannot be journaled',async()=>{ + const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'native-write.json'))throw new Error('native journal disk failure');return rename(source,destination);}); + const spawn=vi.spyOn(childProcess,'spawn').mockImplementation((_command,args)=>{ + expect(args).toContain('set s to get volume settings'); + const child=fakeChild();queueMicrotask(()=>{child.stdout.write('20,true');child.emit('close',0,null);});return child as unknown as childProcess.ChildProcess; + }); + await expect(createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70)).rejects.toThrow('native journal disk failure'); + expect(spawn).toHaveBeenCalledOnce();expect(fs.existsSync(join(directory,'state.json'))).toBe(true); + }); +}); + +function fakeChild(){const child=new EventEmitter() as EventEmitter&{pid:number;stdout:PassThrough;stderr:PassThrough;kill:ReturnType};child.pid=process.pid;child.stdout=new PassThrough();child.stderr=new PassThrough();child.kill=vi.fn(()=>true);return child;} +async function until(predicate:()=>boolean):Promise{const deadline=Date.now()+3_000;while(!predicate()){if(Date.now()>=deadline)throw new Error('Owned fake output helper did not reach the expected state.');await new Promise(resolve=>setTimeout(resolve,10));}} diff --git a/src/alarms/system-volume-ownership.test.ts b/src/alarms/system-volume-ownership.test.ts new file mode 100644 index 0000000..2470559 --- /dev/null +++ b/src/alarms/system-volume-ownership.test.ts @@ -0,0 +1,100 @@ +import * as fs from 'node:fs'; +import {randomUUID} from 'node:crypto'; +import {existsSync,mkdirSync,mkdtempSync,readdirSync,rmSync,utimesSync,writeFileSync} from 'node:fs'; +import {tmpdir,userInfo} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import {defaultSystemVolumeDirectory,SystemVolumeOwnership,volumeProcessAlive} from './system-volume-ownership.js'; + +vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); + +let directory:string; +beforeEach(()=>{directory=mkdtempSync(join(tmpdir(),'radiocli-volume-ownership-'));}); +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();rmSync(directory,{recursive:true,force:true});}); +const missingProcess=99999999; +function ownerMarker(pid=process.pid){return`owner-${pid}-${randomUUID()}`;} +function lockWith(marker:string){const lock=join(directory,'lock');mkdirSync(lock);writeFileSync(join(lock,marker),'');return lock;} +function deadProcessFixture(){vi.spyOn(process,'kill').mockImplementation(pid=>{if(pid===missingProcess)throw Object.assign(new Error('gone'),{code:'ESRCH'});return true;});} + +describe('shared system-volume ownership',()=>{ + it('uses the actual OS account home across library, HOME and XDG overrides',()=>{ + const expected=join(userInfo().homedir,'.radiocli','system-volume'); + vi.stubEnv('RADIOCLI_HOME',join(directory,'profile'));vi.stubEnv('RADIO_ATLAS_HOME',join(directory,'legacy')); + vi.stubEnv('HOME',directory);vi.stubEnv('XDG_RUNTIME_DIR',join(directory,'runtime'));vi.stubEnv('XDG_STATE_HOME',join(directory,'state'));vi.stubEnv('USERPROFILE',directory); + expect(defaultSystemVolumeDirectory()).toBe(expected); + }); + + it('only treats ESRCH as proof that a participant is dead',()=>{ + vi.spyOn(process,'kill').mockImplementationOnce(()=>{throw Object.assign(new Error('denied'),{code:'EPERM'});}).mockImplementationOnce(()=>{throw Object.assign(new Error('unknown'),{code:'EINVAL'});}).mockImplementationOnce(()=>{throw Object.assign(new Error('gone'),{code:'ESRCH'});}); + expect(volumeProcessAlive(1)).toBe(true);expect(volumeProcessAlive(2)).toBe(true);expect(volumeProcessAlive(3)).toBe(false); + }); + + it('does not steal an old lock from a live owner',async()=>{ + const marker=ownerMarker();const lock=lockWith(marker);utimesSync(lock,new Date(0),new Date(0)); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/busy.*live owner/); + expect(work).not.toHaveBeenCalled();expect(readdirSync(lock)).toEqual([marker]); + }); + + it('recovers a dead owner and an empty directory left during cleanup',async()=>{ + deadProcessFixture();lockWith(ownerMarker(missingProcess)); + await expect(new SystemVolumeOwnership(directory,25).transaction(async()=>1)).resolves.toBe(1); + mkdirSync(join(directory,'lock')); + await expect(new SystemVolumeOwnership(directory,25).transaction(async()=>2)).resolves.toBe(2); + expect(existsSync(join(directory,'lock'))).toBe(false); + }); + + it('does not displace a new live generation while reclaiming an observed dead owner',async()=>{ + deadProcessFixture();const stale=ownerMarker(missingProcess);const lock=lockWith(stale);const current=ownerMarker(); + const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{ + if(String(source)===join(lock,stale)){ + fs.unlinkSync(join(lock,stale));fs.rmdirSync(lock);mkdirSync(lock);writeFileSync(join(lock,current),''); + writeFileSync(join(directory,'native-write.json'),JSON.stringify({version:1,ownerPid:process.pid,helperPid:null})); + } + return rename(source,destination); + }); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/busy/); + expect(work).not.toHaveBeenCalled();expect(readdirSync(lock)).toEqual([current]); + }); + + it('keeps an unreadable lock intact instead of allowing independent output mutations',async()=>{ + const lock=lockWith('unknown-owner');const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/lock is unreadable/); + expect(work).not.toHaveBeenCalled();expect(readdirSync(lock)).toEqual(['unknown-owner']); + }); + + it('returns a completed acquisition after cleanup fails and permits the next transaction to recover',async()=>{ + vi.spyOn(fs,'unlinkSync').mockImplementationOnce(()=>{throw Object.assign(new Error('cleanup denied'),{code:'EACCES'});}); + const ownership=new SystemVolumeOwnership(directory,25); + await expect(ownership.transaction(async()=>({lease:'owned'}))).resolves.toEqual({lease:'owned'}); + await expect(ownership.transaction(async()=>2)).resolves.toBe(2); + expect(existsSync(join(directory,'lock'))).toBe(false); + }); + + it('does not reap a dead parent while its recorded native output helper is alive',async()=>{ + deadProcessFixture();const lock=lockWith(ownerMarker(missingProcess)); + const journal=join(directory,'native-write.json');writeFileSync(journal,JSON.stringify({version:1,ownerPid:missingProcess,helperPid:process.pid})); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/helper.*running/i); + expect(work).not.toHaveBeenCalled();expect(existsSync(lock)).toBe(true);expect(existsSync(journal)).toBe(true); + }); + + it('waits for an orphan output helper to exit before recovering its dead parent lock',async()=>{ + let helperAlive=true;const helperPid=88888888; + vi.spyOn(process,'kill').mockImplementation(pid=>{if(pid===missingProcess||pid===helperPid&&!helperAlive)throw Object.assign(new Error('gone'),{code:'ESRCH'});return true;}); + lockWith(ownerMarker(missingProcess));const journal=join(directory,'native-write.json');writeFileSync(journal,JSON.stringify({version:1,ownerPid:missingProcess,helperPid})); + const exited=setTimeout(()=>{helperAlive=false;},15); + try{await new SystemVolumeOwnership(directory,100).transaction(async()=>{expect(helperAlive).toBe(false);});} + finally{clearTimeout(exited);} + expect(existsSync(journal)).toBe(false); + }); + + it.each([JSON.stringify({version:1,ownerPid:missingProcess,helperPid:null}),'unreadable native journal'])('retains ambiguous native work and explains safe recovery: %s',async text=>{ + const journal=join(directory,'native-write.json');writeFileSync(journal,text); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/restart.*native-write\.json/i); + expect(work).not.toHaveBeenCalled();expect(fs.readFileSync(journal,'utf8')).toBe(text); + }); +}); diff --git a/src/alarms/system-volume-ownership.ts b/src/alarms/system-volume-ownership.ts new file mode 100644 index 0000000..0dcd194 --- /dev/null +++ b/src/alarms/system-volume-ownership.ts @@ -0,0 +1,129 @@ +import {randomUUID} from 'node:crypto'; +import {mkdirSync,readFileSync,readdirSync,renameSync,rmSync,rmdirSync,unlinkSync,writeFileSync} from 'node:fs'; +import {userInfo} from 'node:os'; +import {isAbsolute,join} from 'node:path'; + +export type OutputSnapshot={volume:number;muted:boolean}; +export type SystemVolumeState={version:1;generation:string;baseline:OutputSnapshot|null;changed:boolean;participants:Array<{id:string;pid:number;phase:'pending'|'active'}>}; + +/** The output device belongs to the OS account, not a RADIOCLI_HOME/XDG profile. */ +export function defaultSystemVolumeDirectory():string{ + const home=userInfo().homedir; + if(!home||!isAbsolute(home))throw new Error('The OS account home is unavailable for shared output-volume ownership.'); + return join(home,'.radiocli','system-volume'); +} + +/** One short transaction covers both the saved baseline and native mixer writes. */ +export class SystemVolumeOwnership{ + constructor(private readonly directory:string,private readonly timeoutMs=30_000){} + + async transaction(work:()=>Promise):Promise{ + const release=await this.lock(); + try{return await work();} + finally{release();} + } + + read():SystemVolumeState|undefined{ + let text:string; + try{text=readFileSync(join(this.directory,'state.json'),'utf8');} + catch(error){if(codeOf(error)==='ENOENT')return undefined;throw error;} + try{ + const value=JSON.parse(text) as SystemVolumeState; + if(value?.version!==1||!validId(value.generation)||typeof value.changed!=='boolean'||!(value.baseline===null?!value.changed:validSnapshot(value.baseline))||!Array.isArray(value.participants)||!value.participants.every(item=>item&&validId(item.id)&&validPid(item.pid)&&(item.phase==='pending'||item.phase==='active'))||new Set(value.participants.map(item=>item.id)).size!==value.participants.length)throw new Error(); + return value; + }catch{throw new Error('Shared output-volume ownership state is unreadable; the saved output state was left intact.');} + } + + save(value:SystemVolumeState):void{ + const temporary=join(this.directory,`.state-${process.pid}-${randomUUID()}`); + try{writeFileSync(temporary,JSON.stringify(value),{mode:0o600,flag:'wx'});renameSync(temporary,join(this.directory,'state.json'));} + catch(error){try{rmSync(temporary,{force:true});}catch{}throw error;} + } + + clear():void{try{unlinkSync(join(this.directory,'state.json'));}catch(error){if(codeOf(error)!=='ENOENT')throw error;}} + + /** A null PID is intentional: a crash before PID persistence is ambiguous. */ + beginNativeWrite():void{this.saveNativeWrite(null);} + recordNativeWritePid(pid:number):void{if(!validPid(pid))throw new Error('The system-output helper did not report a valid process ID.');this.saveNativeWrite(pid);} + finishNativeWrite():void{try{unlinkSync(join(this.directory,'native-write.json'));}catch(error){if(codeOf(error)!=='ENOENT')throw error;}} + + private saveNativeWrite(helperPid:number|null):void{ + const temporary=join(this.directory,`.native-write-${process.pid}-${randomUUID()}`); + try{writeFileSync(temporary,JSON.stringify({version:1,ownerPid:process.pid,helperPid}),{mode:0o600,flag:'wx'});renameSync(temporary,join(this.directory,'native-write.json'));} + catch(error){try{rmSync(temporary,{force:true});}catch{}throw error;} + } + + private nativeWriteSettled():boolean{ + const path=join(this.directory,'native-write.json');let value:{version?:number;ownerPid?:number;helperPid?:number|null}; + try{value=JSON.parse(readFileSync(path,'utf8')) as typeof value;} + catch(error){if(codeOf(error)==='ENOENT')return true;throw this.unknownNativeWrite(path);} + if(value?.version!==1||!validPid(value.ownerPid)||!validPid(value.helperPid))throw this.unknownNativeWrite(path); + if(volumeProcessAlive(value.helperPid))return false; + // Removal is only allowed after acquiring the lock, so an old observer + // cannot remove a newer owner's journal. + this.finishNativeWrite();return true; + } + + private unknownNativeWrite(path:string):Error{return new Error(`An interrupted system-output helper could not be identified; the saved volume was retained. Restart the computer, then remove ${path} before retrying system output control.`);} + private async waitForNativeWrite(deadline:number):Promise{while(!this.nativeWriteSettled()){if(Date.now()>=deadline)throw new Error('A system-output helper is still running; the saved volume will be recovered after it exits.');await pause();}} + + private async lock():Promise<()=>void>{ + mkdirSync(this.directory,{recursive:true,mode:0o700}); + const marker=`owner-${process.pid}-${randomUUID()}`; + const candidate=join(this.directory,`.lock-${marker}`);const lock=join(this.directory,'lock'); + mkdirSync(candidate,{mode:0o700}); + let published=false; + try{ + writeFileSync(join(candidate,marker),'',{mode:0o600,flag:'wx'}); + const deadline=Date.now()+this.timeoutMs; + while(true){ + let acquired=false; + try{renameSync(candidate,lock);published=true;acquired=true;} + catch(error){if(!['EEXIST','ENOTEMPTY','EPERM','EACCES'].includes(codeOf(error)??''))throw error;} + if(acquired){try{await this.waitForNativeWrite(deadline);}catch(error){finishLock(lock,marker);throw error;}return()=>finishLock(lock,marker);} + if(Date.now()>=deadline)throw new Error('Shared output-volume ownership is busy; its live owner was left intact.'); + let owners:string[]; + try{owners=readdirSync(lock);}catch(error){if(codeOf(error)==='ENOENT'){await pause();continue;}throw error;} + if(owners.length===0){removeEmptyLock(lock);continue;} + const owner=owners[0]!;const match=/^(owner|released)-([1-9]\d*)-([a-f\d-]{36})$/.exec(owner); + if(owners.length!==1||!match||!validPid(Number(match[2]))||!validId(match[3]))throw new Error('Shared output-volume ownership lock is unreadable; it was left intact.'); + if(match[1]==='released'||!volumeProcessAlive(Number(match[2]))){ + // Claim this exact dead generation, never rename/remove a replacement + // lock directory. A reaper that dies is recovered by the same protocol. + const reaper=`owner-${process.pid}-${randomUUID()}`; + try{renameSync(join(lock,owner),join(lock,reaper));} + catch(error){if(codeOf(error)==='ENOENT')continue;throw error;} + try{await this.waitForNativeWrite(deadline);} + catch(error){try{renameSync(join(lock,reaper),join(lock,owner));}catch{finishLock(lock,reaper);}throw error;} + finishLock(lock,reaper);await pause();continue; + } + await pause(); + } + }finally{if(!published)try{rmSync(candidate,{recursive:true,force:true});}catch{}} + } +} + +export function volumeProcessAlive(pid:number):boolean{ + try{process.kill(pid,0);return true;}catch(error){return codeOf(error)!=='ESRCH';} +} +function finishLock(directory:string,marker:string):void{ + const released=`released-${process.pid}-${randomUUID()}`; + try{renameSync(join(directory,marker),join(directory,released));} + catch{try{removeOwnedLock(directory,marker);}catch{}return;} + // Do not lose a returned lease or replace a mixer failure with cleanup errors. + // A published completion can be reclaimed even while this PID remains alive. + try{removeOwnedLock(directory,released);}catch{} +} +function removeOwnedLock(directory:string,marker:string):void{ + try{unlinkSync(join(directory,marker));}catch(error){if(codeOf(error)==='ENOENT')return;throw error;} + removeEmptyLock(directory); +} +function removeEmptyLock(directory:string):void{ + // Another process may already have published a new nonempty generation. + try{rmdirSync(directory);}catch(error){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes(codeOf(error)??''))throw error;} +} +function validPid(value:unknown):value is number{return Number.isSafeInteger(value)&&Number(value)>0;} +function validId(value:unknown):value is string{return typeof value==='string'&&/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/.test(value);} +function validSnapshot(value:unknown):value is OutputSnapshot{if(!value||typeof value!=='object')return false;const state=value as OutputSnapshot;return Number.isInteger(state.volume)&&state.volume>=0&&state.volume<=100&&typeof state.muted==='boolean';} +function codeOf(error:unknown):string|undefined{return(error as NodeJS.ErrnoException)?.code;} +function pause():Promise{return new Promise(resolve=>setTimeout(resolve,20));} diff --git a/src/alarms/system-volume-process.test.ts b/src/alarms/system-volume-process.test.ts new file mode 100644 index 0000000..747d91a --- /dev/null +++ b/src/alarms/system-volume-process.test.ts @@ -0,0 +1,102 @@ +import {spawn} from 'node:child_process'; +import {mkdtempSync,readFileSync,rmSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it} from 'vitest'; + +let directory:string;let mixer:string; +type OutputWorker={held:Promise;call(action:string,fields:Record):Promise;stop():Promise}; +const workers:OutputWorker[]=[]; +beforeEach(()=>{directory=mkdtempSync(join(tmpdir(),'radiocli-volume-process-'));mixer=join(directory,'fake-output.json');writeFileSync(mixer,JSON.stringify({volume:20,muted:true}));}); +afterEach(async()=>{await Promise.all(workers.splice(0).map(worker=>worker.stop()));rmSync(directory,{recursive:true,force:true});}); +function output(){return JSON.parse(readFileSync(mixer,'utf8')) as {volume:number;muted:boolean};} + +describe('system output ownership between real processes',()=>{ + it.each([false,true])('restores once after overlapping processes with separate library profiles end (reverse release: %s)',async reverse=>{ + const [first,second]=await Promise.all([startWorker('first'),startWorker('second')]); + await first.call('acquire',{key:'a',target:40});await second.call('acquire',{key:'b',target:70}); + await (reverse?second:first).call('release',{key:reverse?'b':'a'}); + expect(output()).toEqual({volume:70,muted:false}); + await (reverse?first:second).call('release',{key:reverse?'a':'b'}); + expect(output()).toEqual({volume:20,muted:true}); + expect(readFileSync(join(directory,'writes.jsonl'),'utf8').trim().split('\n').map(line=>JSON.parse(line))).toEqual([{volume:40,muted:false},{volume:70,muted:false},{volume:20,muted:true}]); + }); + + it('serializes simultaneous acquisition and release across processes',async()=>{ + const [first,second]=await Promise.all([startWorker('first'),startWorker('second')]); + await Promise.all([first.call('acquire',{key:'a',target:40}),second.call('acquire',{key:'b',target:70})]); + expect(output()).toEqual({volume:70,muted:false}); + await Promise.all([first.call('release',{key:'a'}),second.call('release',{key:'b'})]); + expect(output()).toEqual({volume:20,muted:true}); + }); + + it('restores the original baseline when an earlier participating process dies',async()=>{ + const first=await startWorker('first');const second=await startWorker('second'); + await first.call('acquire',{key:'a',target:40});await second.call('acquire',{key:'b',target:70}); + await first.stop();expect(output()).toEqual({volume:70,muted:false}); + await second.call('release',{key:'b'});expect(output()).toEqual({volume:20,muted:true}); + }); + + it('recovers a dead mutation owner and its persisted baseline after an interrupted write',async()=>{ + const first=await startWorker('first'); + const interrupted=first.call('acquire',{key:'a',target:70,hold:'write'}).catch(()=>undefined); + await first.held;expect(output()).toEqual({volume:70,muted:false});await first.stop();await interrupted; + const second=await startWorker('second');await second.call('acquire',{key:'b',target:80}); + await second.call('release',{key:'b'});expect(output()).toEqual({volume:20,muted:true}); + }); + + it('preserves a handoff after the original owner exits and another alarm releases',async()=>{ + const first=await startWorker('first');const second=await startWorker('second'); + await first.call('acquire',{key:'a',target:40});await second.call('acquire',{key:'b',target:70}); + await first.call('release',{key:'a',preserve:true});await first.stop(); + await second.call('release',{key:'b'});expect(output()).toEqual({volume:70,muted:false}); + await second.call('acquire',{key:'c',target:90});await second.call('release',{key:'c'}); + expect(output()).toEqual({volume:70,muted:false}); + }); +}); + +async function startWorker(profile:string):Promise{ + const source=` + import {createSystemVolumeController} from ${JSON.stringify(new URL('./system-volume.ts',import.meta.url).href)}; + import {appendFileSync,readFileSync,writeFileSync} from 'node:fs'; + import {join} from 'node:path'; + const [directory,mixer]=process.argv.slice(1);const leases=new Map();let hold; + const run=async(_command,args)=>{ + if(args.includes('set s to get volume settings')){const state=JSON.parse(readFileSync(mixer,'utf8'));return{code:0,stdout:state.volume+','+state.muted,stderr:''};} + const match=/set volume output volume (\\d+) (with|without) output muted/.exec(args.join(' ')); + if(!match)throw new Error('Unexpected fake mixer invocation'); + const state={volume:Number(match[1]),muted:match[2]==='with'}; + writeFileSync(mixer,JSON.stringify(state));appendFileSync(join(directory,'writes.jsonl'),JSON.stringify(state)+'\\n'); + if(hold==='write'){process.send({event:'held'});await new Promise(()=>{});} + return{code:0,stdout:'',stderr:''}; + }; + const controller=createSystemVolumeController('darwin',run,()=>null,{directory}); + process.on('message',async message=>{ + try{ + if(message.action==='acquire'){hold=message.hold;leases.set(message.key,await controller.acquireMinimum(message.target));} + else if(message.action==='release'){await leases.get(message.key).release({preserve:message.preserve});} + else throw new Error('Unknown test action'); + process.send({id:message.id}); + }catch(error){process.send({id:message.id,error:String(error)});} + }); + process.send({event:'ready'}); + `; + const child=spawn(process.execPath,['--import','tsx','--input-type=module','-e',source,directory,mixer],{env:{...process.env,RADIOCLI_HOME:join(directory,profile),XDG_RUNTIME_DIR:join(directory,`${profile}-runtime`)},stdio:['ignore','pipe','pipe','ipc'],windowsHide:true,timeout:15_000,killSignal:'SIGKILL'}); + let stderr='';let readyResolve:()=>void=()=>{};let readyReject:(error:Error)=>void=()=>{};let heldResolve:()=>void=()=>{};let sequence=0; + const ready=new Promise((resolve,reject)=>{readyResolve=resolve;readyReject=reject;}); + const held=new Promise(resolve=>{heldResolve=resolve;}); + const pending=new Map(); + child.stderr!.on('data',data=>{stderr+=String(data);}); + child.on('message',(message:{event?:string;id?:number;error?:string})=>{ + if(message.event==='ready')readyResolve();else if(message.event==='held')heldResolve(); + else if(message.id!==undefined){const request=pending.get(message.id);pending.delete(message.id);if(message.error)request?.reject(new Error(message.error));else request?.resolve();} + }); + child.on('error',error=>{readyReject(error);for(const request of pending.values())request.reject(error);pending.clear();}); + const closed=new Promise(resolve=>child.once('close',()=>{const error=new Error(`Fake output worker exited: ${stderr}`);readyReject(error);for(const request of pending.values())request.reject(error);pending.clear();resolve();})); + const worker={ + held, + call(action:string,fields:Record){return new Promise((resolve,reject)=>{const id=++sequence;pending.set(id,{resolve,reject});child.send({id,action,...fields},error=>{if(error){pending.delete(id);reject(error);}});});}, + async stop(){if(child.exitCode===null&&child.signalCode===null)child.kill('SIGKILL');await closed;} + }; + workers.push(worker);await ready;return worker; +} diff --git a/src/alarms/system-volume.test.ts b/src/alarms/system-volume.test.ts index 435f8ea..7c83ece 100644 --- a/src/alarms/system-volume.test.ts +++ b/src/alarms/system-volume.test.ts @@ -1,9 +1,151 @@ -import {afterEach,describe,expect,it,vi} from 'vitest'; -import {createSystemVolumeController} from './system-volume.js'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import * as fs from 'node:fs'; +import {existsSync,mkdirSync,mkdtempSync,readFileSync,rmSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {createSystemVolumeController as createController} from './system-volume.js'; -afterEach(()=>vi.unstubAllEnvs()); +vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); + +let directory:string; +beforeEach(()=>{directory=mkdtempSync(join(tmpdir(),'radiocli-volume-'));}); +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();rmSync(directory,{recursive:true,force:true});}); +function createSystemVolumeController(...args:Parameters){return createController(args[0],args[1],args[2],{directory});} + +function fakeOutput(volume=20,muted=true){ + let state={volume,muted};const writes:Array=[]; + const run=vi.fn(async(_command:string,args:string[])=>{ + if(args.includes('set s to get volume settings'))return{code:0,stdout:`${state.volume},${state.muted}`,stderr:''}; + const match=/set volume output volume (\d+) (with|without) output muted/.exec(args.join(' ')); + if(!match)throw new Error('Unexpected fake mixer command'); + state={volume:Number(match[1]),muted:match[2]==='with'};writes.push({...state}); + return{code:0,stdout:'',stderr:''}; + }); + return{run,writes,state:()=>({...state})}; +} describe('alarm system output volume',()=>{ + it.each([false,true])('keeps overlapping alarms audible and restores the shared baseline (reverse release: %s)',async reverse=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + const second=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + await (reverse?second:first).release(); + expect(output.state()).toEqual({volume:70,muted:false}); + await (reverse?first:second).release(); + expect(output.state()).toEqual({volume:20,muted:true}); + expect(output.writes).toEqual([{volume:40,muted:false},{volume:70,muted:false},{volume:20,muted:true}]); + }); + + it('joins an already-loud output lease so another alarm cannot restore underneath it',async()=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + const second=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + await first.release();expect(output.state()).toEqual({volume:70,muted:false}); + await second.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('serializes concurrent acquisitions and duplicate releases',async()=>{ + const output=fakeOutput(); + const [first,second]=await Promise.all([createSystemVolumeController('darwin',output.run).acquireMinimum(40),createSystemVolumeController('darwin',output.run).acquireMinimum(70)]); + await Promise.all([first.release(),first.release(),second.release(),second.release()]); + expect(output.state()).toEqual({volume:20,muted:true}); + expect(output.writes.filter(state=>state.muted)).toHaveLength(1); + }); + + it('preserves handed-off output after remaining alarms end and rebases later raises',async()=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + const second=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + await first.release({preserve:true}); + const third=await createSystemVolumeController('darwin',output.run).acquireMinimum(90); + await second.release();expect(output.state()).toEqual({volume:90,muted:false}); + await third.release();expect(output.state()).toEqual({volume:70,muted:false}); + const fourth=await createSystemVolumeController('darwin',output.run).acquireMinimum(80); + await fourth.release();expect(output.state()).toEqual({volume:70,muted:false}); + }); + + it('rolls a failed overlapping adjustment back to the active alarm output',async()=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + const failRaise=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.join(' ').includes('output volume 70 ')?{...result,code:1,stderr:'partial adjustment'}:result;}; + await expect(createSystemVolumeController('darwin',failRaise).acquireMinimum(70)).rejects.toThrow('partial adjustment'); + expect(output.state()).toEqual({volume:40,muted:false}); + await first.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('retains the original baseline after both an adjustment and its rollback fail',async()=>{ + const output=fakeOutput(); + const failWrites=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.includes('set s to get volume settings')?result:{...result,code:1,stderr:'mixer failed'};}; + await expect(createSystemVolumeController('darwin',failWrites).acquireMinimum(70)).rejects.toThrow(/restoring.*failed/); + expect(existsSync(join(directory,'state.json'))).toBe(true); + const next=await createSystemVolumeController('darwin',output.run).acquireMinimum(80); + await next.release();expect(output.state()).toEqual({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it('recovers a failed final restoration before taking a later alarm baseline',async()=>{ + const output=fakeOutput();let failRestore=true; + const run=async(command:string,args:string[])=>{if(failRestore&&args.join(' ').includes('output volume 20 '))return{code:1,stdout:'',stderr:'restore failed'};return output.run(command,args);}; + const first=await createSystemVolumeController('darwin',run).acquireMinimum(70); + await expect(first.release()).rejects.toThrow('restore failed'); + expect(output.state()).toEqual({volume:70,muted:false}); + failRestore=false; + const second=await createSystemVolumeController('darwin',run).acquireMinimum(80); + await first.release();expect(output.state()).toEqual({volume:80,muted:false}); + await second.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('allows concurrent release retries after a failed final restoration',async()=>{ + const output=fakeOutput();let attempts=0; + const run=async(command:string,args:string[])=>{if(args.join(' ').includes('output volume 20 ')&&++attempts===1)return{code:1,stdout:'',stderr:'retry restore'};return output.run(command,args);}; + const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70); + const results=await Promise.allSettled([lease.release(),lease.release(),lease.release()]); + expect(results.map(result=>result.status).sort()).toEqual(['fulfilled','fulfilled','rejected']); + expect(output.state()).toEqual({volume:20,muted:true});expect(attempts).toBe(2); + }); + + it('does not touch the mixer when saved ownership is corrupt',async()=>{ + const text='{"version":77,"baseline":"unknown"}';writeFileSync(join(directory,'state.json'),text); + const output=fakeOutput();await expect(createSystemVolumeController('darwin',output.run).acquireMinimum(70)).rejects.toThrow(/ownership state is unreadable/); + expect(output.run).not.toHaveBeenCalled();expect(readFileSync(join(directory,'state.json'),'utf8')).toBe(text); + }); + + it('does not raise output if the original state cannot be saved',async()=>{ + const output=fakeOutput(); + const run=async(command:string,args:string[])=>{const result=await output.run(command,args);if(args.includes('set s to get volume settings'))mkdirSync(join(directory,'state.json'));return result;}; + await expect(createSystemVolumeController('darwin',run).acquireMinimum(70)).rejects.toThrow(); + expect(output.writes).toEqual([]);expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('does not leave a live phantom participant when adjustment, rollback and recovery persistence all fail',async()=>{ + const output=fakeOutput();const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + let saves=0;const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'state.json')&&++saves===2)throw new Error('recovery disk failure');return rename(source,destination);}); + const failWrites=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.includes('set s to get volume settings')?result:{...result,code:1,stderr:'mixer failure'};}; + await expect(createSystemVolumeController('darwin',failWrites).acquireMinimum(70)).rejects.toThrow(/mixer failure.*restoring.*mixer failure.*recovery disk failure/); + await first.release();expect(output.state()).toEqual({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it('recovers an unreturned lease while its process lives when rollback succeeds but cleanup persistence fails',async()=>{ + const output=fakeOutput();const unlink=fs.unlinkSync;let failed=false; + vi.spyOn(fs,'unlinkSync').mockImplementation(path=>{if(String(path)===join(directory,'state.json')&&!failed){failed=true;throw new Error('recovery cleanup failure');}return unlink(path);}); + const failRaise=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.join(' ').includes('output volume 70 ')?{...result,code:1,stderr:'raise failed'}:result;}; + await expect(createSystemVolumeController('darwin',failRaise).acquireMinimum(70)).rejects.toThrow(/raise failed.*recovery cleanup failure/); + const next=await createSystemVolumeController('darwin',output.run).acquireMinimum(80); + await next.release();expect(output.state()).toEqual({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it.each([40,80])('rolls back an acquisition whose active ownership commit fails (target: %s)',async target=>{ + const output=fakeOutput();const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + let saves=0;const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'state.json')&&++saves===2)throw new Error('ownership commit failed');return rename(source,destination);}); + await expect(createSystemVolumeController('darwin',output.run).acquireMinimum(target)).rejects.toThrow('ownership commit failed'); + expect(output.state()).toEqual({volume:70,muted:false}); + await first.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + it('unmutes and raises macOS output for an alarm, then restores it',async()=>{const calls:Array<{command:string;args:string[]}>=[];const run=vi.fn(async(command:string,args:string[])=>{calls.push({command,args});return{code:0,stdout:args.includes('set s to get volume settings')?'20,true\n':'',stderr:''};});const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70);expect(calls[1]?.args.join(' ')).toContain('set volume output volume 70 without output muted');await lease.release();expect(calls[2]?.args.join(' ')).toContain('set volume output volume 20 with output muted');}); it('does not lower a device that is already louder than the alarm setting',async()=>{const run=vi.fn(async()=>({code:0,stdout:'85,false\n',stderr:''}));const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70);expect(run).toHaveBeenCalledOnce();await lease.release();expect(run).toHaveBeenCalledOnce();}); diff --git a/src/alarms/system-volume.ts b/src/alarms/system-volume.ts index 14e63d5..4c38292 100644 --- a/src/alarms/system-volume.ts +++ b/src/alarms/system-volume.ts @@ -1,46 +1,95 @@ -import {spawn} from 'node:child_process'; -import {resolveCommand} from '../player/command.js'; +import {spawn,type ChildProcessByStdio} from 'node:child_process'; +import {randomUUID} from 'node:crypto'; +import type {Readable} from 'node:stream'; +import {resolveCommand} from '../platform/executables.js'; import {identifyPlatform,nativeAdapters} from '../platform/runtime.js'; +import {defaultSystemVolumeDirectory,SystemVolumeOwnership,volumeProcessAlive,type OutputSnapshot,type SystemVolumeState} from './system-volume-ownership.js'; -type Snapshot={volume:number;muted:boolean}; +type Snapshot=OutputSnapshot; type Result={code:number;stdout:string;stderr:string}; type Run=(command:string,args:string[])=>Promise; -export type SystemVolumeLease={release():Promise;message:string}; +export type SystemVolumeLease={release(options?:{preserve?:boolean}):Promise;message:string}; export type SystemVolumeController={acquireMinimum(volume:number):Promise}; -export function createSystemVolumeController(platform:NodeJS.Platform=process.platform,run:Run=runCommand,resolve:(command:string)=>string|null=resolveCommand):SystemVolumeController{ +export function createSystemVolumeController(platform:NodeJS.Platform=process.platform,run:Run=runCommand,resolve:(command:string)=>string|null=resolveCommand,options:{directory?:string;lockTimeoutMs?:number}={}):SystemVolumeController{ const host=identifyPlatform({platform});const adapter=nativeAdapters(host).volume; const backend=adapter==='macos'?macBackend(run):adapter==='unix-audio'?linuxBackend(run,resolve):adapter==='windows'?windowsBackend(run,resolve):undefined; return{async acquireMinimum(volume){ if(!backend)throw new Error(`System output volume control is unavailable on ${host.id==='unknown'?platform:host.id}.`); - const target=clamp(volume);const before=await backend.read();const changed=before.muted||before.volumebackend.write(value,run===runCommand?(command,args)=>runCommand(command,args,ownership):run); + return ownership.transaction(async()=>{ + let state=ownership.read(); + if(state){ + state.participants=state.participants.filter(activeParticipant); + if(state.participants.length===0){ + // A crashed owner or failed final restore must not turn the raised + // output into the next alarm's baseline. + if(state.changed&&state.baseline)await write(state.baseline); + ownership.clear();state=undefined; + } + } + const target=clamp(volume);const before=await backend.read();const changed=before.muted||before.volumeitem.id===id?{...item,phase:'active'}:item)}); + }catch(error){ + let restoreError:unknown; + if(changed)try{await write(before);}catch(cause){restoreError=cause;} + try{ + if(restoreError)ownership.save({...joined,participants:joined.participants.filter(item=>item.id!==id)}); + else if(prior)ownership.save(prior);else ownership.clear(); + }catch(saveError){throw new Error(`${messageOf(error)}${restoreError?`; restoring the previous output state also failed: ${messageOf(restoreError)}`:''}; output-volume recovery state could not be updated: ${messageOf(saveError)}`);} + if(restoreError)throw new Error(`${messageOf(error)}; restoring the previous output state also failed: ${messageOf(restoreError)}`); throw error; } - } - let released=false; - return{ - message:changed?`Local output raised to at least ${target}% and unmuted for the alarm.`:`Local output was already at least ${target}% and unmuted.`, - async release(){if(released)return;if(changed)await backend.write(before);released=true;} - }; + let released=false; + return{ + message:changed?`Local output raised to at least ${target}% and unmuted for the alarm.`:`Local output was already at least ${target}% and unmuted.`, + async release({preserve=false}={}){ + if(released)return; + await ownership.transaction(async()=>{ + if(released)return; + const current=ownership.read(); + if(!current||current.generation!==joined.generation){released=true;return;} + current.participants=current.participants.filter(item=>item.id!==id&&activeParticipant(item)); + if(preserve){ + // Handoff commits the current output as the user's new baseline. + // A later acquisition samples it before making another adjustment. + current.baseline=null;current.changed=false; + } + ownership.save(current); + if(current.participants.length===0){ + if(current.changed&¤t.baseline)await write(current.baseline); + ownership.clear(); + } + released=true; + }); + } + }; + }); }}; } -type Backend={read():Promise;write(value:Snapshot):Promise}; -function macBackend(run:Run):Backend{return{async read(){const result=await checked(run('/usr/bin/osascript',['-e','set s to get volume settings','-e','return (output volume of s as text) & "," & (output muted of s as text)']),'read macOS output volume');const match=/^(\d+)\s*,\s*(true|false)/i.exec(result.stdout.trim());if(!match)throw new Error('macOS returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value){await checked(run('/usr/bin/osascript',['-e',`set volume output volume ${clamp(value.volume)} ${value.muted?'with':'without'} output muted`]),'set macOS output volume');}};} +function activeParticipant(item:SystemVolumeState['participants'][number]):boolean{return item.phase==='active'&&volumeProcessAlive(item.pid);} + +type Backend={read():Promise;write(value:Snapshot,execute?:Run):Promise}; +function macBackend(run:Run):Backend{return{async read(){const result=await checked(run('/usr/bin/osascript',['-e','set s to get volume settings','-e','return (output volume of s as text) & "," & (output muted of s as text)']),'read macOS output volume');const match=/^(\d+)\s*,\s*(true|false)/i.exec(result.stdout.trim());if(!match)throw new Error('macOS returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value,execute=run){await checked(execute('/usr/bin/osascript',['-e',`set volume output volume ${clamp(value.volume)} ${value.muted?'with':'without'} output muted`]),'set macOS output volume');}};} function linuxBackend(run:Run,resolve:(command:string)=>string|null):Backend|undefined{ - const wpctl=resolve('wpctl');if(wpctl)return{async read(){const result=await checked(run(wpctl,['get-volume','@DEFAULT_AUDIO_SINK@']),'read PipeWire output volume');const match=/Volume:\s*([\d.]+)/i.exec(result.stdout);if(!match)throw new Error('wpctl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])*100),muted:/\[MUTED\]/i.test(result.stdout)};},async write(value){await checked(run(wpctl,['set-volume','@DEFAULT_AUDIO_SINK@',`${clamp(value.volume)}%`]),'set PipeWire output volume');await checked(run(wpctl,['set-mute','@DEFAULT_AUDIO_SINK@',value.muted?'1':'0']),'set PipeWire mute state');}}; - const pactl=resolve('pactl');if(pactl)return{async read(){const [volume,mute]=await Promise.all([checked(run(pactl,['get-sink-volume','@DEFAULT_SINK@']),'read PulseAudio output volume'),checked(run(pactl,['get-sink-mute','@DEFAULT_SINK@']),'read PulseAudio mute state')]);const match=/(\d+)%/.exec(volume.stdout);if(!match)throw new Error('pactl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:/yes/i.test(mute.stdout)};},async write(value){await checked(run(pactl,['set-sink-volume','@DEFAULT_SINK@',`${clamp(value.volume)}%`]),'set PulseAudio output volume');await checked(run(pactl,['set-sink-mute','@DEFAULT_SINK@',value.muted?'1':'0']),'set PulseAudio mute state');}}; - const amixer=resolve('amixer');if(amixer)return{async read(){const result=await checked(run(amixer,['get','Master']),'read ALSA output volume');const matches=[...result.stdout.matchAll(/\[(\d+)%\]/g)];const last=matches.at(-1);if(!last)throw new Error('amixer returned an unreadable output-volume state.');return{volume:clamp(Number(last[1])),muted:/\[off\]/i.test(result.stdout)};},async write(value){await checked(run(amixer,['set','Master',`${clamp(value.volume)}%`,value.muted?'mute':'unmute']),'set ALSA output volume');}}; + const wpctl=resolve('wpctl');if(wpctl)return{async read(){const result=await checked(run(wpctl,['get-volume','@DEFAULT_AUDIO_SINK@']),'read PipeWire output volume');const match=/Volume:\s*([\d.]+)/i.exec(result.stdout);if(!match)throw new Error('wpctl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])*100),muted:/\[MUTED\]/i.test(result.stdout)};},async write(value,execute=run){await checked(execute(wpctl,['set-volume','@DEFAULT_AUDIO_SINK@',`${clamp(value.volume)}%`]),'set PipeWire output volume');await checked(execute(wpctl,['set-mute','@DEFAULT_AUDIO_SINK@',value.muted?'1':'0']),'set PipeWire mute state');}}; + const pactl=resolve('pactl');if(pactl)return{async read(){const [volume,mute]=await Promise.all([checked(run(pactl,['get-sink-volume','@DEFAULT_SINK@']),'read PulseAudio output volume'),checked(run(pactl,['get-sink-mute','@DEFAULT_SINK@']),'read PulseAudio mute state')]);const match=/(\d+)%/.exec(volume.stdout);if(!match)throw new Error('pactl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:/yes/i.test(mute.stdout)};},async write(value,execute=run){await checked(execute(pactl,['set-sink-volume','@DEFAULT_SINK@',`${clamp(value.volume)}%`]),'set PulseAudio output volume');await checked(execute(pactl,['set-sink-mute','@DEFAULT_SINK@',value.muted?'1':'0']),'set PulseAudio mute state');}}; + const amixer=resolve('amixer');if(amixer)return{async read(){const result=await checked(run(amixer,['get','Master']),'read ALSA output volume');const matches=[...result.stdout.matchAll(/\[(\d+)%\]/g)];const last=matches.at(-1);if(!last)throw new Error('amixer returned an unreadable output-volume state.');return{volume:clamp(Number(last[1])),muted:/\[off\]/i.test(result.stdout)};},async write(value,execute=run){await checked(execute(amixer,['set','Master',`${clamp(value.volume)}%`,value.muted?'mute':'unmute']),'set ALSA output volume');}}; return undefined; } -function windowsBackend(run:Run,resolve:(command:string)=>string|null):Backend|undefined{const powershell=resolve('powershell.exe')??resolve('pwsh.exe');if(!powershell)return undefined;return{async read(){const result=await checked(run(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Get()`]),'read Windows output volume');const match=/^(\d+)\s*,\s*(true|false)/im.exec(result.stdout);if(!match)throw new Error('Windows returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value){await checked(run(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted?'true':'false'})`]),'set Windows output volume');}};} +function windowsBackend(run:Run,resolve:(command:string)=>string|null):Backend|undefined{const powershell=resolve('powershell.exe')??resolve('pwsh.exe');if(!powershell)return undefined;return{async read(){const result=await checked(run(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Get()`]),'read Windows output volume');const match=/^(\d+)\s*,\s*(true|false)/im.exec(result.stdout);if(!match)throw new Error('Windows returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value,execute=run){await checked(execute(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted?'true':'false'})`]),'set Windows output volume');}};} const windowsCoreAudio=String.raw`Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; @@ -54,4 +103,22 @@ public static class RadioCliVolume { static IAudioEndpointVolume Endpoint(){IMMD function clamp(value:number){return Math.max(0,Math.min(100,Math.round(Number.isFinite(value)?value:0)));} function messageOf(error:unknown){return error instanceof Error?error.message:String(error);} async function checked(promise:Promise,label:string){const result=await promise;if(result.code!==0)throw new Error(`${label} failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);return result;} -function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',value=>stdout+=String(value));child.stderr.on('data',value=>stderr+=String(value));child.on('error',error=>resolve({code:127,stdout,stderr:error.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} +function runCommand(command:string,args:string[],ownership?:SystemVolumeOwnership):Promise{ + return new Promise(resolve=>{ + ownership?.beginNativeWrite(); + let child:ChildProcessByStdio; + try{child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true,timeout:10_000,killSignal:'SIGKILL'});} + catch(error){let message=messageOf(error);try{ownership?.finishNativeWrite();}catch(cleanupError){message+=`; system-output command journal cleanup failed: ${messageOf(cleanupError)}`;}resolve({code:127,stdout:'',stderr:message});return;} + let stdout='';let stderr='';let failure:string|undefined; + child.stdout.on('data',value=>stdout+=String(value));child.stderr.on('data',value=>stderr+=String(value)); + // Spawn/kill errors do not prove the helper has closed. Keep ownership until + // close, including when recording its PID fails after the process starts. + child.on('error',error=>{failure=error.message;}); + child.on('close',(code,signal)=>{ + try{ownership?.finishNativeWrite();}catch(error){failure=`${failure?`${failure}; `:''}system-output command journal cleanup failed: ${messageOf(error)}`;} + resolve({code:failure?1:code??1,stdout,stderr:failure||stderr||(signal?'The output-volume command was interrupted or timed out.':'')}); + }); + if(ownership&&child.pid)try{ownership.recordNativeWritePid(child.pid);} + catch(error){failure=`The system-output helper PID could not be saved: ${messageOf(error)}`;try{child.kill('SIGKILL');}catch(killError){failure+=`; stopping the helper failed: ${messageOf(killError)}`;}} + }); +} diff --git a/src/alarms/terminal-launcher.test.ts b/src/alarms/terminal-launcher.test.ts index 3723dbf..96576e5 100644 --- a/src/alarms/terminal-launcher.test.ts +++ b/src/alarms/terminal-launcher.test.ts @@ -2,7 +2,8 @@ import {EventEmitter} from 'node:events'; import {spawn as nodeSpawn,type ChildProcess} from 'node:child_process'; import {connect, type Socket} from 'node:net'; import {describe,expect,it,vi} from 'vitest'; -import {detectAlarmTerminal,openAlarmControls,prepareAlarmTerminalAccess,verifyAlarmTerminalLaunch} from './terminal-launcher.js'; +import {openAlarmControls,prepareAlarmTerminalAccess,verifyAlarmTerminalLaunch} from './terminal-launcher.js'; +import {detectGraphicalTerminal} from '../platform/terminals.js'; function spawnRecorder(){ const calls:Array<{command:string;args:readonly string[]}>=[]; @@ -23,10 +24,10 @@ function bootstrapPayload(args: readonly string[]): {args: string[]; environment describe('alarm terminal launcher',()=>{ it('remembers recognizable terminal emulators per platform',()=>{ - expect(detectAlarmTerminal('darwin',{TERM_PROGRAM:'iTerm.app'})).toBe('darwin:iterm'); - expect(detectAlarmTerminal('darwin',{})).toBe('darwin:apple-terminal'); - expect(detectAlarmTerminal('win32',{WT_SESSION:'1'})).toBe('win32:windows-terminal'); - expect(detectAlarmTerminal('win32',{})).toBe('win32:console'); + expect(detectGraphicalTerminal('darwin',{TERM_PROGRAM:'iTerm.app'})).toBe('darwin:iterm'); + expect(detectGraphicalTerminal('darwin',{})).toBe('darwin:apple-terminal'); + expect(detectGraphicalTerminal('win32',{WT_SESSION:'1'})).toBe('win32:windows-terminal'); + expect(detectGraphicalTerminal('win32',{})).toBe('win32:console'); }); it('opens Apple Terminal with RadioCLI and its saved data home',async()=>{ @@ -34,8 +35,10 @@ describe('alarm terminal launcher',()=>{ const result=await openAlarmControls({platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal',RADIOCLI_HOME:"/Data/O'Brien"},nodePath:'/usr/bin/node',cliPath:'/app/cli.js',spawn:recorded.spawn}); expect(result).toMatchObject({opened:false,requested:true,message:expect.stringMatching(/requested.*unverified/i)}); expect(recorded.calls[0]?.command).toBe('/usr/bin/osascript'); - expect(recorded.calls[0]?.args.at(-1)).toContain("RADIOCLI_HOME='/Data/O'\\''Brien'"); - expect(recorded.calls[0]?.args.at(-1)).toContain("'/app/cli.js'"); + const shell=recorded.calls[0]!.args.at(-1)!; + expect(shell.startsWith("'/usr/bin/node' '-e' ")).toBe(true); + const encoded=/'([A-Za-z0-9_-]+)'$/.exec(shell)?.[1];expect(encoded).toBeDefined(); + expect(bootstrapPayload([encoded!])).toEqual({args:['/app/cli.js'],environment:{RADIOCLI_HOME:"/Data/O'Brien"}}); }); it('reports opened only after the new TUI becomes observable',async()=>{const recorded=spawnRecorder();const hasLiveTui=vi.fn().mockReturnValueOnce(false).mockReturnValue(true);const result=await openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,hasLiveTui,timeoutMs:100});expect(result).toMatchObject({opened:true,requested:true});expect(hasLiveTui).toHaveBeenCalledTimes(2);}); @@ -55,7 +58,12 @@ describe('alarm terminal launcher',()=>{ expect(bootstrapPayload(invocation.args)).toEqual({args:['C:\\RadioCLI\\cli.js'],environment:{RADIOCLI_HOME:home}}); }); - it.each(['freebsd','openbsd','netbsd'] as const)('requests an installed graphical terminal on %s',async platform=>{const recorded=spawnRecorder();const result=await openAlarmControls({platform,env:{DISPLAY:':0',TERMINAL:'xterm'},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined});expect(result).toMatchObject({opened:false,requested:true,terminal:`${platform}:/usr/local/bin/xterm`});expect(recorded.calls[0]).toEqual({command:'/usr/local/bin/xterm',args:['-e','/node','/cli.js']});}); + it.each(['freebsd','openbsd','netbsd'] as const)('requests an installed graphical terminal on %s',async platform=>{ + const recorded=spawnRecorder();const result=await openAlarmControls({platform,env:{DISPLAY:':0',TERMINAL:'xterm'},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined}); + expect(result).toMatchObject({opened:false,requested:true,terminal:`${platform}:/usr/local/bin/xterm`}); + expect(recorded.calls[0]!.command).toBe('/usr/local/bin/xterm');expect(recorded.calls[0]!.args.slice(0,3)).toEqual(['-e','/node','-e']); + expect(bootstrapPayload(recorded.calls[0]!.args)).toEqual({args:['/cli.js'],environment:{}}); + }); it('does not open a second terminal when a RadioCLI TUI is already live',async()=>{ const recorded=spawnRecorder(); diff --git a/src/alarms/terminal-launcher.ts b/src/alarms/terminal-launcher.ts index 2490a4a..e4a96e1 100644 --- a/src/alarms/terminal-launcher.ts +++ b/src/alarms/terminal-launcher.ts @@ -3,7 +3,7 @@ import {createServer, type Socket} from 'node:net'; import {randomBytes} from 'node:crypto'; import {listenLoopback} from '../platform/loopback.js'; import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; -import {detectGraphicalTerminal, launchTerminalCommand, type TerminalOptions, type TerminalResolver} from '../platform/terminals.js'; +import {detectGraphicalTerminal, launchTerminalCommand, type TerminalOptions} from '../platform/terminals.js'; export type AlarmTerminalLaunchResult = {opened: boolean; requested?: boolean; terminal: string; message: string}; type LaunchOptions = TerminalOptions & { @@ -15,10 +15,6 @@ type LaunchOptions = TerminalOptions & { type PermissionOptions = TerminalOptions & {permissionTimeoutMs?: number}; type ProbeOptions = PermissionOptions & {nodePath?: string; timeoutMs?: number}; -export function detectAlarmTerminal(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, resolve?: TerminalResolver): string { - return detectGraphicalTerminal(platform, env, resolve); -} - export async function openAlarmControls(options: LaunchOptions): Promise { if (options.hasLiveTui?.()) return {opened: false, terminal: 'existing-tui', message: 'An existing RadioCLI TUI will show the ringing controls.'}; const terminal = await launchTerminalCommand({...options, args: [options.cliPath], title: 'RadioCLI Alarm'}); @@ -37,7 +33,7 @@ export async function prepareAlarmTerminalAccess(options: PermissionOptions = {} const platform = options.platform ?? process.platform; const env = options.env ?? process.env; if (nativeAdapters(identifyPlatform({platform, env})).terminal !== 'macos') return; - const terminal = detectAlarmTerminal(platform, env, options.resolve); + const terminal = detectGraphicalTerminal(platform, env, options.resolve); const application = terminal === 'darwin:apple-terminal' ? 'Terminal' : terminal === 'darwin:iterm' ? 'iTerm' : undefined; if (!application) return; const launch = options.spawn ?? spawnAttached; @@ -55,7 +51,7 @@ export async function verifyAlarmTerminalLaunch(options: ProbeOptions = {}): Pro const platform = options.platform ?? process.platform; const env = options.env ?? process.env; await prepareAlarmTerminalAccess(options); - const terminal = detectAlarmTerminal(platform, env, options.resolve); + const terminal = detectGraphicalTerminal(platform, env, options.resolve); if (terminal.endsWith(':unsupported')) throw new Error('No supported graphical terminal was found for automatic alarm controls.'); const token = randomBytes(24).toString('base64url'); const server = createServer(); diff --git a/src/cli.tsx b/src/cli.tsx index 941e4b0..d8a96d2 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -7,7 +7,7 @@ import {PlayerController} from './player/player-controller.js'; import {defaultLibraryPath, JsonLibraryStore} from './storage/store.js'; import {parsePlaylistFile, stationFromUrl, writeM3u} from './playlists/playlist.js'; import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backend-install.js'; -import {resolveCommand} from './player/command.js'; +import {resolveCommand} from './platform/executables.js'; import {diagnoseCommand, type CommandDiagnostic} from './player/command-diagnostics.js'; import {airPlaySenderHealth} from './player/airplay-sender-health.js'; import {appVersion} from './version.js'; diff --git a/src/player/command.test.ts b/src/platform/executable-locations.test.ts similarity index 97% rename from src/player/command.test.ts rename to src/platform/executable-locations.test.ts index 8a5589e..778a5ea 100644 --- a/src/player/command.test.ts +++ b/src/platform/executable-locations.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {resolveCommandDetails} from './command.js'; +import {resolveCommandDetails} from './executables.js'; describe('command resolution', () => { it('finds the WinGet shinchiro mpv installer location without PATH', () => { diff --git a/src/platform/executables.test.ts b/src/platform/executables.test.ts index 8849880..08f414d 100644 --- a/src/platform/executables.test.ts +++ b/src/platform/executables.test.ts @@ -68,6 +68,14 @@ describe('safe executable configuration and permissions', () => { expect(resolveCommandDetails(directory).path).toBeNull(); }); + it('finds a runnable agent command on the supplied PATH', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); + roots.push(root); + const path = join(root, `radiocli-test-command${process.platform === 'win32' ? '.exe' : ''}`); + writeFileSync(path, 'test', {mode: 0o700}); + expect(resolveCommandDetails('radiocli-test-command', {env: {PATH: root}, platform: process.platform}).path).toBe(path); + }); + it.skipIf(process.platform === 'win32')('honors POSIX executable permission and environment changes without a stale cache', () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); roots.push(root); diff --git a/src/platform/launch-command.test.ts b/src/platform/launch-command.test.ts new file mode 100644 index 0000000..71550cc --- /dev/null +++ b/src/platform/launch-command.test.ts @@ -0,0 +1,162 @@ +import {execFileSync, spawn, spawnSync} from 'node:child_process'; +import {once} from 'node:events'; +import {mkdirSync, mkdtempSync, realpathSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {describe, expect, it, vi} from 'vitest'; +import {launchEnvironment, nodeLaunchCommand, waitForLaunch} from './launch-command.js'; + +const pathsModule = new URL('./paths.ts', import.meta.url).href; +const tsxModule = import.meta.resolve('tsx'); +function probeArgs(platform: string, absolute = false): string[] { + return ['--import', tsxModule, '--input-type=module', '-e', ` + import {platformPaths} from ${JSON.stringify(pathsModule)}; + import {posix,win32} from 'node:path'; + const paths=platformPaths({platform: ${JSON.stringify(platform)}}); + const path=${platform === 'win32' ? 'win32' : 'posix'}; + process.stdout.write(JSON.stringify(${absolute ? 'Object.fromEntries(Object.entries(paths).map(([key,value])=>[key,path.resolve(value)]))' : 'paths'})); + `]; +} +function run(command: readonly string[], env: NodeJS.ProcessEnv, cwd?: string) { + return JSON.parse(execFileSync(command[0]!, command.slice(1), {env, cwd, encoding: 'utf8'})); +} + +it('waits for an actual stubborn bootstrap to terminate before reporting its timeout', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + const child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});setInterval(()=>{},1000);process.send('ready');"], {stdio: ['ignore', 'ignore', 'ignore', 'ipc']}); + let closed = false; + child.once('close', () => { closed = true; }); + const result = waitForLaunch(child, {waitForExit: true}).catch(error => error as Error); + try { + await once(child, 'message'); + await vi.advanceTimersByTimeAsync(10_250); + expect(await result).toMatchObject({message: 'Launch bootstrap did not complete.'}); + expect(closed).toBe(true); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + } finally { + vi.useRealTimers(); + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'close'); + child.kill('SIGKILL'); + await exited; + } + } +}); + +describe('launch path identity', () => { + const literal = "Radio Data ' \" $() ; & %PATH%! 单播"; + function compareWorkingDirectories(platform: string, env: NodeJS.ProcessEnv) { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'radiocli-launch-cwd-'))); + try { + const parentCwd = join(root, 'parent workspace');const childCwd = join(root, 'unrelated session'); + mkdirSync(parentCwd);mkdirSync(childCwd); + const args = probeArgs(platform, true); + const parent = run([process.execPath, ...args], env, parentCwd); + const snapshot = launchEnvironment(env, {platform, cwd: parentCwd}); + const child = run(nodeLaunchCommand(process.execPath, args, snapshot), {}, childCwd); + expect(child).toEqual(parent); + } finally { rmSync(root, {recursive: true, force: true}); } + } + + it.skipIf(process.platform === 'win32').each([ + {HOME: './listener', RADIOCLI_HOME: `./${literal}`}, + {HOME: './listener', RADIO_ATLAS_HOME: `./${literal}`, XDG_RUNTIME_DIR: './run'}, + {HOME: './listener', XDG_DATA_HOME: `../${literal}`, XDG_CACHE_HOME: './cache', XDG_RUNTIME_DIR: './run'}, + {HOME: './listener', RADIOCLI_HOME: '', RADIO_ATLAS_HOME: `./${literal}`}, + {HOME: './listener', RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '', XDG_DATA_HOME: '', XDG_CACHE_HOME: '', XDG_RUNTIME_DIR: ''}, + {HOME: ''} + ])('keeps POSIX storage identity across different working directories: %j', env => compareWorkingDirectories('linux', env)); + + it.skipIf(process.platform !== 'win32').each([ + {RADIOCLI_HOME: '.\\Radio Data', USERPROFILE: '.\\listener'}, + {RADIO_ATLAS_HOME: '.\\Radio Data', USERPROFILE: '.\\listener', LOCALAPPDATA: '.\\local'}, + {USERPROFILE: '.\\listener', APPDATA: '.\\roaming', LOCALAPPDATA: '.\\local'}, + {RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '.\\Radio Data', USERPROFILE: '.\\listener'}, + {APPDATA: '', LOCALAPPDATA: '', XDG_DATA_HOME: '', XDG_CACHE_HOME: ''} + ])('keeps native Windows storage identity across different working directories: %j', env => compareWorkingDirectories('win32', env)); + + it.each([ + ['linux', {HOME: `/home/${literal}`, RADIO_ATLAS_HOME: `/legacy/${literal}`, XDG_RUNTIME_DIR: `/run/${literal}`}], + ['linux', {HOME: `/home/${literal}`, XDG_DATA_HOME: `/data/${literal}`, XDG_CACHE_HOME: `/cache/${literal}`, XDG_RUNTIME_DIR: `/run/${literal}`}], + ['freebsd', {HOME: `/home/${literal}`, XDG_DATA_HOME: `/data/${literal}`, XDG_CACHE_HOME: `/cache/${literal}`}], + ['darwin', {HOME: `/Users/${literal}`, XDG_RUNTIME_DIR: `/run/${literal}`}], + ['win32', {USERPROFILE: `C:\\Users\\${literal}`, APPDATA: `D:\\Roaming\\${literal}`, LOCALAPPDATA: `D:\\Local\\${literal}`}], + ['linux', {HOME: `/home/${literal}`, RADIOCLI_HOME: `/current/${literal}`, RADIO_ATLAS_HOME: `/legacy/${literal}`}] + ] as const)('preserves %s parent library, cache, and runtime in a clean child with %j', (platform, env) => { + const args = probeArgs(platform); + const parent = run([process.execPath, ...args], env); + // Both processes receive only the fixture environment. In particular, + // neither can recover a dropped selector from the test runner's env. + const child = run(nodeLaunchCommand(process.execPath, args, launchEnvironment(env, {platform})), {}); + expect(child).toEqual(parent); + if ('RADIO_ATLAS_HOME' in env && !('RADIOCLI_HOME' in env)) { + expect(child.library).toBe(`${env.RADIO_ATLAS_HOME}/radio-atlas.json`); + expect(child.cache).toBe(`${env.RADIO_ATLAS_HOME}/radio-atlas-cache.json`); + } + }); + + it.each([ + {}, + {HOME: '/home/saved'}, + {HOME: '/home/saved', RADIO_ATLAS_HOME: '/saved legacy'}, + {HOME: '/home/saved', RADIOCLI_HOME: '', XDG_DATA_HOME: '', XDG_CACHE_HOME: '', XDG_RUNTIME_DIR: ''} + ])('preserves absent and empty selectors despite inherited launcher contamination: %j', env => { + const args = probeArgs('linux', true); + const expected = run([process.execPath, ...args], env); + const inherited = { + HOME: '/home/unrelated', USERPROFILE: 'C:\\Unrelated', APPDATA: '/unrelated/roaming', LOCALAPPDATA: '/unrelated/local', + RADIOCLI_HOME: '/unrelated/current', RADIO_ATLAS_HOME: '/unrelated/legacy', + XDG_DATA_HOME: '/unrelated/data', XDG_CACHE_HOME: '/unrelated/cache', XDG_RUNTIME_DIR: '/unrelated/runtime' + }; + expect(run(nodeLaunchCommand(process.execPath, args, launchEnvironment(env, {platform: 'linux'})), inherited)).toEqual(expected); + }); + + it('persists only approved application, path, and optional desktop settings', () => { + const env = {RADIO_ATLAS_HOME: '/legacy', XDG_RUNTIME_DIR: '/run/user/1000', RADIOCLI_OFFLINE: '1', DISPLAY: ':0', API_TOKEN: 'secret', NODE_OPTIONS: '--require unsafe.cjs', PATH: '/shell/tools'}; + expect(launchEnvironment(env, {platform: 'linux'})).toEqual({RADIO_ATLAS_HOME: '/legacy', XDG_RUNTIME_DIR: '/run/user/1000', RADIOCLI_OFFLINE: '1'}); + expect(launchEnvironment(env, {platform: 'linux', includeDesktop: true, terminal: 'linux:/bin/kitty'})).toEqual({RADIO_ATLAS_HOME: '/legacy', XDG_RUNTIME_DIR: '/run/user/1000', RADIOCLI_OFFLINE: '1', DISPLAY: ':0', RADIOCLI_ALARM_TERMINAL: 'linux:/bin/kitty'}); + }); + + it('resolves Windows relative, drive-relative, and rooted selectors against the launch directory', () => { + expect(launchEnvironment({RADIOCLI_HOME: 'Radio Data', RADIO_ATLAS_HOME: 'C:legacy', APPDATA: '\\roaming', LOCALAPPDATA: '', USERPROFILE: '.\\listener'}, {platform: 'win32', cwd: 'C:\\Project Workspace'})).toEqual({ + RADIOCLI_HOME: 'C:\\Project Workspace\\Radio Data', RADIO_ATLAS_HOME: 'C:\\Project Workspace\\legacy', APPDATA: 'C:\\roaming', LOCALAPPDATA: 'C:\\Project Workspace', USERPROFILE: 'C:\\Project Workspace\\listener' + }); + }); + + it('preserves empty fallback overrides and the invalid empty Windows user profile', () => { + expect(launchEnvironment({RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '', USERPROFILE: ''}, {platform: 'win32', cwd: 'C:\\Project'})).toEqual({RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '', USERPROFILE: ''}); + }); + + it('validates the captured directory before it becomes a persisted path selector', () => { + expect(() => launchEnvironment({XDG_DATA_HOME: 'data'}, {platform: 'linux', cwd: '/parent\nworkspace'})).toThrow(/control characters/); + }); + + it.skipIf(process.platform !== 'win32')('clears inherited Windows path selectors regardless of environment-key casing', () => { + const args = probeArgs('win32'); + const inherited = {UserProfile: 'C:\\Unrelated', AppData: 'C:\\Unrelated\\Roaming', LocalAppData: 'C:\\Unrelated\\Local', radiocli_home: 'C:\\Unrelated\\RadioCLI'}; + expect(run(nodeLaunchCommand(process.execPath, args, launchEnvironment({})), inherited)).toEqual(run([process.execPath, ...args], {})); + }); + + it('applies Windows case-insensitive identity cleanup at the bootstrap boundary', () => { + const args = ['-e', "process.stdout.write(JSON.stringify(Object.keys(process.env).filter(key=>/^(appdata|radiocli_home)$/i.test(key))))"]; + const command = nodeLaunchCommand(process.execPath, args, {}); + // Inject only the native platform boundary; the generated bootstrap runs + // unchanged and launches a real child with the resulting environment. + const probe = `const [program,payload]=JSON.parse(process.argv[1]);require('node:vm').runInNewContext(program,{Buffer,require,console,process:{...process,platform:'win32',argv:[process.execPath,payload],env:{AppData:'C:\\\\Unrelated',radiocli_home:'C:\\\\Other'}}});`; + expect(run([process.execPath, '-e', probe, JSON.stringify(command.slice(2))], {})).toEqual([]); + }); + + it.each([0, 7])('returns the actual application exit status %s through the bootstrap', code => { + const command = nodeLaunchCommand(process.execPath, ['-e', `process.exit(${code})`], {}); + expect(spawnSync(command[0]!, command.slice(1), {env: {}}).status).toBe(code); + }); + + it.skipIf(process.platform === 'win32')('reports application signal termination as a launch failure', () => { + const command = nodeLaunchCommand(process.execPath, ['-e', "process.kill(process.pid,'SIGTERM')"], {}); + expect(spawnSync(command[0]!, command.slice(1), {env: {}}).status).toBe(1); + }); + + it.each(['RADIO_ATLAS_HOME', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'])('rejects control characters in the persisted %s selector', key => { + expect(() => launchEnvironment({[key]: 'bad\nvalue'})).toThrow(/control characters/); + }); +}); diff --git a/src/platform/launch-command.ts b/src/platform/launch-command.ts index 646be8b..f39efe0 100644 --- a/src/platform/launch-command.ts +++ b/src/platform/launch-command.ts @@ -1,5 +1,8 @@ +import {pathEnvironmentKeys} from './paths.js'; +import type {ChildProcess} from 'node:child_process'; +import {posix, win32} from 'node:path'; + const applicationEnvironmentKeys = [ - 'RADIOCLI_HOME', 'RADIOCLI_MPV_PATH', 'RADIOCLI_FFPLAY_PATH', 'RADIOCLI_VLC_PATH', @@ -8,19 +11,33 @@ const applicationEnvironmentKeys = [ 'RADIOCLI_LOW_BANDWIDTH' ] as const; -const desktopEnvironmentKeys = ['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR'] as const; +const desktopEnvironmentKeys = ['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS'] as const; /** Persist only application settings and the desktop connection a job needs. */ export function launchEnvironment( env: NodeJS.ProcessEnv, - options: {includeDesktop?: boolean; terminal?: string} = {} + options: {includeDesktop?: boolean; terminal?: string; platform?: string; cwd?: string} = {} ): Record { const result: Record = {}; + const platform = options.platform ?? process.platform; + const path = platform === 'win32' ? win32 : posix; const add = (key: string, value: string | undefined) => { if (value === undefined) return; if (/[\r\n\0]/.test(value)) throw new Error(`${key} cannot contain control characters in a launch environment.`); result[key] = value; }; + for (const key of pathEnvironmentKeys) { + const value = env[key]; + add(key, value); + if (value === undefined) continue; + // Empty overrides fall back; empty roots select the invoking directory. + // Windows rejects an empty USERPROFILE, while HOME is unused there. + if (value === '' && (key === 'RADIOCLI_HOME' || key === 'RADIO_ATLAS_HOME' || key === 'USERPROFILE' || key === 'HOME' && platform === 'win32')) continue; + // Windows root-relative paths also depend on the invoking drive. Fully + // qualified values need no cwd lookup (which can fail after cwd removal). + const absolute = path.isAbsolute(value) && (platform !== 'win32' || /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(value)); + if (!absolute) add(key, path.resolve(options.cwd ?? process.cwd(), value)); + } for (const key of applicationEnvironmentKeys) add(key, env[key]); if (options.includeDesktop) for (const key of desktopEnvironmentKeys) add(key, env[key]); add('RADIOCLI_ALARM_TERMINAL', options.terminal); @@ -34,6 +51,61 @@ export function nodeLaunchCommand(nodePath: string, args: readonly string[], env } // This fixed program contains no double quotes, including after expansion. // PowerShell 5 and Task Scheduler only receive this program and encoded data. - const program = "const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...process.env,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);"; + // A terminal server or scheduler may have unrelated path overrides. Clear the + // complete identity before restoring this launch's snapshot, preserving absent + // selectors and the path layer's existing empty-value/default semantics. + const keys = pathEnvironmentKeys.map(key => `'${key}'`).join(','); + const program = `const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const e={...process.env};for(const k of Object.keys(e))if([${keys}].includes(process.platform==='win32'?k.toUpperCase():k))delete e[k];const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...e,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);`; return [nodePath, '-e', program, Buffer.from(JSON.stringify({args, environment}), 'utf8').toString('base64url')]; } + +/** Process acceptance is separate from application/session readiness. */ +export function waitForLaunch(child: ChildProcess, options: {waitForExit?: boolean} = {}): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let acceptedTimer: NodeJS.Timeout | undefined; + let forceTimer: NodeJS.Timeout | undefined; + let cleanupTimer: NodeJS.Timeout | undefined; + let stopError: Error | undefined; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(startupTimer); + if (acceptedTimer) clearTimeout(acceptedTimer); + clearTimeout(forceTimer); + clearTimeout(cleanupTimer); + if (error) reject(error); else resolve(); + }; + const signal = (value: NodeJS.Signals) => { + try { child.kill(value); } catch { /* Retain the launch failure and bounded cleanup. */ } + }; + const stop = (error: Error) => { + if (settled || stopError) return; + stopError = error; + clearTimeout(startupTimer); + if (acceptedTimer) clearTimeout(acceptedTimer); + forceTimer = setTimeout(() => signal('SIGKILL'), 250); + cleanupTimer = setTimeout(() => { child.unref(); finish(error); }, 1_000); + signal('SIGTERM'); + }; + const startupTimer = setTimeout(() => { + const error = new Error('Process launcher did not report process startup.'); + if (child.pid) stop(error); else finish(error); + }, 3_000); + // Keep a listener after acceptance: a late native error must not become an + // unhandled EventEmitter error in the application that requested the launch. + child.on('error', error => { + if (!settled && child.pid) stop(error); else finish(error); + }); + child.once('close', (code, signal) => finish(stopError ?? (code === 0 ? undefined : new Error(`Process launcher exited with ${code ?? signal ?? 'unknown status'}.`)))); + child.once('spawn', () => { + if (options.waitForExit) { + clearTimeout(startupTimer); + acceptedTimer = setTimeout(() => stop(new Error('Launch bootstrap did not complete.')), 10_000); + } else { + child.unref(); + acceptedTimer = setTimeout(() => finish(), 100); + } + }); + }); +} diff --git a/src/platform/packages.test.ts b/src/platform/packages.test.ts index f3f7847..50779e1 100644 --- a/src/platform/packages.test.ts +++ b/src/platform/packages.test.ts @@ -3,6 +3,32 @@ import {detectPackageManager, ffplayInstallCommand, mpvInstallCommand, packageCo import {identifyPlatform} from './runtime.js'; describe('native package plans', () => { + it.each(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'sunos', 'haiku', 'android'] as const)( + 'gives manual guidance instead of an unavailable installer on %s', platform => { + const options = {hasCommand: () => false, getUid: () => 1000}; + for (const hint of [mpvInstallCommand(platform, 'ID=ubuntu', {}, options), ffplayInstallCommand(platform, 'ID=ubuntu', {}, options)]) { + expect(hint).toMatch(/manual|manually/); + expect(hint).not.toMatch(/(?:brew|winget|sudo|doas|pkg|pkgin|pkgman) (?:install|add|-I|-y)/); + } + } + ); + + it('does not suggest a privilege helper that is missing or needed by root', () => { + const hasCommand = (command: string): boolean => command === 'apt'; + expect(mpvInstallCommand('linux', 'ID=debian', {}, {hasCommand, getUid: () => 1000})).toBe('apt-get install -y mpv (run as root; sudo/doas unavailable)'); + expect(mpvInstallCommand('linux', 'ID=debian', {}, {hasCommand, getUid: () => 0})).toBe('apt-get install -y mpv'); + }); + + it('keeps Termux prerequisites explicit when pkg is absent or the caller is root', () => { + const env = {TERMUX_VERSION: '0.118.3'}; + expect(mpvInstallCommand('android', '', env, {hasCommand: () => false})).toContain('manually'); + const ffplay = ffplayInstallCommand('android', '', env, {hasCommand: () => false}); + expect(ffplay).toContain('x11-repo'); + expect(ffplay).toContain('configured display/audio'); + expect(ffplay).not.toContain('pkg install'); + expect(mpvInstallCommand('android', '', env, {hasCommand: () => true, getUid: () => 0})).toContain('normal Termux app user, without root'); + }); + it.each([ ['freebsd', 'pkg'], ['openbsd', 'pkg_add'], ['netbsd', 'pkgin'], ['sunos', 'pkgin'], ['haiku', 'pkgman'] ] as const)('selects %s package tools without confusing other Unix commands', (platform, expected) => { @@ -53,12 +79,13 @@ describe('native package plans', () => { it('keeps the printed installation hints consistent with each verified route', () => { const termux = {TERMUX_VERSION: '0.118.3'}; - expect(mpvInstallCommand('android', '', termux)).toBe('pkg install -y mpv'); - expect(ffplayInstallCommand('linux', '', termux)).toContain('x11-repo'); - expect(mpvInstallCommand('haiku', '', {})).toBe('pkgman install -y mpv'); - expect(ffplayInstallCommand('haiku', '', {})).toBe('pkgman install -y ffmpeg8_tools'); - expect(mpvInstallCommand('sunos', '', {})).toContain('pkgin -y install mpv'); - expect(ffplayInstallCommand('sunos', '', {})).toContain('matching pkgsrc'); + const options = {hasCommand: () => true, getUid: () => 1000}; + expect(mpvInstallCommand('android', '', termux, options)).toBe('pkg install -y mpv'); + expect(ffplayInstallCommand('linux', '', termux, options)).toContain('x11-repo'); + expect(mpvInstallCommand('haiku', '', {}, options)).toBe('pkgman install -y mpv'); + expect(ffplayInstallCommand('haiku', '', {}, options)).toBe('pkgman install -y ffmpeg8_tools'); + expect(mpvInstallCommand('sunos', '', {}, options)).toContain('pkgin -y install mpv'); + expect(ffplayInstallCommand('sunos', '', {}, options)).toContain('matching pkgsrc'); }); it('uses verified noninteractive BSD syntax', () => { diff --git a/src/platform/packages.ts b/src/platform/packages.ts index 01b531c..a38e51f 100644 --- a/src/platform/packages.ts +++ b/src/platform/packages.ts @@ -1,10 +1,10 @@ -import {existsSync, readFileSync} from 'node:fs'; import {commandExists} from './executables.js'; import {powershellCommand} from './shell.js'; -import {identifyPlatform, type PlatformProfile} from './runtime.js'; +import {identifyPlatform, readLinuxOsRelease, type PlatformProfile} from './runtime.js'; export type SetupComponent = 'mpv' | 'ffmpeg' | 'vlc'; -export type SetupPackageManager = 'brew' | 'winget' | 'scoop' | 'choco' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper' | 'pkg' | 'pkg_add' | 'pkgin' | 'termux-pkg' | 'pkgman'; +export const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper', 'pkg', 'pkg_add', 'pkgin', 'termux-pkg', 'pkgman'] as const; +export type SetupPackageManager = typeof packageManagers[number]; export type SetupCommand = { component: SetupComponent | null; @@ -14,8 +14,6 @@ export type SetupCommand = { display: string; }; -export const packageManagers: SetupPackageManager[] = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper', 'pkg', 'pkg_add', 'pkgin', 'termux-pkg', 'pkgman']; - /** FreeBSD pkg and Termux pkg have distinct recipes and privilege models. */ export function packageManagerProgram(manager: SetupPackageManager): string { return manager === 'termux-pkg' ? 'pkg' : manager; @@ -73,18 +71,8 @@ export function packageInstallCommand( component: SetupComponent, {elevation = 'sudo'}: {elevation?: 'sudo' | 'doas' | null} = {} ): SetupCommand | null { - const packages: Record>> = { - brew: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, + const packages: Partial>>> = { winget: {mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC'}, - scoop: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - choco: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - apt: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - dnf: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - pacman: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - apk: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - zypper: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - pkg: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - pkg_add: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, // pkgsrc FFmpeg/ffplay packages and executables carry a major version. // Choose an available matching pair manually instead of guessing a catalog. pkgin: {mpv: 'mpv', vlc: 'vlc'}, @@ -93,7 +81,7 @@ export function packageInstallCommand( 'termux-pkg': {mpv: 'mpv'}, pkgman: {mpv: 'mpv', ffmpeg: 'ffmpeg8_tools'} }; - const packageName = packages[manager][component]; + const packageName = packages[manager] ? packages[manager][component] : component; if (!packageName) return null; let program = packageManagerProgram(manager); let args: string[]; @@ -103,11 +91,10 @@ export function packageInstallCommand( else if (manager === 'scoop') args = ['install', packageName]; else if (manager === 'choco') args = ['install', packageName, '-y']; else if (manager === 'apt') { program = 'apt-get'; args = ['install', '-y', packageName]; } - else if (manager === 'dnf') args = ['install', '-y', packageName]; else if (manager === 'pacman') args = ['-S', '--needed', '--noconfirm', packageName]; else if (manager === 'apk') args = ['add', packageName]; else if (manager === 'zypper') args = ['--non-interactive', 'install', packageName]; - else if (manager === 'pkg' || manager === 'termux-pkg' || manager === 'pkgman') args = ['install', '-y', packageName]; + else if (manager === 'dnf' || manager === 'pkg' || manager === 'termux-pkg' || manager === 'pkgman') args = ['install', '-y', packageName]; else if (manager === 'pkg_add') args = ['-I', packageName]; else args = ['-y', 'install', packageName]; @@ -163,15 +150,6 @@ function firstAvailable(values: T[], hasCommand: (command: str return values.find(hasCommand) ?? null; } -export function readLinuxOsRelease(platform: NodeJS.Platform = process.platform): string { - if (platform !== 'linux' || !existsSync('/etc/os-release')) return ''; - try { - return readFileSync('/etc/os-release', 'utf8'); - } catch { - return ''; - } -} - function linuxReleaseIds(osRelease: string): Set { const ids = new Set(); for (const line of osRelease.split('\n')) { @@ -191,90 +169,47 @@ function hasAny(values: Set, candidates: string[]): boolean { return candidates.some(candidate => values.has(candidate)); } -export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env): string { - if (identifyPlatform({platform, osRelease, env}).id === 'termux') return 'pkg install -y mpv'; - if (platform === 'darwin') { - return 'brew install mpv'; - } - - if (platform === 'win32') { - return 'winget install --id shinchiro.mpv -e'; - } - - if (platform === 'freebsd') return 'pkg install -y mpv (as root, or use sudo/doas)'; - if (platform === 'openbsd') return 'doas pkg_add -I mpv'; - if (platform === 'netbsd' || platform === 'sunos') return 'pkgin -y install mpv (with pkgsrc configured, as root or using sudo/doas)'; - if (platform === 'haiku') return 'pkgman install -y mpv'; - if (platform === 'aix') return 'manual native player required; no verified AIX mpv package recipe (set RADIOCLI_MPV_PATH)'; - - if (platform !== 'linux') { - return 'install mpv with your system package manager'; - } - - const ids = linuxReleaseIds(osRelease); - if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { - return 'sudo apt install mpv'; - } - - if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { - return 'sudo dnf install mpv'; - } - - if (hasAny(ids, ['arch', 'manjaro'])) { - return 'sudo pacman -S mpv'; - } - - if (hasAny(ids, ['alpine'])) { - return 'sudo apk add mpv'; - } - - if (hasAny(ids, ['opensuse', 'suse'])) { - return 'sudo zypper install mpv'; - } +export type PackageHintOptions = { + hasCommand?: (command: string) => boolean; + getUid?: () => number | undefined; +}; - return 'install mpv with your system package manager'; +export function packageManagerElevation(hasCommand: (command: string) => boolean, isRoot: boolean): 'sudo' | 'doas' | null { + return isRoot ? null : firstAvailable(['sudo', 'doas'], hasCommand); } -export function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env): string { - if (identifyPlatform({platform, osRelease, env}).id === 'termux') return 'enable x11-repo manually, then pkg install -y ffplay (requires configured display/audio); use mpv for audio'; - if (platform === 'darwin') { - return 'brew install ffmpeg'; - } - - if (platform === 'win32') { - return 'winget install --id Gyan.FFmpeg -e'; - } - - if (platform === 'freebsd') return 'pkg install -y ffmpeg; ffplay requires the SDL build option'; - if (platform === 'openbsd') return 'doas pkg_add -I ffmpeg'; - if (platform === 'netbsd' || platform === 'sunos') return 'install a matching pkgsrc ffmpegN/ffplayN pair; set RADIOCLI_FFPLAY_PATH'; - if (platform === 'haiku') return 'pkgman install -y ffmpeg8_tools'; - if (platform === 'aix') return 'manual native player required; no verified AIX ffplay package recipe (set RADIOCLI_FFPLAY_PATH)'; - - if (platform !== 'linux') { - return 'install FFmpeg with your system package manager'; - } - - const ids = linuxReleaseIds(osRelease); - if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { - return 'sudo apt install ffmpeg'; - } - - if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { - return 'sudo dnf install ffmpeg'; - } - - if (hasAny(ids, ['arch', 'manjaro'])) { - return 'sudo pacman -S ffmpeg'; - } +export function packageInstallPrerequisites(manager: SetupPackageManager, components: SetupComponent[]): SetupCommand[] { + if (manager !== 'scoop' || !components.some(component => component === 'mpv' || component === 'vlc')) return []; + return [{component: null, label: 'Scoop extras bucket', program: 'scoop', args: ['bucket', 'add', 'extras'], display: 'scoop bucket add extras'}]; +} - if (hasAny(ids, ['alpine'])) { - return 'sudo apk add ffmpeg'; - } +export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env, options: PackageHintOptions = {}): string { + return playbackInstallHint('mpv', platform, osRelease, env, options); +} - if (hasAny(ids, ['opensuse', 'suse'])) { - return 'sudo zypper install ffmpeg'; - } +export function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env, options: PackageHintOptions = {}): string { + return playbackInstallHint('ffmpeg', platform, osRelease, env, options); +} - return 'install FFmpeg with your system package manager'; +function playbackInstallHint(component: 'mpv' | 'ffmpeg', platform: NodeJS.Platform, osRelease: string, env: NodeJS.ProcessEnv, {hasCommand = commandExists, getUid = process.getuid}: PackageHintOptions): string { + const host = identifyPlatform({platform, osRelease, env}); + const player = component === 'mpv' ? 'mpv' : 'ffplay'; + if (host.id === 'aix') return `manual native player required; no verified AIX ${player} package recipe (set RADIOCLI_${player.toUpperCase()}_PATH)`; + const manager = detectPackageManager(platform, osRelease, hasCommand, env); + if (component === 'ffmpeg' && host.id === 'termux') return `enable x11-repo manually, then ${manager ? 'pkg install -y ffplay' : 'install the separate ffplay package manually'} (requires configured display/audio); use mpv for audio`; + if (component === 'ffmpeg' && (platform === 'netbsd' || platform === 'sunos')) return 'manually install a matching pkgsrc ffmpegN/ffplayN pair; set RADIOCLI_FFPLAY_PATH'; + + const notes: string[] = []; + if (component === 'ffmpeg' && platform === 'freebsd') notes.push('ffplay requires the SDL build option'); + if (!manager) return [`install ${component === 'mpv' ? 'mpv' : 'FFmpeg'} manually; no supported package manager was found`, ...notes].join('; '); + const isRoot = getUid?.() === 0; + const elevation = packageManagerElevation(hasCommand, isRoot); + const command = packageInstallCommand(manager, component, {elevation}); + if (!command) return `install ${componentLabel(component)} manually`; + if (packageManagerNeedsRoot(manager) && !isRoot && !elevation) notes.unshift('run as root; sudo/doas unavailable'); + if (manager === 'pkgin') notes.push('requires configured pkgsrc'); + if (manager === 'termux-pkg' && isRoot) notes.push('run as the normal Termux app user, without root'); + const prerequisites = packageInstallPrerequisites(manager, [component]); + for (const prerequisite of prerequisites) notes.push(`first run ${prerequisite.display}`); + return command.display + (notes.length ? ` (${notes.join('; ')})` : ''); } diff --git a/src/platform/paths.ts b/src/platform/paths.ts index d6117ae..c0e687d 100644 --- a/src/platform/paths.ts +++ b/src/platform/paths.ts @@ -3,6 +3,13 @@ import {posix, win32} from 'node:path'; type PathInput = {platform?: string; home?: string; env?: NodeJS.ProcessEnv}; +/** Path selectors form one launch identity, including selectors that are absent. */ +export const pathEnvironmentKeys = [ + 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', + 'RADIOCLI_HOME', 'RADIO_ATLAS_HOME', + 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR' +] as const; + /** * Keep historical namespaces stable. In particular, macOS alarm-control/Guard * files and general agent/occurrence runtime files already use different roots. diff --git a/src/platform/runtime.ts b/src/platform/runtime.ts index 71c9fab..e35a05a 100644 --- a/src/platform/runtime.ts +++ b/src/platform/runtime.ts @@ -54,7 +54,7 @@ export function identifyPlatform(input: PlatformInput = {}): PlatformProfile { nodeMajor: Number.parseInt(nodeVersion, 10), libc: platform === 'android' || termux ? 'bionic' : input.libc ?? (platform === 'linux' ? nativeLibc(platform) : 'none'), isWsl: id === 'linux' && Boolean(env.WSL_INTEROP || env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(kernelRelease)), - osRelease: input.osRelease ?? (platform === 'linux' && platform === process.platform ? readOsRelease() : '') + osRelease: input.osRelease ?? (platform === 'linux' && platform === process.platform ? readLinuxOsRelease() : '') }; } @@ -78,7 +78,8 @@ export function nativeAdapters(host: PlatformProfile = identifyPlatform()): Nati return portable; } -function readOsRelease(): string { +export function readLinuxOsRelease(platform: string = process.platform): string { + if (platform !== 'linux') return ''; try { return readFileSync('/etc/os-release', 'utf8'); } catch { return ''; } } diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts index 1631fe8..1561e36 100644 --- a/src/platform/terminals.test.ts +++ b/src/platform/terminals.test.ts @@ -1,9 +1,10 @@ -import {execFileSync,spawn,type ChildProcess} from 'node:child_process'; +import {execFileSync,spawn,spawnSync,type ChildProcess} from 'node:child_process'; import {EventEmitter} from 'node:events'; -import {existsSync,mkdtempSync,mkdirSync,readFileSync,rmSync,writeFileSync} from 'node:fs'; +import {existsSync,mkdtempSync,mkdirSync,readFileSync,rmSync,symlinkSync,writeFileSync} from 'node:fs'; import {basename,join,posix} from 'node:path'; import {afterEach,describe,expect,it,vi} from 'vitest'; -import {createTerminalLaunch,detectGraphicalTerminal,launchTerminalCommand,waitForLaunch} from './terminals.js'; +import {createTerminalLaunch,detectGraphicalTerminal,launchTerminalCommand} from './terminals.js'; +import {waitForLaunch} from './launch-command.js'; const roots:string[]=[]; afterEach(()=>{vi.useRealTimers();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true,maxRetries:5,retryDelay:50});}); @@ -36,10 +37,56 @@ describe('shared graphical terminal discovery',()=>{ }); describe('graphical terminal invocation plans',()=>{ + it.each([ + ['darwin','apple-terminal'],['darwin','iterm'],['darwin','wezterm'],['darwin','ghostty'],['darwin','kitty'], + ['linux','/tools/kitty'],['win32','console'],['win32','windows-terminal'] + ] as const)('restores the saved identity through %s:%s despite an unrelated terminal environment', (platform,name) => { + if(platform==='darwin'&&(name==='apple-terminal'||name==='iterm')&&process.platform==='win32')return; + const saved=platform==='win32' + ?{HOME:"C:\\home\\Radio ' \" $() & 单播",RADIO_ATLAS_HOME:"C:\\legacy\\Radio ' \" $() & 单播",XDG_CACHE_HOME:'C:\\cache\\saved',XDG_RUNTIME_DIR:'C:\\run\\saved'} + :{HOME:"/home/Radio ' \" $() & 单播",RADIO_ATLAS_HOME:"/legacy/Radio ' \" $() & 单播",XDG_CACHE_HOME:'/cache/saved',XDG_RUNTIME_DIR:'/run/saved'}; + const probe="process.stdout.write(JSON.stringify({home:process.env.HOME,legacy:process.env.RADIO_ATLAS_HOME,current:process.env.RADIOCLI_HOME??null,cache:process.env.XDG_CACHE_HOME,runtime:process.env.XDG_RUNTIME_DIR,args:process.argv.slice(1)}))"; + const values=['--flag',"literal ' \" $() ; & 单播",'']; + const plan=createTerminalLaunch({platform,env:{...saved,DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:`${platform}:${name}`},nodePath:process.execPath,args:['-e',probe,'--',...values],resolve}); + let command:readonly string[]; + if(plan.command==='/usr/bin/osascript')command=['/bin/sh','-c',plan.args.at(-1)!]; + else if(platform==='win32'){const invocation=nodeInvocation(plan.args,plan.environment);command=[invocation.command,...invocation.args];} + else command=plan.args.slice(plan.args.indexOf(process.execPath)); + const output=execFileSync(command[0]!,command.slice(1),{env:{RADIOCLI_HOME:'/unrelated/current',HOME:'/unrelated/home',RADIO_ATLAS_HOME:'/unrelated/legacy',XDG_CACHE_HOME:'/unrelated/cache',XDG_RUNTIME_DIR:'/unrelated/runtime'},encoding:'utf8'}); + expect(JSON.parse(output)).toEqual({home:saved.HOME,legacy:saved.RADIO_ATLAS_HOME,current:null,cache:saved.XDG_CACHE_HOME,runtime:saved.XDG_RUNTIME_DIR,args:values}); + }); + + it('clears an inherited path override even when a Unix launch has no saved settings', () => { + const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',TERMINAL:'kitty'},nodePath:process.execPath,args:['-e',"process.stdout.write(process.env.RADIOCLI_HOME??'absent')"],resolve}); + expect(execFileSync(plan.args[1]!,plan.args.slice(2),{env:{RADIOCLI_HOME:'/unrelated'},encoding:'utf8'})).toBe('absent'); + }); + + it.skipIf(process.platform==='win32').each([ + ['qterminal','requested'],['x-terminal-emulator','requested'],['x-terminal-emulator','automatic'],['x-terminal-emulator','saved'] + ] as const)('preserves exact argv when %s (%s) reparses only its first -e argument', (name,selection) => { + const root=fixture();const nodeDirectory=join(root,"Radio Tools ' 单播");const nodePath=join(nodeDirectory,'node');mkdirSync(nodeDirectory);symlinkSync(process.execPath,nodePath); + const qterminal=join(root,'qterminal');const alias=join(root,'x-terminal-emulator'); + // QTerminal parses optarg, then appends every remaining argv literally: + // https://github.com/lxqt/qterminal/blob/master/src/main.cpp#L99-L107 + // This fake implements the unquoted whitespace case used by this fixture. + writeFileSync(qterminal,`import{spawnSync}from'node:child_process';const argv=process.argv.slice(2);const start=argv.indexOf('-e');const command=[...argv[start+1].split(/\\s+/),...argv.slice(start+2)];const child=spawnSync(command[0],command.slice(1),{stdio:'inherit'});if(child.error)console.error(child.error.message);process.exit(child.status??1);`); + symlinkSync(qterminal,alias); + const values=['--flag','--option=two words','a b',"apostrophe's",'"double quotes"','$(false); & | < > %PATH%!','单播','']; + const env={DISPLAY:':0',...(selection==='requested'?{TERMINAL:name}:selection==='saved'?{RADIOCLI_ALARM_TERMINAL:`linux:${alias}`}:{})}; + const plan=createTerminalLaunch({platform:'linux',env,nodePath,args:['-e','process.stdout.write(JSON.stringify(process.argv.slice(1)))','--',...values],resolve:command=>command==='/bin/sh'?'/bin/sh':command===alias||command==='x-terminal-emulator'?alias:command==='qterminal'?qterminal:undefined}); + expect(plan.command).toBe(join(root,name)); + const result=spawnSync(process.execPath,[plan.command,...plan.args],{env:{},encoding:'utf8'}); + expect(result.status,result.stderr).toBe(0);expect(JSON.parse(result.stdout)).toEqual(values); + }); it.each([ ['gnome-terminal',['--']],['mate-terminal',['-x']],['xfce4-terminal',['-x']],['terminator',['-x']], - ['wezterm',['start','--always-new-process','--']],['kitty',['-e']],['alacritty',['-e']],['konsole',['-e']],['ghostty',['-e']],['tilix',['-e']],['x-terminal-emulator',['-e']],['xterm',['-e']],['uxterm',['-e']],['foot',['--']] - ] as const)('uses %s argument boundaries without joining application argv', (name,prefix)=>{const nodePath='/Node A/单播/node';const args=['/Radio A/cli.js','agent-ui','a;& quoted "value"'];const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',WAYLAND_DISPLAY:'wayland-0',RADIOCLI_ALARM_TERMINAL:`linux:/tools/${name}`},nodePath,args,resolve});expect(plan).toMatchObject({command:`/tools/${name}`,args:[...prefix,nodePath,...args]});}); + ['wezterm',['start','--always-new-process','--']],['kitty',['-e']],['alacritty',['-e']],['konsole',['-e']],['ghostty',['-e']],['tilix',['-e']],['xterm',['-e']],['uxterm',['-e']],['foot',['--']] + ] as const)('uses %s argument boundaries with an encoded application invocation', (name,prefix)=>{ + const nodePath='/Node A/单播/node';const args=['/Radio A/cli.js','agent-ui','a;& quoted "value"']; + const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',WAYLAND_DISPLAY:'wayland-0',RADIOCLI_ALARM_TERMINAL:`linux:/tools/${name}`},nodePath,args,resolve}); + expect(plan.command).toBe(`/tools/${name}`);expect(plan.args.slice(0,prefix.length+2)).toEqual([...prefix,nodePath,'-e']); + expect(JSON.parse(Buffer.from(plan.args.at(-1)!,'base64url').toString('utf8'))).toEqual({args,environment:{}}); + }); it.skipIf(process.platform==='win32')('uses a literal-safe shell boundary for qterminal command parsing',()=>{const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/tools/qterminal'},nodePath:process.execPath,args:['-e','process.stdout.write(process.argv[1])','--',"Data ' \" ; $() 单播"],resolve});expect(plan.args.slice(0,3)).toEqual(['-e','/bin/sh','-c']);expect(execFileSync('/bin/sh',plan.args.slice(2),{encoding:'utf8'})).toBe("Data ' \" ; $() 单播");}); it('preserves a special data home and every argument through the Windows Node bootstrap',()=>{ const home='C:\\Data %PATH%! & "quote" \' 单播';const values=['space value','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; @@ -167,11 +214,18 @@ describe('terminal launcher acceptance',()=>{ child.emit('spawn');await vi.advanceTimersByTimeAsync(250);child.emit('close',7); expect(await result).toMatchObject({message:expect.stringMatching(/launcher.*exit.*7/i)});expect(child.kill).not.toHaveBeenCalled(); }); - it('bounds a transient bootstrap and kills its owned process on the completion deadline',async()=>{ + it('bounds cleanup when a transient bootstrap never confirms its exit',async()=>{ vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); const result=waitForLaunch(child,{waitForExit:true}).then(()=>undefined,error=>error as Error); child.emit('spawn');await vi.advanceTimersByTimeAsync(9_999);expect(child.kill).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - expect(await result).toMatchObject({message:expect.stringMatching(/bootstrap.*did not complete/i)});expect(child.kill).toHaveBeenCalledOnce();expect(child.unref).not.toHaveBeenCalled(); + expect(child.kill).toHaveBeenCalledWith('SIGTERM');expect(child.unref).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_000); + expect(await result).toMatchObject({message:expect.stringMatching(/bootstrap.*did not complete/i)});expect(child.kill).toHaveBeenCalledTimes(2);expect(child.kill).toHaveBeenLastCalledWith('SIGKILL');expect(child.unref).toHaveBeenCalledOnce(); + }); + it('absorbs late native errors after accepting a detached application',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn(); + const result=waitForLaunch(child);child.emit('spawn');await vi.advanceTimersByTimeAsync(100);await result; + expect(()=>{child.emit('error',new Error('late native error'));child.emit('error',new Error('another native error'));}).not.toThrow(); }); }); diff --git a/src/platform/terminals.ts b/src/platform/terminals.ts index e1df080..a68fa01 100644 --- a/src/platform/terminals.ts +++ b/src/platform/terminals.ts @@ -3,7 +3,7 @@ import {posix,win32} from 'node:path'; import {resolveCommandDetails} from './executables.js'; import {identifyPlatform,nativeAdapters} from './runtime.js'; import {powershellCommand} from './shell.js'; -import {launchEnvironment, nodeLaunchCommand} from './launch-command.js'; +import {launchEnvironment, nodeLaunchCommand, waitForLaunch} from './launch-command.js'; export type TerminalResolver=(command:string)=>string|undefined; type TerminalSpawn=(command:string,args:readonly string[],options?:SpawnOptions)=>ChildProcess; @@ -15,7 +15,7 @@ type UnixTerminal={name:string;args:string[];display?:'x11'|'wayland';shell?:boo const unixTerminals:UnixTerminal[]=[ {name:'ghostty',args:['-e']},{name:'wezterm',args:['start','--always-new-process','--']}, {name:'kitty',args:['-e']},{name:'gnome-terminal',args:['--']},{name:'konsole',args:['-e']}, - {name:'xfce4-terminal',args:['-x']},{name:'x-terminal-emulator',args:['-e']}, + {name:'xfce4-terminal',args:['-x']},{name:'x-terminal-emulator',args:['-e'],shell:true}, {name:'alacritty',args:['-e']},{name:'foot',args:['--'],display:'wayland'}, {name:'mate-terminal',args:['-x']},{name:'qterminal',args:['-e'],shell:true}, {name:'terminator',args:['-x']},{name:'tilix',args:['-e']}, @@ -56,17 +56,16 @@ export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ const platform=options.platform??process.platform;const env=options.env??process.env;const resolve=options.resolve??resolver(platform,env); const terminal=detectGraphicalTerminal(platform,env,resolve);const values=[options.nodePath,...options.args]; if(values.some(value=>value.includes('\0')))throw new Error('Terminal command values cannot contain NUL bytes.'); - const environment=launchEnvironment(env); + const environment=launchEnvironment(env,{platform}); + const direct=nodeLaunchCommand(options.nodePath,options.args,environment); if(terminal.endsWith(':unsupported')){ if(nativeAdapters(identifyPlatform({platform,env})).terminal==='unix'&&!hasDisplay(env))throw new Error('No graphical desktop session is available: DISPLAY and WAYLAND_DISPLAY are unset. Open radiocli manually for controls.'); throw new Error('No supported installed graphical terminal was found. Open radiocli manually for controls.'); } if(terminal==='darwin:apple-terminal'||terminal==='darwin:iterm'){ - const assignments=Object.entries(environment).map(([key,value])=>`${key}=${shellQuote(value)}`).join(' '); - const command=`${assignments?`${assignments} `:''}${values.map(shellQuote).join(' ')}${options.closeOnExit?'; exit':''}`; + const command=`${direct.map(shellQuote).join(' ')}${options.closeOnExit?'; exit':''}`; return{terminal,command:'/usr/bin/osascript',args:terminal==='darwin:apple-terminal'?appleTerminalScript(command):iTermScript(command)}; } - const direct=Object.keys(environment).length?nodeLaunchCommand(options.nodePath,options.args,environment):values; if(terminal==='darwin:wezterm')return{terminal,command:'/usr/bin/open',args:['-na','WezTerm','--args','start','--always-new-process','--',...direct]}; if(terminal==='darwin:ghostty')return{terminal,command:'/usr/bin/open',args:['-na','Ghostty','--args','-e',...direct]}; if(terminal==='darwin:kitty')return{terminal,command:'/usr/bin/open',args:['-na','kitty','--args','--detach',...direct]}; @@ -76,8 +75,7 @@ export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ if(!powershell)throw new Error('PowerShell is unavailable; a RadioCLI terminal cannot be requested.'); // PowerShell 5 does not preserve arbitrary native argv quoting. Only a // fixed quote-free Node program and encoded JSON cross that boundary. - const command=nodeLaunchCommand(options.nodePath,options.args,environment); - const args=powershellCommand(command,{}, {keepOpen:!options.closeOnExit}); + const args=powershellCommand(direct,{}, {keepOpen:!options.closeOnExit}); if(terminal==='win32:console')return{terminal,command:powershell,...newWindowsConsole(powershell,args)}; const wt=resolve('wt.exe')??(env.LOCALAPPDATA?resolve(win32.join(env.LOCALAPPDATA,'Microsoft','WindowsApps','wt.exe')):undefined); if(!wt)throw new Error('Windows Terminal is unavailable; the saved terminal cannot be requested.'); @@ -85,9 +83,10 @@ export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ } const executable=terminal.slice(terminal.indexOf(':')+1);const spec=unixTerminals.find(item=>item.name===posix.basename(executable)); if(!spec)throw new Error('The saved graphical terminal is unsupported.'); - // qterminal reparses its first -e argument as a command string. A fixed - // POSIX shell keeps a Node path containing quotes/spaces out of that parser. - if(spec.shell){const sh=resolve('/bin/sh');if(!sh)throw new Error('The POSIX shell required by qterminal is unavailable.');return{terminal,command:executable,args:[...spec.args,sh,'-c',direct.map(shellQuote).join(' ')]};} + // QTerminal reparses only its first -e argument, then appends argv literally. + // The alternatives alias can select QTerminal too, so both use the fixed + // POSIX shell boundary. This also preserves argv for ordinary -e terminals. + if(spec.shell){const sh=resolve('/bin/sh');if(!sh)throw new Error('The POSIX shell required by this terminal is unavailable.');return{terminal,command:executable,args:[...spec.args,sh,'-c',direct.map(shellQuote).join(' ')]};} return{terminal,command:executable,args:[...spec.args,...direct]}; } @@ -103,27 +102,6 @@ export async function launchTerminalCommand(options:TerminalCommand):Promise{ - return new Promise((resolve,reject)=>{ - let settled=false;let acceptedTimer:NodeJS.Timeout|undefined; - const finish=(error?:Error)=>{if(settled)return;settled=true;clearTimeout(startupTimer);if(acceptedTimer)clearTimeout(acceptedTimer);if(error)reject(error);else resolve();}; - const startupTimer=setTimeout(()=>finish(new Error('Terminal launcher did not report process startup.')),3_000); - child.once('error',error=>finish(error)); - child.once('close',(code,signal)=>finish(code===0?undefined:new Error(`Terminal launcher exited with ${code??signal??'unknown status'}.`))); - child.once('spawn',()=>{ - if(options.waitForExit){ - clearTimeout(startupTimer); - acceptedTimer=setTimeout(()=>{ - finish(new Error('Terminal bootstrap did not complete.')); - try{child.kill();}catch{/* Preserve the timeout if cleanup also fails. */} - },10_000); - }else{ - child.unref();acceptedTimer=setTimeout(()=>finish(),100); - } - }); - }); -} - function resolver(platform:NodeJS.Platform,env:NodeJS.ProcessEnv):TerminalResolver{return command=>resolveCommandDetails(command,{platform,env}).path??undefined;} function hasDisplay(env:NodeJS.ProcessEnv){return Boolean(env.DISPLAY?.trim()||env.WAYLAND_DISPLAY?.trim());} function resolveUnixTerminal(input:string,env:NodeJS.ProcessEnv,resolve:TerminalResolver):string|undefined{ diff --git a/src/player/airplay-discovery.test.ts b/src/player/airplay-discovery.test.ts index 0832fbb..e533728 100644 --- a/src/player/airplay-discovery.test.ts +++ b/src/player/airplay-discovery.test.ts @@ -1,6 +1,6 @@ import * as childProcess from 'node:child_process'; import {describe, expect, it, vi} from 'vitest'; -import * as commands from './command.js'; +import * as commands from '../platform/executables.js'; import {discoverAirPlayDevices, parseRaopBrowseOutput, parseRaopLookupOutput} from './airplay-discovery.js'; vi.mock('node:child_process', () => ({spawn: vi.fn(() => {throw new Error('discovery must remain offline');})})); diff --git a/src/player/airplay-discovery.ts b/src/player/airplay-discovery.ts index fecae28..9d14005 100644 --- a/src/player/airplay-discovery.ts +++ b/src/player/airplay-discovery.ts @@ -2,7 +2,7 @@ import {spawn} from 'node:child_process'; import {lookup} from 'node:dns/promises'; import {networkInterfaces} from 'node:os'; import type {AirPlayDevice} from '../types.js'; -import {commandExists} from './command.js'; +import {commandExists} from '../platform/executables.js'; import {networkPolicy} from '../platform/network.js'; import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; diff --git a/src/player/backend-install.test.ts b/src/player/backend-install.test.ts index 14c7aef..79c2b1d 100644 --- a/src/player/backend-install.test.ts +++ b/src/player/backend-install.test.ts @@ -1,31 +1,34 @@ import {describe, expect, it} from 'vitest'; -import {detectPlaybackBackends, mpvInstallCommand, playbackBackendCapabilities, playbackBackendLabel, playbackBackendStatusLines} from './backend-install.js'; +import {detectPlaybackBackends, playbackBackendInstallHint, playbackBackendCapabilities, playbackBackendLabel, playbackBackendStatusLines} from './backend-install.js'; +import {mpvInstallCommand} from '../platform/packages.js'; + +const hintOptions = {hasCommand: () => true, getUid: () => 1000}; describe('playback backend install guidance', () => { it('uses Homebrew for macOS mpv guidance', () => { - expect(mpvInstallCommand('darwin')).toBe('brew install mpv'); + expect(mpvInstallCommand('darwin', '', {}, hintOptions)).toBe('brew install mpv'); }); it('uses apt for Debian-like Linux mpv guidance', () => { - expect(mpvInstallCommand('linux', 'ID=ubuntu\nID_LIKE=debian\n')).toBe('sudo apt install mpv'); + expect(mpvInstallCommand('linux', 'ID=ubuntu\nID_LIKE=debian\n', {}, hintOptions)).toBe('sudo apt-get install -y mpv'); }); it('uses dnf for Fedora-like Linux mpv guidance', () => { - expect(mpvInstallCommand('linux', 'ID=fedora\n')).toBe('sudo dnf install mpv'); + expect(mpvInstallCommand('linux', 'ID=fedora\n', {}, hintOptions)).toBe('sudo dnf install -y mpv'); }); it('uses winget for native Windows mpv guidance', () => { - expect(mpvInstallCommand('win32')).toBe('winget install --id shinchiro.mpv -e'); + expect(mpvInstallCommand('win32', '', {}, hintOptions)).toBe('winget install --id shinchiro.mpv -e --accept-package-agreements --accept-source-agreements'); }); it.each([ - ['freebsd', 'pkg install -y mpv'], ['openbsd', 'doas pkg_add -I mpv'], ['netbsd', 'pkgin -y install mpv'] + ['freebsd', 'pkg install -y mpv'], ['openbsd', 'sudo pkg_add -I mpv'], ['netbsd', 'pkgin -y install mpv'] ] as const)('describes the explicit %s mpv installation path', (platform, command) => { - expect(mpvInstallCommand(platform)).toContain(command); + expect(mpvInstallCommand(platform, '', {}, hintOptions)).toContain(command); }); it('reports npm and native playback responsibilities separately', () => { - expect(playbackBackendStatusLines([], 'darwin')).toEqual([ + expect(playbackBackendStatusLines([], 'darwin', '', hintOptions)).toEqual([ 'playback=missing', 'playback_backend=none', 'controls=missing', @@ -38,20 +41,29 @@ describe('playback backend install guidance', () => { }); it('reports native Windows playback setup with winget commands', () => { - expect(playbackBackendStatusLines([], 'win32')).toEqual([ + expect(playbackBackendStatusLines([], 'win32', '', hintOptions)).toEqual([ 'playback=missing', 'playback_backend=none', 'controls=missing', 'controls_hint=install mpv for playback and controls', 'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg', 'guided_setup=radiocli setup', - 'install_mpv=winget install --id shinchiro.mpv -e', - 'optional_ffplay=winget install --id Gyan.FFmpeg -e' + 'install_mpv=winget install --id shinchiro.mpv -e --accept-package-agreements --accept-source-agreements', + 'optional_ffplay=winget install --id Gyan.FFmpeg -e --accept-package-agreements --accept-source-agreements' ]); }); + it.each([{backends: []}, {backends: ['ffplay']}, {backends: ['vlc']}, {backends: ['mpv']}])('keeps diagnostics on the available Scoop route for backends $backends', ({backends}) => { + const options = {hasCommand: (command: string) => command === 'scoop', getUid: () => undefined}; + const lines = playbackBackendStatusLines(backends, 'win32', '', options); + expect(lines).toContain('install_mpv=scoop install mpv (first run scoop bucket add extras)'); + expect(lines).toContain('optional_ffplay=scoop install ffmpeg'); + expect(lines.join(' ')).not.toContain('winget'); + expect(playbackBackendInstallHint('win32', '', options)).toContain('scoop install mpv'); + }); + it('prefers mpv when available', () => { - expect(playbackBackendStatusLines(['ffplay', 'mpv'], 'darwin')).toEqual([ + expect(playbackBackendStatusLines(['ffplay', 'mpv'], 'darwin', '', hintOptions)).toEqual([ 'playback=ready', 'playback_backend=mpv', 'controls=full', @@ -71,7 +83,7 @@ describe('playback backend install guidance', () => { }); it('reports AirPlay as limited output-only playback when it is the only backend', () => { - expect(playbackBackendStatusLines(['airplay'], 'darwin')).toEqual([ + expect(playbackBackendStatusLines(['airplay'], 'darwin', '', hintOptions)).toEqual([ 'playback=airplay-only', 'playback_backend=airplay', 'controls=airplay-limited', @@ -91,7 +103,7 @@ describe('playback backend install guidance', () => { }); it('reports ffplay as fallback playback with limited controls', () => { - expect(playbackBackendStatusLines(['ffplay'], 'darwin')).toEqual([ + expect(playbackBackendStatusLines(['ffplay'], 'darwin', '', hintOptions)).toEqual([ 'playback=fallback-only', 'playback_backend=ffplay', 'controls=limited', diff --git a/src/player/backend-install.ts b/src/player/backend-install.ts index bce355d..26ceeb7 100644 --- a/src/player/backend-install.ts +++ b/src/player/backend-install.ts @@ -1,7 +1,6 @@ -import {ffplayInstallCommand, mpvInstallCommand, readLinuxOsRelease} from '../platform/packages.js'; -export {mpvInstallCommand}; -import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; -import {commandExists} from './command.js'; +import {ffplayInstallCommand, mpvInstallCommand, type PackageHintOptions} from '../platform/packages.js'; +import {identifyPlatform, nativeAdapters, readLinuxOsRelease} from '../platform/runtime.js'; +import {commandExists} from '../platform/executables.js'; import {airPlaySenderHealth} from './airplay-sender-health.js'; type PlaybackBackendDetectionOptions = { @@ -104,22 +103,24 @@ export function playbackBackendLabel(backend: string | null | undefined): string export function playbackBackendInstallHint( platform: NodeJS.Platform = process.platform, - osRelease = readLinuxOsRelease() + osRelease = readLinuxOsRelease(platform), + options: PackageHintOptions = {} ): string { - return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease)}), then run radiocli doctor.`; + return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease, process.env, options)}), then run radiocli doctor.`; } export function playbackBackendStatusLines( backends: string[], platform: NodeJS.Platform = process.platform, - osRelease = readLinuxOsRelease() + osRelease = readLinuxOsRelease(platform), + options: PackageHintOptions = {} ): string[] { const backendSet = new Set(backends); const lines = [ 'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg', 'guided_setup=radiocli setup', - `install_mpv=${mpvInstallCommand(platform, osRelease)}`, - `optional_ffplay=${ffplayInstallCommand(platform, osRelease)}` + `install_mpv=${mpvInstallCommand(platform, osRelease, process.env, options)}`, + `optional_ffplay=${ffplayInstallCommand(platform, osRelease, process.env, options)}` ]; if (backendSet.has('mpv')) { diff --git a/src/player/command-diagnostics.ts b/src/player/command-diagnostics.ts index 676f227..97b9f1d 100644 --- a/src/player/command-diagnostics.ts +++ b/src/player/command-diagnostics.ts @@ -1,5 +1,5 @@ import {spawnSync} from 'node:child_process'; -import {resolveCommandDetails, type CommandDiscovery} from './command.js'; +import {resolveCommandDetails, type CommandDiscovery} from '../platform/executables.js'; export type CommandDiagnostic = { path: string | null; diff --git a/src/player/command.ts b/src/player/command.ts deleted file mode 100644 index f45dc72..0000000 --- a/src/player/command.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Keep the existing playback import path stable; executable discovery is shared -// by every platform integration. -export {clearCommandCache, commandExists, resolveCommand, resolveCommandDetails} from '../platform/executables.js'; -export type {CommandDiscovery} from '../platform/executables.js'; diff --git a/src/player/player-controller.test.ts b/src/player/player-controller.test.ts index 6c43b12..b8d19a8 100644 --- a/src/player/player-controller.test.ts +++ b/src/player/player-controller.test.ts @@ -3,16 +3,16 @@ import {spawn} from 'node:child_process'; import {existsSync, unlinkSync} from 'node:fs'; import {createServer, type Server} from 'node:net'; import {afterEach, describe, expect, it, vi} from 'vitest'; -import {commandExists} from './command.js'; +import {commandExists} from '../platform/executables.js'; import {discoverAirPlayDevices} from './airplay-discovery.js'; -import {createMpvIpcPath, extractMpvTitle, isPlaybackOutputError, PlayerController} from './player-controller.js'; +import {extractMpvTitle, isPlaybackOutputError, PlayerController} from './player-controller.js'; import type {AirPlayDevice, AppSettings, Station} from '../types.js'; vi.mock('node:child_process', () => ({ spawn: vi.fn() })); -vi.mock('./command.js', () => ({ +vi.mock('../platform/executables.js', () => ({ commandExists: vi.fn(), resolveCommand: vi.fn() })); @@ -64,16 +64,6 @@ describe('extractMpvTitle', () => { }); }); -describe('createMpvIpcPath', () => { - it('uses a Unix socket path on POSIX platforms', () => { - expect(createMpvIpcPath('linux', 123, 456)).toMatch(/radiocli-123-456\.sock$/); - }); - - it('uses a Windows named pipe path on native Windows', () => { - expect(createMpvIpcPath('win32', 123, 456)).toBe('\\\\.\\pipe\\radiocli-123-456'); - }); -}); - describe('PlayerController lifecycle', () => { it('throws before spawning when no playback backend is available', async () => { commandExistsMock.mockReturnValue(false); diff --git a/src/player/player-controller.ts b/src/player/player-controller.ts index d759197..59e58f2 100644 --- a/src/player/player-controller.ts +++ b/src/player/player-controller.ts @@ -4,7 +4,7 @@ import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {AirPlayDevice, AppSettings, IcyNowPlaying, PlaybackDiagnostics, PlaybackState, Station} from '../types.js'; import {detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage} from './backend-install.js'; -import {resolveCommand} from './command.js'; +import {resolveCommand} from '../platform/executables.js'; import {discoverAirPlayDevices} from './airplay-discovery.js'; import {airPlaySenderHealth} from './airplay-sender-health.js'; import {encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage, type AirPlayWorkerCommand, type AirPlayWorkerEvent} from './airplay-worker-protocol.js'; @@ -443,7 +443,7 @@ export class PlayerController { } private playWithMpv(url: string, initialTitle: string): void { - this.ipcPath = createMpvIpcPath(); + this.ipcPath = mpvIpcPath(); this.mpvIpcClient = new MpvIpcClient(this.ipcPath); this.mpvSessionId += 1; this.confirmedMpvVolume = clampVolume(this.getSettings().volume); @@ -1040,14 +1040,6 @@ export class PlayerController { } } -export function createMpvIpcPath( - platform: NodeJS.Platform = process.platform, - pid = process.pid, - timestamp = Date.now() -): string { - return mpvIpcPath({platform, pid, timestamp}); -} - function airPlayWorkerPath(): string { const currentPath = fileURLToPath(import.meta.url); const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js'; diff --git a/src/setup.test.ts b/src/setup.test.ts index 6e0c233..8ca42f6 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -1,10 +1,56 @@ import {PassThrough} from 'node:stream'; import {describe, expect, it, vi} from 'vitest'; -import {createSetupPlan, detectPackageManager, parseSetupArgs, runSetup, type SetupComponent} from './setup.js'; +import {createSetupPlan, parseSetupArgs, runSetup, type SetupComponent} from './setup.js'; +import {detectPackageManager, ffplayInstallCommand, mpvInstallCommand, type SetupPackageManager} from './platform/packages.js'; const nothingInstalled: Record = {mpv: false, ffmpeg: false, vlc: false}; describe('RadioCLI setup', () => { + it.each([ + ['darwin', '', 'brew', 'brew', {}], + ['win32', '', 'winget', 'winget', {}], + ['win32', '', 'scoop', 'scoop', {}], + ['win32', '', 'choco', 'choco', {}], + ['linux', 'ID=ubuntu\nID_LIKE=debian', 'apt', 'apt', {}], + ['linux', 'ID=fedora', 'dnf', 'dnf', {}], + ['linux', 'ID=arch', 'pacman', 'pacman', {}], + ['linux', 'ID=alpine', 'apk', 'apk', {}], + ['linux', 'ID=opensuse', 'zypper', 'zypper', {}], + ['linux', 'ID=unknown', 'dnf', 'dnf', {}], + ['freebsd', '', 'pkg', 'pkg', {}], + ['openbsd', '', 'pkg_add', 'pkg_add', {}], + ['netbsd', '', 'pkgin', 'pkgin', {}], + ['sunos', '', 'pkgin', 'pkgin', {}], + ['haiku', '', 'pkgman', 'pkgman', {}], + ['android', '', 'termux-pkg', 'pkg', {TERMUX_VERSION: '0.118.3'}], + ['linux', 'ID=debian', 'termux-pkg', 'pkg', {TERMUX_VERSION: '0.118.3'}] + ] satisfies [NodeJS.Platform, string, SetupPackageManager, string, NodeJS.ProcessEnv][])('uses the selected %s/%s/%s setup recipes in playback hints', (platform, osRelease, manager, executable, env) => { + const hasCommand = (command: string): boolean => [executable, 'doas'].includes(command); + expect(detectPackageManager(platform, osRelease, hasCommand, env)).toBe(manager); + const plan = createSetupPlan({platform, osRelease, env, packageManager: manager, installed: nothingInstalled, + selected: ['mpv', 'ffmpeg'], elevation: 'doas'}); + const options = {hasCommand, getUid: () => 1000}; + const hints = {mpv: mpvInstallCommand(platform, osRelease, env, options), ffmpeg: ffplayInstallCommand(platform, osRelease, env, options)}; + for (const command of plan.commands) { + if (command.component === 'mpv' || command.component === 'ffmpeg') expect(hints[command.component]).toContain(command.display); + } + if (manager === 'termux-pkg') expect(hints.ffmpeg).toContain('x11-repo'); + if (manager === 'pkgin') expect(hints.ffmpeg).toContain('matching pkgsrc'); + if (manager === 'pkg') expect(hints.ffmpeg).toContain('SDL'); + if (manager === 'scoop') expect(hints.mpv).toContain('scoop bucket add extras'); + }); + + it('shows every Scoop prerequisite in the reviewed dry-run plan', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + await runSetup({platform: 'win32', env: {}, args: ['--dry-run', '--only=mpv'], input: new PassThrough(), output, + hasCommand: command => command === 'scoop'}); + expect(text).toContain('scoop bucket add extras'); + expect(text).toContain('scoop install mpv'); + expect(text).not.toContain('winget'); + }); + it('prints an ASCII-safe setup plan in a dumb terminal without control sequences', async () => { vi.stubEnv('TERM', 'dumb'); const output = Object.assign(new PassThrough(), {isTTY: true}); @@ -172,6 +218,17 @@ describe('RadioCLI setup', () => { expect(execute).not.toHaveBeenCalled(); }); + it('labels the root requirement when previewing commands without an available privilege helper', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + await runSetup({platform: 'freebsd', env: {}, args: ['--dry-run', '--only=mpv'], getUid: () => 1000, + input: new PassThrough(), output, hasCommand: command => command === 'pkg'}); + expect(text).toContain('pkg install -y mpv'); + expect(text).toContain('Run package commands as root; sudo/doas unavailable.'); + expect(text).not.toContain('sudo pkg'); + }); + it('shows a manual pkgsrc FFmpeg plan without invoking a guessed package', async () => { const output = new PassThrough(); let text = ''; diff --git a/src/setup.ts b/src/setup.ts index 5a4e9a3..f86574d 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,17 +1,16 @@ -import {componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageManagerNeedsRoot, packageManagerNotes, packageManagerProgram, packageManagers, platformLabel, readLinuxOsRelease, type SetupCommand, type SetupComponent, type SetupPackageManager} from './platform/packages.js'; -export {detectPackageManager}; +import {componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageInstallPrerequisites, packageManagerElevation, packageManagerNeedsRoot, packageManagerNotes, packageManagerProgram, packageManagers, platformLabel, type SetupCommand, type SetupComponent, type SetupPackageManager} from './platform/packages.js'; export type {SetupComponent}; import {spawn, type SpawnOptions} from 'node:child_process'; import {realpathSync} from 'node:fs'; import {createInterface} from 'node:readline/promises'; import type {Readable, Writable} from 'node:stream'; -import {clearCommandCache, commandExists, resolveCommand} from './player/command.js'; +import {clearCommandCache, commandExists, resolveCommand} from './platform/executables.js'; import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backend-install.js'; import {configureMcpIntegrations} from './agent/mcp-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; import {resolveTerminalCapabilities} from './platform/terminal.js'; -import {identifyPlatform, type PlatformProfile} from './platform/runtime.js'; +import {identifyPlatform, readLinuxOsRelease, type PlatformProfile} from './platform/runtime.js'; export type SetupPlan = { platform: NodeJS.Platform; @@ -80,9 +79,12 @@ export async function runSetup(options: SetupOptions = {}): Promise { } const isRoot = (options.getUid ?? process.getuid)?.() === 0; - const elevation = isRoot ? null : hasCommand('sudo') ? 'sudo' : hasCommand('doas') ? 'doas' : 'sudo'; + const elevation = packageManagerElevation(hasCommand, isRoot); const plan = createSetupPlan({platform, osRelease, env, packageManager, installed, selected, elevation}); printPlan(plan, output); + if (packageManager && packageManagerNeedsRoot(packageManager) && !isRoot && !elevation) { + output.write(' Note: Run package commands as root; sudo/doas unavailable.\n'); + } for (const note of packageManagerNotes(packageManager, host)) output.write(` Note: ${note}\n`); const missing = plan.selected.filter(component => !plan.installed[component]); @@ -112,7 +114,7 @@ export async function runSetup(options: SetupOptions = {}): Promise { const manual = missing.filter(component => !plan.commands.some(command => command.component === component)); if (manual.length) throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`); if (plan.packageManager === 'termux-pkg' && isRoot) throw new Error('Run setup as the normal Termux app user. Termux pkg refuses root execution.'); - if (packageManagerNeedsRoot(plan.packageManager) && !isRoot && !hasCommand(elevation!)) { + if (packageManagerNeedsRoot(plan.packageManager) && !isRoot && !elevation) { throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.'); } @@ -169,15 +171,7 @@ export function createSetupPlan({ const uniqueSelected = components.filter(component => selected.includes(component)); const missing = uniqueSelected.filter(component => !installed[component]); const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, {elevation})).filter((command): command is SetupCommand => command !== null) : []; - if (packageManager === 'scoop' && missing.some(component => component === 'mpv' || component === 'vlc')) { - commands.unshift({ - component: null, - label: 'Scoop extras bucket', - program: 'scoop', - args: ['bucket', 'add', 'extras'], - display: 'scoop bucket add extras' - }); - } + if (packageManager) commands.unshift(...packageInstallPrerequisites(packageManager, missing)); return { platform, @@ -325,6 +319,9 @@ async function promptYesNo(input: Readable, output: Writable, question: string, function printPlan(plan: SetupPlan, output: Writable): void { output.write('\nInstallation plan\n'); + for (const command of plan.commands.filter(command => command.component === null)) { + output.write(` ${pendingMark(output)} ${command.label} ${separator(output)} ${command.display}\n`); + } for (const component of plan.selected) { if (plan.installed[component]) output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`); else { diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index 56ececb..e08e8e8 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -576,6 +576,81 @@ describe('alarm TUI integration', () => { app.stdin.write('r'); await settle(); expect(calls.syncAll.mock.calls.at(-1)?.[0]).toEqual([expect.objectContaining({id: alarm.id, enabled: false})]); app.unmount(); }); + it('does not touch native jobs when deletion cannot first save a disabled alarm', async () => { + const {store, service, calls} = fixture(); + const alarm = addAlarm(store); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + const original = readFileSync(store.filePath, 'utf8'); + const disable = vi.spyOn(store, 'toggleAlarm').mockImplementation(() => { throw new Error('Library is read-only'); }); + calls.remove.mockRejectedValue(new Error('Native cleanup denied')); + + app.stdin.write('b'); await settle(); + app.stdin.write('8'); await settle(); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + + expect(calls.remove).not.toHaveBeenCalled(); + expect(store.getAlarm(alarm.id)?.enabled).toBe(true); + expect(readFileSync(store.filePath, 'utf8')).toBe(original); + expect(player.state().state).toBe('playing'); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(app.lastFrame()).not.toContain('kept disabled'); + + // A failed attempt releases the row and permits an explicit retry. + disable.mockRestore(); calls.remove.mockResolvedValue(undefined); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + expect(store.getAlarm(alarm.id)).toBeUndefined(); + expect(calls.remove).toHaveBeenCalledOnce(); + expect(player.state().state).toBe('playing'); + app.unmount(); + }); + + it('survives native cleanup failure after storage becomes unavailable without a recovery write', async () => { + const {store, service, calls} = fixture(); + const alarm = addAlarm(store); + const player = simulatedPlayer(); + const app = render(); + await settle(); await tuneLibrary(app); + const disable = vi.spyOn(store, 'toggleAlarm'); + calls.remove.mockImplementationOnce(async () => { + disable.mockImplementation(() => { throw new Error('Storage disappeared during cleanup'); }); + throw new Error('Native cleanup denied'); + }); + app.stdin.write('b'); await settle(); + app.stdin.write('8'); await settle(); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + + expect(disable).toHaveBeenCalledOnce(); + expect(store.getAlarm(alarm.id)?.enabled).toBe(false); + expect(new JsonLibraryStore(store.filePath).getAlarm(alarm.id)?.enabled).toBe(false); + expect(app.lastFrame()).toContain('Native cleanup denied'); + expect(app.lastFrame()).toContain('kept disabled'); + expect(player.state().state).toBe('playing'); + app.stdin.write('b'); await settle(); + expect(app.lastFrame()).toContain('Overview'); + app.unmount(); + }); + + it('retains a disabled definition and accurate diagnostics when its final deletion write fails', async () => { + const {store, service, calls} = fixture(); + const alarm = addAlarm(store); + const app = render(); + await settle(); + const disable = vi.spyOn(store, 'toggleAlarm'); + vi.spyOn(store, 'removeAlarm').mockImplementation(() => { throw new Error('Disk full'); }); + app.stdin.write('8'); await settle(); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + + expect(calls.remove).toHaveBeenCalledWith(expect.objectContaining({id: alarm.id, enabled: false})); + expect(disable).toHaveBeenCalledOnce(); + expect(new JsonLibraryStore(store.filePath).getAlarm(alarm.id)?.enabled).toBe(false); + expect(app.lastFrame()).toContain('Native cleanup completed'); + expect(app.lastFrame()).toContain('Disk full'); + app.unmount(); + }); + it('preserves rapid disable-enable order through a deferred native sync', async () => { const {store, service, calls} = fixture(); addAlarm(store); let finish!: (value: Date) => void; calls.sync.mockImplementationOnce(async () => new Promise(resolve => { finish = resolve; })); diff --git a/src/ui/display-context.test.ts b/src/ui/display-context.test.ts index f5ecbfb..652d7e6 100644 --- a/src/ui/display-context.test.ts +++ b/src/ui/display-context.test.ts @@ -1,12 +1,12 @@ import {describe, expect, it} from 'vitest'; -import {noColorRequested, panelBorderStyle, resolveDisplayMode} from './display-context.js'; +import {panelBorderStyle, resolveDisplayMode} from './display-context.js'; import {appBackground, panelBackground} from './theme.js'; describe('display mode', () => { it('honors the NO_COLOR convention only for non-empty values', () => { - expect(noColorRequested({NO_COLOR: '1'})).toBe(true); - expect(noColorRequested({NO_COLOR: ''})).toBe(false); - expect(noColorRequested({})).toBe(false); + expect(resolveDisplayMode({}, {NO_COLOR: '1'})).toMatchObject({colorLevel: 0, app: undefined, panel: undefined}); + expect(resolveDisplayMode({}, {NO_COLOR: ''})).toMatchObject({colorLevel: 3, app: appBackground, panel: panelBackground}); + expect(resolveDisplayMode({}, {})).toMatchObject({colorLevel: 3, app: appBackground, panel: panelBackground}); }); it('keeps opaque panel fills by default', () => { diff --git a/src/ui/display-context.ts b/src/ui/display-context.ts index 996ab5a..5a4adcb 100644 --- a/src/ui/display-context.ts +++ b/src/ui/display-context.ts @@ -23,12 +23,6 @@ type DisplaySettings = { const opaqueBackgrounds: DisplayBackgrounds = {app: appBackground, panel: panelBackground}; const transparentBackgrounds: DisplayBackgrounds = {app: undefined, panel: undefined}; -// Respect the NO_COLOR convention (https://no-color.org): any non-empty value -// opts the user out of our forced colors, including the dark panel fills. -export function noColorRequested(env: NodeJS.ProcessEnv = process.env): boolean { - return typeof env.NO_COLOR === 'string' && env.NO_COLOR.length > 0; -} - // Sixteen-color palettes cannot safely reproduce our dark fills. Let those // terminals keep their own background while Ink quantizes the foregrounds. export function resolveDisplayMode(settings: DisplaySettings, env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): DisplayMode { diff --git a/src/ui/system-actions.test.ts b/src/ui/system-actions.test.ts index 4b9263b..fcba2de 100644 --- a/src/ui/system-actions.test.ts +++ b/src/ui/system-actions.test.ts @@ -1,24 +1,13 @@ import {EventEmitter} from 'node:events'; import {PassThrough} from 'node:stream'; +import {once} from 'node:events'; import type {ChildProcess} from 'node:child_process'; import {describe, expect, it, vi} from 'vitest'; -import {clipboardCommands, copyToClipboard, openExternal, openExternalCommand} from './system-actions.js'; +import {copyToClipboard, openExternal} from './system-actions.js'; vi.mock('node:child_process', () => ({spawn: vi.fn(() => { throw new Error('Desktop tests must inject their launcher.'); })})); describe('system actions', () => { - it('opens URLs with the platform default handler', () => { - expect(openExternalCommand('darwin')).toEqual({command: 'open', args: []}); - expect(openExternalCommand('win32')).toEqual({command: 'explorer', args: []}); - expect(openExternalCommand('linux')).toEqual({command: 'xdg-open', args: []}); - }); - - it('lists platform clipboard tools in priority order', () => { - expect(clipboardCommands('darwin').map(entry => entry.command)).toEqual(['pbcopy']); - expect(clipboardCommands('win32').map(entry => entry.command)).toEqual(['clip']); - expect(clipboardCommands('linux').map(entry => entry.command)).toEqual(['wl-copy', 'xclip', 'xsel']); - }); - it('does not report an asynchronous browser launch failure as success', async () => { const launch = fakeLauncher('error'); await expect(openExternal('https://example.test/?x=1&y=2', 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); @@ -54,7 +43,54 @@ describe('system actions', () => { await expect(copyToClipboard('text', 'darwin', {spawn: broken, env: {}, resolve: name => name})).resolves.toBe(false); const hung = fakeLauncher('hang'); await expect(copyToClipboard('text', 'darwin', {spawn: hung, env: {}, resolve: name => name, timeoutMs: 10})).resolves.toBe(false); - expect(hung.kills).toHaveBeenCalledOnce(); + expect(hung.kills.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]); + }); + + it('terminates an actual helper that ignores graceful shutdown before reporting timeout', async () => { + const {spawn} = await vi.importActual('node:child_process'); + let child: ChildProcess | undefined; + let closed = false; + try { + child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});process.stdin.resume();setInterval(()=>{},1000);process.send('ready');"], {stdio: ['pipe', 'ignore', 'ignore', 'ipc']}); + await once(child, 'message'); + child.once('close', () => { closed = true; }); + const result = await copyToClipboard('literal text', 'darwin', { + env: {}, resolve: () => process.execPath, timeoutMs: 50, + spawn: () => child! + }); + expect(result).toBe(false); + expect(closed).toBe(true); + expect(child!.exitCode !== null || child!.signalCode !== null).toBe(true); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close'); + child.kill('SIGKILL'); + await closed; + } + } + }); + + it('accepts a long-lived browser opener without terminating its application', async () => { + const {spawn} = await vi.importActual('node:child_process'); + let child: ChildProcess | undefined; + try { + const result = await openExternal('https://example.test/?x=1&y=%22', 'linux', { + env: {DISPLAY: ':0'}, resolve: () => process.execPath, timeoutMs: 500, + spawn: (_command, _args, options) => { + child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});setInterval(()=>{},1000);"], options); + return child; + } + }); + expect(result).toBe(true); + expect(child!.exitCode).toBeNull(); + expect(child!.signalCode).toBeNull(); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close'); + child.kill('SIGKILL'); + await closed; + } + } }); }); diff --git a/src/ui/system-actions.ts b/src/ui/system-actions.ts index f74a343..5f8339c 100644 --- a/src/ui/system-actions.ts +++ b/src/ui/system-actions.ts @@ -1,19 +1,9 @@ import {spawn, type ChildProcess, type SpawnOptions} from 'node:child_process'; -import {resolveCommand} from '../player/command.js'; +import {resolveCommand} from '../platform/executables.js'; import {safeExternalHttpUrl} from '../safety.js'; -import {browserCommands, clipboardCandidates, hasGraphicalSession, type SystemCommand} from '../platform/desktop.js'; +import {browserCommands, clipboardCandidates, hasGraphicalSession} from '../platform/desktop.js'; import {identifyPlatform} from '../platform/runtime.js'; - -// The command used to open a URL in the platform's default handler. -export function openExternalCommand(platform: NodeJS.Platform = process.platform): SystemCommand | undefined { - return browserCommands(identifyPlatform({platform}))[0]; -} - -// Candidate clipboard tools per platform, in priority order. pbcopy/clip ship -// with macOS/Windows; Linux/BSD rely on whichever of these is installed. -export function clipboardCommands(platform: NodeJS.Platform = process.platform): SystemCommand[] { - return clipboardCandidates(identifyPlatform({platform})); -} +import {waitForLaunch} from '../platform/launch-command.js'; type ActionDeps = { env?: NodeJS.ProcessEnv; @@ -29,7 +19,14 @@ export async function openExternal(url: string, platform: NodeJS.Platform = proc if (!safeUrl || !hasGraphicalSession(host, env)) return false; for (const {command, args} of browserCommands(host)) { const executable = (deps.resolve ?? resolveCommand)(command); - if (executable && await runHelper(executable, [...args, safeUrl], undefined, deps)) return true; + if (!executable) continue; + try { + // xdg-open may run for the browser's entire lifetime. Observe startup + // failure, then let the desktop application own its detached process. + const child = (deps.spawn ?? spawn)(executable, [...args, safeUrl], {stdio: 'ignore', env, detached: true, windowsHide: true}); + await waitForLaunch(child); + return true; + } catch { /* Try the next available opener after a launch failure. */ } } return false; } @@ -45,25 +42,45 @@ export async function copyToClipboard(text: string, platform: NodeJS.Platform = return false; } -function runHelper(command: string, args: string[], input: string | undefined, deps: ActionDeps): Promise { +function runHelper(command: string, args: string[], input: string, deps: ActionDeps): Promise { return new Promise(resolve => { let child: ChildProcess | undefined; let settled = false; + let stopping = false; + let forceTimer: NodeJS.Timeout | undefined; + let cleanupTimer: NodeJS.Timeout | undefined; + const signal = (value: NodeJS.Signals) => { + try { child?.kill(value); } catch { /* The failed action remains false; cleanup is still bounded. */ } + }; const finish = (ok: boolean) => { if (settled) return; settled = true; clearTimeout(timer); + clearTimeout(forceTimer); + clearTimeout(cleanupTimer); resolve(ok); }; - const timer = setTimeout(() => { child?.kill(); finish(false); }, deps.timeoutMs ?? 5000); + const stop = () => { + if (settled || stopping) return; + stopping = true; + forceTimer = setTimeout(() => signal('SIGKILL'), 250); + cleanupTimer = setTimeout(() => { + // Even an uninterruptible OS process must not keep RadioCLI alive. + child?.unref(); + finish(false); + }, 1_000); + child?.stdin?.destroy(); + signal('SIGTERM'); + }; + const timer = setTimeout(stop, deps.timeoutMs ?? 5000); try { - child = (deps.spawn ?? spawn)(command, args, {stdio: input === undefined ? 'ignore' : ['pipe', 'ignore', 'ignore'], env: deps.env ?? process.env, windowsHide: true}); - child.once('error', () => finish(false)); - child.once('close', code => finish(code === 0)); - child.stdin?.on('error', () => { child?.kill(); finish(false); }); - if (input !== undefined) child.stdin?.end(input); + child = (deps.spawn ?? spawn)(command, args, {stdio: ['pipe', 'ignore', 'ignore'], env: deps.env ?? process.env, windowsHide: true}); + child.on('error', () => { if (child?.pid) stop(); else finish(false); }); + child.once('close', code => finish(!stopping && code === 0)); + child.stdin?.on('error', stop); + child.stdin?.end(input); } catch { - finish(false); + if (child?.pid) stop(); else finish(false); } }); } diff --git a/src/ui/use-alarm-tui.ts b/src/ui/use-alarm-tui.ts index 27beb48..155b080 100644 --- a/src/ui/use-alarm-tui.ts +++ b/src/ui/use-alarm-tui.ts @@ -175,6 +175,39 @@ export function useAlarmTui(params: Params): AlarmTuiController { await refreshRuntime(); }, [refreshRuntime, service, setMessage]); + const deleteAlarm = useCallback(async (alarm: Alarm) => { + let phase: 'disable' | 'native' | 'definition' = 'disable'; + let message: string; + try { + // Persist the safe state before touching native jobs. A failed cleanup + // can then be retried without another write in the error handler. + const disabled = store.toggleAlarm(alarm.id, false); + const disabledSnapshot = store.snapshot(); + alarmStateRef.current = disabledSnapshot.alarms; + setLibrary(disabledSnapshot); + phase = 'native'; + await service.remove(disabled); + phase = 'definition'; + store.removeAlarm(alarm.id); + const snapshot = store.snapshot(); + alarmStateRef.current = snapshot.alarms; + setLibrary(snapshot); + setSelected(value => clamp(value, snapshot.alarms.length + 1)); + message = `Deleted ${alarm.label}.`; + } catch (error) { + message = phase === 'disable' + ? `Delete canceled: ${messageOf(error)}. Alarm and native jobs unchanged.` + : phase === 'native' + ? `Delete cleanup failed: ${messageOf(error)}. ${alarm.label} was kept disabled; press r to repair.` + : `Native cleanup completed. Save failed: ${messageOf(error)}. ${alarm.label} was kept disabled; retry deletion.`; + } finally { + busyAlarmIdsRef.current.delete(alarm.id); + setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); + } + await refreshRuntime(); + setMessage(message); + }, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]); + const saveDraft = useCallback(() => { if (!draft || savingRef.current) return; const error = validateAlarmDraft(draft); @@ -248,13 +281,8 @@ export function useAlarmTui(params: Params): AlarmTuiController { if(alarmIsActive){setMessage(`Dismiss or snooze ${alarm.label} before deleting it; current playback was left untouched.`);return true;} if (deletingIdRef.current !== alarm.id) { deletingIdRef.current = alarm.id; setDeletingId(alarm.id); setMessage(`Press x again to delete “${alarm.label}”.`); return true; } deletingIdRef.current = undefined; setDeletingId(undefined); busyAlarmIdsRef.current.add(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); - void service.remove(alarm).then(() => { - if (store.getAlarm(alarm.id)) store.removeAlarm(alarm.id); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); setSelected(value => clamp(value, snapshot.alarms.length + 1)); setMessage(`Deleted ${alarm.label}.`); - }).catch(error => { - const existing = store.getAlarm(alarm.id); - if (existing) { const disabled = store.toggleAlarm(alarm.id, false); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); setMessage(`Delete cleanup failed; ${disabled.label} was kept disabled so Repair can retry: ${messageOf(error)}`); } - else setMessage(`Delete cleanup failed after the local alarm disappeared: ${messageOf(error)}`); - }).finally(() => { busyAlarmIdsRef.current.delete(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); void refreshRuntime(); }); return true; + void deleteAlarm(alarm).catch(error => setMessage(`Unexpected alarm deletion failure: ${messageOf(error)}`)); + return true; } if (input !== 'x') { deletingIdRef.current = undefined; setDeletingId(undefined); } if (input === 't' && alarm) { void previewStation(alarm.station).then(() => setMessage(`Test-tuning ${alarm.station.name}; no alarm state changed.`)).catch(error => setMessage(`Test tune failed: ${messageOf(error)}`)); return true; } @@ -361,7 +389,7 @@ export function useAlarmTui(params: Params): AlarmTuiController { return true; } return false; - }, [activeAlarms, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]); + }, [activeAlarms, deleteAlarm, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]); const openActive = useCallback(() => { if (!activeAlarms.length) { setMessage('No alarm is currently playing.'); return; } From 70298970e8f4a04ce1d07f2727484d99668ce47e Mon Sep 17 00:00:00 2001 From: Ciphore Date: Sat, 12 Sep 2026 01:54:45 -0700 Subject: [PATCH 24/25] Release 0.2.4 Co-authored-by: MrSeizy <228224+PonchoPig@users.noreply.github.com> --- .github/workflows/ci.yml | 236 +- CHANGELOG.md | 66 + CONTRIBUTING.md | 42 +- README.md | 50 +- apps/docs/DISCOVERY.md | 58 - apps/docs/content/docs/architecture.mdx | 104 +- apps/docs/content/docs/changelog.mdx | 65 + apps/docs/content/docs/contributing.mdx | 41 +- apps/docs/content/docs/design.mdx | 58 +- .../content/docs/getting-started/install.mdx | 137 +- .../docs/getting-started/tui-controls.mdx | 70 +- apps/docs/content/docs/meta.json | 5 +- apps/docs/content/docs/platforms.mdx | 217 + apps/docs/content/docs/privacy-security.mdx | 104 +- apps/docs/content/docs/release-packaging.mdx | 70 +- apps/docs/content/docs/reliability.mdx | 130 +- apps/docs/content/docs/roadmap.mdx | 36 - apps/docs/content/docs/troubleshooting.mdx | 201 + apps/docs/lib/seo.ts | 2 +- apps/docs/package-lock.json | 344 +- apps/docs/package.json | 4 +- package-lock.json | 4 +- package.json | 2 +- scripts/fresh-check.mjs | 91 +- scripts/install-smoke-mpv.mjs | 160 + scripts/install-smoke-mpv.test-node.mjs | 57 + scripts/packed-smoke.mjs | 305 + scripts/packed-smoke.test-node.mjs | 75 + src/agent/headless-host.test.ts | 234 + src/agent/headless-host.ts | 51 +- src/agent/launcher.test.ts | 45 + src/agent/launcher.ts | 71 +- src/agent/mcp-install.ts | 32 +- src/agent/session.test.ts | 116 +- src/agent/session.ts | 25 +- src/alarms/active-session.test.ts | 69 + src/alarms/active-session.ts | 18 +- src/alarms/guard.test.ts | 158 +- src/alarms/guard.ts | 84 +- src/alarms/inhibitor.test.ts | 20 +- src/alarms/inhibitor.ts | 39 +- src/alarms/power-guard-store.ts | 11 +- src/alarms/runner.test.ts | 98 +- src/alarms/runner.ts | 42 +- src/alarms/schedule.test.ts | 19 +- src/alarms/schedule.ts | 9 +- src/alarms/scheduler.test.ts | 360 +- src/alarms/scheduler.ts | 184 +- src/alarms/setup-verification.test.ts | 1 + src/alarms/setup-verification.ts | 3 +- src/alarms/system-volume-native.test.ts | 105 + src/alarms/system-volume-ownership.test.ts | 100 + src/alarms/system-volume-ownership.ts | 129 + src/alarms/system-volume-process.test.ts | 102 + src/alarms/system-volume.test.ts | 170 +- src/alarms/system-volume.ts | 114 +- src/alarms/terminal-launcher.test.ts | 78 +- src/alarms/terminal-launcher.ts | 203 +- src/alarms/tui-presence.ts | 4 +- src/cli-terminal.test.tsx | 60 + src/cli.test.ts | 93 + src/cli.tsx | 102 +- src/platform/capabilities.test.ts | 52 + src/platform/capabilities.ts | 68 + src/platform/desktop.test.ts | 36 + src/platform/desktop.ts | 32 + .../executable-locations.test.ts} | 2 +- src/platform/executables.test.ts | 92 + .../command.ts => platform/executables.ts} | 46 +- src/platform/ipc.test.ts | 17 + src/platform/ipc.ts | 16 + src/platform/launch-command.test.ts | 164 + src/platform/launch-command.ts | 118 + src/platform/loopback.test.ts | 70 + src/platform/loopback.ts | 47 + src/platform/network.test.ts | 343 + src/platform/network.ts | 296 + src/platform/packages.test.ts | 126 + src/platform/packages.ts | 215 + src/platform/paths.test.ts | 45 + src/platform/paths.ts | 44 + src/platform/runtime.test.ts | 61 + src/platform/runtime.ts | 97 + src/platform/shell.test.ts | 43 + src/platform/shell.ts | 27 + src/platform/storage.test.ts | 92 + src/platform/storage.ts | 46 + src/platform/support.test.ts | 274 + src/platform/support.ts | 222 + src/platform/terminal.test.ts | 141 + src/platform/terminal.ts | 72 + src/platform/terminals.test.ts | 231 + src/platform/terminals.ts | 182 + src/player/airplay-discovery.test.ts | 18 +- src/player/airplay-discovery.ts | 6 +- src/player/backend-install.test.ts | 42 +- src/player/backend-install.ts | 128 +- src/player/command-diagnostics.ts | 4 +- src/player/mpv-ipc-client.test.ts | 20 + src/player/mpv-ipc-client.ts | 3 +- src/player/player-controller.test.ts | 110 +- src/player/player-controller.ts | 166 +- src/providers/cache.ts | 37 +- src/providers/radio-browser.test.ts | 157 + src/providers/radio-browser.ts | 226 +- src/providers/radio-garden.test.ts | 58 + src/providers/radio-garden.ts | 38 +- src/setup.test.ts | 243 +- src/setup.ts | 238 +- src/storage/store.test.ts | 243 +- src/storage/store.ts | 132 +- src/ui/AdaptiveContent.test.tsx | 36 +- src/ui/AdaptiveContent.tsx | 63 +- src/ui/App.tsx | 189 +- .../visual-baseline.test.tsx.snap | 11325 ++++++++++++++++ src/ui/alarm-tui-integration.test.tsx | 430 +- src/ui/app-state.test.ts | 23 +- src/ui/app-state.ts | 12 + src/ui/ascii.test.ts | 2 + src/ui/ascii.ts | 10 +- src/ui/components/AdaptiveMarquee.tsx | 9 +- src/ui/components/Logo.tsx | 9 +- src/ui/components/Menu.tsx | 6 +- src/ui/components/ScreenHeader.tsx | 2 +- src/ui/components/StationList.tsx | 14 +- src/ui/components/TopTabs.tsx | 4 +- src/ui/display-context.test.ts | 28 +- src/ui/display-context.ts | 22 +- src/ui/help-content.ts | 2 +- src/ui/page-footer.test.ts | 2 +- src/ui/page-footer.ts | 2 +- src/ui/portable-app.test.tsx | 223 + src/ui/portable-render.test.tsx | 170 + src/ui/screens/AirPlayCodeScreen.tsx | 8 +- src/ui/screens/AirPlaySettingsScreen.tsx | 20 +- src/ui/screens/ExploreScreen.tsx | 2 + src/ui/screens/HelpScreen.tsx | 10 +- src/ui/screens/HomeScreen.tsx | 6 +- src/ui/screens/MapScreen.tsx | 2 +- src/ui/screens/NowPlayingScreen.tsx | 16 +- src/ui/screens/SearchScreen.tsx | 8 +- src/ui/screens/SettingsScreen.tsx | 10 +- src/ui/screens/StatsScreen.tsx | 12 +- src/ui/screens/screen-render.test.tsx | 30 +- src/ui/system-actions.test.ts | 120 +- src/ui/system-actions.ts | 137 +- src/ui/terminal-renderer.ts | 17 + src/ui/use-alarm-tui.ts | 44 +- src/ui/use-app-input.ts | 62 +- src/ui/use-command-executor.ts | 5 +- src/ui/visual-baseline.test.tsx | 228 + src/update-check.test.ts | 28 + src/update-check.ts | 25 +- 153 files changed, 22543 insertions(+), 1699 deletions(-) delete mode 100644 apps/docs/DISCOVERY.md create mode 100644 apps/docs/content/docs/platforms.mdx delete mode 100644 apps/docs/content/docs/roadmap.mdx create mode 100644 apps/docs/content/docs/troubleshooting.mdx create mode 100644 scripts/install-smoke-mpv.mjs create mode 100644 scripts/install-smoke-mpv.test-node.mjs create mode 100644 scripts/packed-smoke.mjs create mode 100644 scripts/packed-smoke.test-node.mjs create mode 100644 src/agent/headless-host.test.ts create mode 100644 src/agent/launcher.test.ts create mode 100644 src/alarms/system-volume-native.test.ts create mode 100644 src/alarms/system-volume-ownership.test.ts create mode 100644 src/alarms/system-volume-ownership.ts create mode 100644 src/alarms/system-volume-process.test.ts create mode 100644 src/cli-terminal.test.tsx create mode 100644 src/platform/capabilities.test.ts create mode 100644 src/platform/capabilities.ts create mode 100644 src/platform/desktop.test.ts create mode 100644 src/platform/desktop.ts rename src/{player/command.test.ts => platform/executable-locations.test.ts} (97%) create mode 100644 src/platform/executables.test.ts rename src/{player/command.ts => platform/executables.ts} (73%) create mode 100644 src/platform/ipc.test.ts create mode 100644 src/platform/ipc.ts create mode 100644 src/platform/launch-command.test.ts create mode 100644 src/platform/launch-command.ts create mode 100644 src/platform/loopback.test.ts create mode 100644 src/platform/loopback.ts create mode 100644 src/platform/network.test.ts create mode 100644 src/platform/network.ts create mode 100644 src/platform/packages.test.ts create mode 100644 src/platform/packages.ts create mode 100644 src/platform/paths.test.ts create mode 100644 src/platform/paths.ts create mode 100644 src/platform/runtime.test.ts create mode 100644 src/platform/runtime.ts create mode 100644 src/platform/shell.test.ts create mode 100644 src/platform/shell.ts create mode 100644 src/platform/storage.test.ts create mode 100644 src/platform/storage.ts create mode 100644 src/platform/support.test.ts create mode 100644 src/platform/support.ts create mode 100644 src/platform/terminal.test.ts create mode 100644 src/platform/terminal.ts create mode 100644 src/platform/terminals.test.ts create mode 100644 src/platform/terminals.ts create mode 100644 src/providers/radio-garden.test.ts create mode 100644 src/ui/__snapshots__/visual-baseline.test.tsx.snap create mode 100644 src/ui/portable-app.test.tsx create mode 100644 src/ui/portable-render.test.tsx create mode 100644 src/ui/terminal-renderer.ts create mode 100644 src/ui/visual-baseline.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22b7fc2..8be5c1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,13 @@ concurrency: on: pull_request: workflow_dispatch: + inputs: + experimental_bsd: + description: Run experimental packed-runtime checks in BSD guest VMs + type: boolean + default: false + schedule: + - cron: '37 7 * * 1' push: branches: - master @@ -18,6 +25,7 @@ on: jobs: verify: name: Verify + if: github.event_name != 'schedule' runs-on: ${{ matrix.os }} timeout-minutes: 25 strategy: @@ -25,16 +33,49 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] node-version: [22.x, 24.x] + # https://docs.github.com/en/actions/reference/runners/github-hosted-runners + # Keep the original six jobs, then add the missing CPU architectures. + include: + - os: ubuntu-latest + architecture: x64 + - os: macos-latest + architecture: arm64 + - os: windows-latest + architecture: x64 + - os: ubuntu-24.04-arm + node-version: 22.x + architecture: arm64 + - os: ubuntu-24.04-arm + node-version: 24.x + architecture: arm64 + - os: windows-11-arm + node-version: 22.x + architecture: arm64 + - os: windows-11-arm + node-version: 24.x + architecture: arm64 + - os: macos-15-intel + node-version: 22.x + architecture: x64 + - os: macos-15-intel + node-version: 24.x + architecture: x64 steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} + architecture: ${{ matrix.architecture }} cache: npm + - name: Verify native runtime identity + env: + RADIOCLI_EXPECTED_ARCH: ${{ matrix.architecture }} + run: node --input-type=module -e "import assert from 'node:assert/strict'; import os from 'node:os'; assert.equal(process.arch, process.env.RADIOCLI_EXPECTED_ARCH); console.log(JSON.stringify({method:'GitHub-hosted native OS/CPU VM',platform:process.platform,architecture:process.arch,kernel:os.release(),node:process.version}));" + - name: Install run: npm ci @@ -50,6 +91,9 @@ jobs: - name: Test run: npm run test + - name: Package smoke harness tests + run: node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs + - name: Build run: npm run build @@ -63,30 +107,58 @@ jobs: run: npm run check:package - name: Data smoke - if: github.event_name != 'pull_request' + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' run: npm run smoke:data + - name: Install native Linux playback fixture + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y mpv + + - name: Install pinned native macOS or Windows playback fixture + if: runner.os != 'Linux' + env: + RADIOCLI_SMOKE_EVIDENCE_DIR: packed-evidence + run: node scripts/install-smoke-mpv.mjs + - name: Fresh install smoke - if: github.event_name != 'pull_request' - run: npm run fresh:check + env: + RADIOCLI_EXPECTED_ARCH: ${{ matrix.architecture }} + RADIOCLI_SMOKE_EXECUTION_METHOD: GitHub-hosted native OS/CPU VM (${{ matrix.os }}) + RADIOCLI_SMOKE_EVIDENCE_DIR: packed-evidence + run: npm run fresh:check -- --require-mpv + + - name: Preserve packed runtime evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: packed-${{ matrix.os }}-${{ matrix.node-version }} + path: packed-evidence/*.json + if-no-files-found: warn playback-smoke: name: Playback Smoke - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' - timeout-minutes: 15 + runs-on: ${{ matrix.os }} + if: github.event_name != 'schedule' + timeout-minutes: 25 strategy: fail-fast: false matrix: + os: [ubuntu-latest, ubuntu-24.04-arm] node-version: [22.x, 24.x] + include: + - os: ubuntu-latest + architecture: x64 + - os: ubuntu-24.04-arm + architecture: arm64 steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} + architecture: ${{ matrix.architecture }} cache: npm - name: Install system playback backend @@ -96,10 +168,12 @@ jobs: run: npm ci - name: Playback smoke + # Packed normal/omit-optional installs for this OS/CPU/Node matrix run in verify. run: npm run smoke:playback docs: name: Docs + if: github.event_name != 'schedule' runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -108,10 +182,10 @@ jobs: node-version: [22.x, 24.x] steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} cache: npm @@ -131,3 +205,141 @@ jobs: - name: Build working-directory: apps/docs run: npm run build + + # Experimental jobs run in full guest kernels, not Linux containers. They are + # opt-in/weekly and do not establish maintained or audible-playback support. + # VM action source is pinned; upstream release assets/images are maintained + # separately by vmactions. The selected guest versions and actual runtimes are logged. + experimental-package: + name: Build experimental portability artifact + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.experimental_bsd) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup build Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.x + cache: npm + + - name: Install build dependencies + run: npm ci --omit=optional + + - name: Build the npm artifact for guest installation + run: | + mkdir -p portability-artifact + npm pack --pack-destination portability-artifact + cp scripts/packed-smoke.mjs portability-artifact/ + sha256sum portability-artifact/*.tgz + + - name: Preserve the built package and standalone harness + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: portability-package + path: portability-artifact/ + if-no-files-found: error + + experimental-freebsd: + name: Experimental FreeBSD 14.4 x64 guest / Node 22 + needs: experimental-package + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Download the Linux-built npm package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: portability-package + path: portability-artifact + + - name: Install and exercise JavaScript in FreeBSD + uses: vmactions/freebsd-vm@f0552d3b69211736abd97f02ff3d4674c56b73b1 # v1.5.5 + with: + release: '14.4' + arch: x86_64 + copyback: false + prepare: pkg install -y node22 npm-node22 mpv + run: | + set -eu + uname -a + node --version + npm --version + cd portability-artifact + set -- ./*.tgz + test "$#" -eq 1 + node packed-smoke.mjs "$1" --omit-optional --require-mpv --execution-method 'QEMU FreeBSD 14.4 x86_64 guest kernel; null audio' + + experimental-openbsd: + name: Experimental OpenBSD 7.9 x64 guest / packaged Node + needs: experimental-package + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Download the Linux-built npm package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: portability-package + path: portability-artifact + + - name: Install and exercise JavaScript in OpenBSD + uses: vmactions/openbsd-vm@86cdc08415d9d0865267e686561e276c52d62530 # v1.4.7 + with: + release: '7.9' + arch: x86_64 + copyback: false + prepare: pkg_add -I node mpv + run: | + set -eu + uname -a + node --version + npm --version + cd portability-artifact + set -- ./*.tgz + test "$#" -eq 1 + node packed-smoke.mjs "$1" --omit-optional --require-mpv --execution-method 'QEMU OpenBSD 7.9 x86_64 guest kernel; null audio' + + experimental-netbsd: + name: Experimental NetBSD 10.1 x64 guest / Node 22 + needs: experimental-package + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Download the Linux-built npm package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: portability-package + path: portability-artifact + + - name: Install and exercise JavaScript in NetBSD + uses: vmactions/netbsd-vm@7f23a491dcc69c34195721ab67934917707f84c7 # v1.4.9 + with: + release: '10.1' + arch: x86_64 + copyback: false + prepare: | + set -eu + export PATH=/usr/pkg/bin:/usr/pkg/sbin:$PATH + # The minimal guest omits X11 libraries required by pkgsrc mpv, + # even for null audio/video. Install the matching official OS set. + # Digest: NetBSD-10.1/amd64/binary/sets/SHA512 on cdn.NetBSD.org. + radiocli_xbase=$(mktemp /tmp/radiocli-xbase.XXXXXX) + trap 'rm -f "$radiocli_xbase"' EXIT + ftp -q 60 -o "$radiocli_xbase" https://cdn.NetBSD.org/pub/NetBSD/NetBSD-10.1/amd64/binary/sets/xbase.tar.xz + test "$(cksum -a SHA512 -q "$radiocli_xbase")" = b6ac2195f91d7ae395ec2734c9ae2a4727014629fbdc94562531225e0d51e3b2705047e8440fcf75e1dacdcecb3cb980df7a82f838c12ad5274ba8ce95e49b72 + tar -xpf "$radiocli_xbase" -C / + /usr/sbin/pkg_add pkgin + pkgin -y update + pkgin -y install 'nodejs>=22<23' mpv + mpv --version + run: | + set -eu + export PATH=/usr/pkg/bin:/usr/pkg/sbin:$PATH + uname -a + node --version + npm --version + cd portability-artifact + set -- ./*.tgz + test "$#" -eq 1 + node packed-smoke.mjs "$1" --omit-optional --require-mpv --execution-method 'QEMU NetBSD 10.1 x86_64 guest kernel; null audio' diff --git a/CHANGELOG.md b/CHANGELOG.md index 562329d..1262371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,71 @@ All notable changes to RadioCLI are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.4] - 2026-09-08 + +### Added + +- Added a shared platform layer for runtime eligibility, capabilities, executable + discovery, package installation, storage paths, networking, IPC, desktop + helpers, and terminal launching. +- Added first-class Node.js 22 and 24 CI coverage for macOS Apple Silicon and + Intel, Ubuntu x64 and arm64, Windows x64, and Windows 11 arm64, including + packed installation and native mpv IPC playback checks. +- Added repeatable packed CLI and mpv playback checks in full FreeBSD, OpenBSD, + and NetBSD guest kernels, plus explicit installation and capability guidance + for additional Unix, WSL, Termux, Haiku, illumos, and AIX environments. +- Added offline, low-bandwidth, screen-reader, reduced-motion, ASCII, limited- + color, and noninteractive terminal modes with deterministic full, compact, + and micro rendering coverage. + +### Changed + +- Centralized native package recipes and guided setup so dry runs and executed + commands use the same platform-specific definitions. +- Expanded `radiocli doctor --json` with host, runtime, network, storage, + terminal, playback, desktop, scheduling, and optional-feature capabilities. +- Reorganized installation, platform, architecture, reliability, privacy, and + contributor documentation as stable reference material with explicit support + boundaries and troubleshooting paths. + +### Fixed + +- Kept playback and the TUI usable when optional history, presence, cache, or + library writes fail, while preserving recoverable data after interrupted or + permission-denied writes. +- Hardened alarm deletion, native scheduler cleanup, terminal reopening, Alarm + Guard ownership, system-volume restoration, and forced-process teardown. +- Preserved exact arguments, data locations, and supported environment selectors + across macOS, Linux, BSD, and Windows child processes without forwarding + unrelated shell state. +- Improved player discovery, mpv readiness and IPC handling, provider timeouts, + mirror fallback, offline cache behavior, and degraded terminal rendering. +- Recovered ARM Linux playback from mpv audio-service initialization failures + by retrying once with ALSA first, retained bounded player diagnostics, and + stopped system-wide backend failures from skipping through every station. +- Replaced stale Radio Browser hosts with live DNS service discovery and added + a TLS-verified resolver fallback for temporary hostname failures without + bypassing configured proxies. +- Preserved full RGB theme colors when a terminal explicitly advertises + truecolor, even when its Node.js stream reports a conservative 256-color + depth; explicit 256-color, 16-color, and no-color modes remain unchanged. +- Kept retained Search results playable after clearing the submitted query and + added `Ctrl+F` to favorite the selected result while query input is active, + without intercepting an ordinary `f` typed into the search text. +- Matched Search favorite confirmations to the Library by dismissing added and + removed messages automatically after the same brief interval. +- Corrected Windows terminal launches to create independent interactive console + handles and retain the intended user data location. + +### Security + +- Updated documentation-site dependencies to patched releases. +- Limited child-process environments to the settings required for RadioCLI data + identity and native integrations, excluding unrelated credentials and proxy + secrets. +- Made native cleanup and ownership checks fail closed when a job, helper, + process, or saved state cannot be identified safely. + ## [0.2.3] - 2026-09-07 ### Added @@ -397,6 +462,7 @@ Initial public release. [0.2.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.1 [0.2.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.2 [0.2.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.3 +[0.2.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.4 [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4 [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3 [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd2f4ed..809dc62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,19 +12,22 @@ npm run build npm run test ``` -Install `mpv` for playback controls, metadata polling, and the best playback -experience: +Install `mpv` for playback controls and metadata polling. On a macOS +configuration supported by Homebrew: ```bash brew install mpv ``` -On Linux, use your package manager's `mpv` package. On Windows, use: +On Linux, use your package manager's `mpv` package. On Windows x64, use: ```powershell winget install --id shinchiro.mpv -e ``` +Use the [platform installation routes](apps/docs/content/docs/platforms.mdx#installation-and-playback-prerequisites) +for Windows arm64 and other package managers. + ## Verification Run these before opening a PR: @@ -34,11 +37,19 @@ npm run check npm run lint npm run test npm run build +npm run pack:check +npm run fresh:check -- --require-mpv +npm run check:package +npm run smoke:mcp +npm run docs:check +npm run docs:build ``` -Run `npm run smoke:playback` when changing playback code. It opens a public -stream briefly, so do not run it in environments where audio/network access is -not acceptable. +Install documentation dependencies with `npm --prefix apps/docs ci` first. +Run `npm run smoke:playback` when changing playback code. Its default fixture is +a local WAV file; mpv uses null audio in deterministic package +checks. These checks exercise the player process and controls, and do not prove +speaker output. `npm run smoke:data` intentionally contacts public providers. Live provider checks are useful before releases, but ordinary pull requests should stay deterministic. Run `npm run verify:release` for a full maintainer @@ -52,9 +63,28 @@ checks. - Never make location lookup implicit. - Prefer clear terminal text over decorative filler. - Keep command-line and TUI behavior aligned. +- Put OS decisions in `src/platform`; keep capability availability separate from + runtime eligibility and recorded platform verification. - Add tests for parsing, storage, layout, or provider transformations when the behavior can be checked without live audio. +## Platform verification + +The required Node 22/24 CI matrix tests x64 and arm64 installations and real mpv +control. Weekly or manually triggered BSD checks execute the packed JavaScript +in guest kernels. Changes to script harnesses +also require `node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs`. + +Record OS release, CPU, endianness, libc, Node/npm versions, artifact hash, and +whether execution was native, virtualized, emulated, or mocked. An installation +test, native command mock, or foreign binary inspection alone cannot promote a +platform to supported. Follow the [support tiers](apps/docs/content/docs/platforms.mdx). + +The deterministic visual fixtures cover full, compact, and micro layouts, all +major screens, and receiver styles. Preserve Unicode captures. When rendering +changes intentionally, describe the visible effect in the pull request and do +not update snapshots merely to hide failures. + ## Issue Triage Playback issues should include station name, country, backend, and diff --git a/README.md b/README.md index ff42002..bd08be0 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Built with [Ink](https://github.com/vadimdemedes/ink), ## Quick start -macOS with Homebrew: +On macOS configurations supported by Homebrew: ```bash brew install ciphore/tap/radiocli @@ -44,11 +44,43 @@ radiocli RadioCLI requires Node.js 22 or newer. The Homebrew formula installs `mpv` and FFmpeg. After an npm install, `radiocli setup` detects the operating system and package manager, lets you choose `mpv`, FFmpeg, and VLC, installs the selected -native tools with branded progress feedback, and verifies playback readiness. +native tools with branded progress feedback, and checks that executables are +available. Play a station to verify the actual audio path. Intel or older macOS +installations should consult the platform matrix for alternative package routes. See the [installation guide](apps/docs/content/docs/getting-started/install.mdx) for Windows, Linux distributions, AirPlay prerequisites, and fallback players. +## Platform coverage + +RadioCLI separates core playback, storage, terminal rendering, desktop helpers, +and native alarm services. A missing scheduler or clipboard helper leaves the +other features available. `radiocli doctor --json` reports each capability, +terminal and network policy, and runtime eligibility separately. + +The [platform matrix](apps/docs/content/docs/platforms.mdx) distinguishes required +CI targets from verified installations, experimental community paths, and known +runtime blockers. Node 22 and 24 are the reference test lines. BSD package plans, +Termux, Haiku and illumos guidance do not imply that every OS/CPU combination has +been exercised. AIX playback and Haiku's packaged Node 20 remain explicit gaps. +Linux without a usable systemd user session can still run the player and TUI; +reliable background alarms require a verified native adapter. + +Invocation preferences do not change saved settings: + +```bash +RADIOCLI_ASCII=1 radiocli # ASCII decoration; station names preserved +NO_COLOR=1 radiocli # no color or background fills +RADIOCLI_SCREEN_READER=1 radiocli # readable controls; no visualizer animation +RADIOCLI_OFFLINE=1 radiocli # cached directories and saved stations +RADIOCLI_LOW_BANDWIDTH=1 radiocli # cached atlas; no automatic receiver scan +``` + +Offline mode disables directory, location, update, vote and receiver-discovery +requests. An external player still needs a connection to listen to a live stream. +For proxy, SSH, read-only storage and custom-player paths, see +[troubleshooting](apps/docs/content/docs/troubleshooting.mdx). + ## Visual tour These recordings come from the built TUI. Generate them locally with @@ -84,7 +116,7 @@ These recordings come from the built TUI. Generate them locally with | `space` or `F8` | Pause or resume with `mpv` | | `,` / `.` or `F7` / `F9` | Previous or next station | | `+` / `-` | Change volume | -| `f` | Save or remove a favorite | +| `f` (`Ctrl+F` while typing in Search) | Save or remove a favorite | | `?` | Open all shortcuts and commands | | `q` or `Ctrl+C` twice | Quit cleanly | @@ -214,6 +246,8 @@ complete data-flow description. - `src/ui` — screens, input, layout, and terminal rendering - `src/providers` — station directories, resolution, and caches - `src/player` — playback backends, metadata, and AirPlay +- `src/platform` — host identity, independent capabilities, native command plans, + paths, terminal policy, networking and runtime eligibility - `src/alarms` — schedules, native registration, Alarm Guard, and active controls - `src/storage` — local library persistence and migration - `apps/docs` — documentation website and manual @@ -231,9 +265,10 @@ npm run verify npm run dev ``` -`npm run verify` checks types, lint, tests, the production build, and package -contents. Playback and live-data smoke tests are available separately because -they contact public services or start a local player. +`npm run verify` checks types, lint, tests, the production build, and live provider +data. `npm run fresh:check -- --require-mpv` tests packed installs with and without +optional dependencies using a local WAV, real mpv IPC controls and MCP. See +[contribution checks](CONTRIBUTING.md) for documentation and packaging validation. ## Contributing @@ -244,9 +279,10 @@ include `radiocli check` output with playback reports. - [Getting started](apps/docs/content/docs/index.mdx) - [Installation](apps/docs/content/docs/getting-started/install.mdx) +- [Platform matrix](apps/docs/content/docs/platforms.mdx) +- [Troubleshooting](apps/docs/content/docs/troubleshooting.mdx) - [Controls](apps/docs/content/docs/getting-started/tui-controls.mdx) - [Architecture](apps/docs/content/docs/architecture.mdx) -- [Roadmap](apps/docs/content/docs/roadmap.mdx) - [Release packaging](apps/docs/content/docs/release-packaging.mdx) Run the documentation site locally with `npm run docs:dev`. diff --git a/apps/docs/DISCOVERY.md b/apps/docs/DISCOVERY.md deleted file mode 100644 index 50c66dc..0000000 --- a/apps/docs/DISCOVERY.md +++ /dev/null @@ -1,58 +0,0 @@ -# Search and AI discovery - -The RadioCLI website exposes the same public documentation to people, search -engines, and AI tools without creating crawler-only content. - -## Public discovery endpoints - -- `/robots.txt` allows public pages and links to the XML sitemap. -- `/sitemap.xml` lists the homepage and canonical documentation pages. -- `/llms.txt` is the concise AI-readable project and documentation index. -- `/llms-full.txt` contains the complete documentation in one text response. -- `/llms.mdx` is a Markdown version of the homepage summary. -- `/llms.mdx/docs//content.md` exposes each documentation page as Markdown. -- `/manifest.webmanifest` describes the documentation site as an installable web app. - -The HTML pages remain the canonical source. LLM routes are alternate -representations and are intentionally omitted from the XML sitemap. - -## Canonical production URL - -Set `NEXT_PUBLIC_SITE_URL` to the public production origin, including `https://`: - -```bash -NEXT_PUBLIC_SITE_URL=https://docs.example.org -``` - -All canonical links, sitemap entries, JSON-LD URLs, Open Graph URLs, and LLM -indexes derive from that value. Vercel deployments can fall back to -`VERCEL_PROJECT_PRODUCTION_URL` and previews to `VERCEL_URL`. Cloudflare Pages -can fall back to `CF_PAGES_URL`. Local builds use `http://localhost:3000`. - -Optional webmaster verification meta tags can be configured without changing -source files: - -```bash -GOOGLE_SITE_VERIFICATION=google-token -BING_SITE_VERIFICATION=bing-token -``` - -## After the first production deployment - -These steps require control of the public domain and therefore cannot be -automated from the repository alone: - -1. Verify the domain in Google Search Console and Bing Webmaster Tools, using - DNS verification or the optional deployment variables above. -2. Submit the canonical `/sitemap.xml` URL to both services. -3. Run the homepage and representative docs pages through Google's Rich - Results Test and the Schema.org validator. -4. Inspect canonical, robots, Open Graph, Twitter, and JSON-LD tags in rendered - HTML. -5. Add the production docs URL to the GitHub repository website field and to - the npm package `homepage` once the URL is permanent. -6. Monitor indexed-page counts and crawl errors after releases. - -Do not add fabricated reviews, ratings, prices, FAQs, or organization claims to -structured data. RadioCLI is represented as free, MIT-licensed open-source -software and source code. diff --git a/apps/docs/content/docs/architecture.mdx b/apps/docs/content/docs/architecture.mdx index 33ec211..3102723 100644 --- a/apps/docs/content/docs/architecture.mdx +++ b/apps/docs/content/docs/architecture.mdx @@ -3,8 +3,9 @@ title: Architecture description: Runtime shape, provider adapters, playback control, TUI state, and persistence. --- -RadioCLI is a terminal-first product built around four seams: TUI state, -provider adapters, playback control, and local persistence. +RadioCLI separates TUI state, provider adapters, playback control, and local +persistence. A shared platform layer describes the host and selects native +integrations for those components. ## Runtime Shape @@ -45,6 +46,26 @@ AirPlay stream before the alarm switches to local output. If that owner cannot be reached, alarm delivery continues and runtime health records the degraded takeover instead of suppressing the alarm. +## Platform Boundaries + +The modules in `src/platform` keep host detection separate from runtime +readiness and the project's support policy: + +| Modules | Responsibility | +| --- | --- | +| `runtime.ts`, `support.ts` | Describe the OS, CPU, libc, WSL, and Termux environment; assess runtime eligibility and verified support evidence separately | +| `capabilities.ts` | Report each feature as available, unavailable, or unverified from the evidence gathered in this session | +| `paths.ts`, `storage.ts` | Preserve library and runtime namespaces; inspect storage access without creating a write probe | +| `executables.ts`, `packages.ts`, `shell.ts` | Resolve commands and construct installation or launch arguments for the host | +| `desktop.ts`, `terminals.ts`, `terminal.ts` | Select optional desktop helpers and describe terminal capabilities | +| `network.ts`, `loopback.ts` | Bound public HTTP requests and keep authenticated local control independent of public-network proxy settings | + +Finding a player, scheduler command, or terminal executable does not establish +that its audio device, user service, or desktop session works. Native operations +check their results, and `radiocli doctor` reports the available evidence. BSD, +Termux, and other experimental targets do not inherit Linux alarm adapters or a +support claim from a successful platform mock. See [Platform support](./platforms.mdx). + ## Alarm Runtime Alarm definitions are portable library data, while delivery is rebuilt from @@ -67,6 +88,12 @@ uses an occurrence lock to suppress duplicate dispatch, records the result, and then installs the next occurrence. A four-worker bound keeps bulk synchronization from spawning unbounded native commands. +An early native dispatch releases its occurrence lock without marking the +occurrence complete, so the real due dispatch can still run. If removal of a +native job fails, the adapter accepts the result only when a query confirms that +specific job is absent. An ambiguous failure keeps the definition and repair +artifacts available. + Recurring schedules store `HH:mm`, ISO weekdays, and an IANA timezone. Civil time is resolved one minute at a time: a spring-forward gap advances to the first valid minute on that civil date, while a fall-back overlap fires only the @@ -84,9 +111,15 @@ start at the configured target volume. Alarm Guard is a second detached process, separate from native scheduling. It holds the platform idle-sleep inhibitor until just after the next occurrence and uses an authenticated loopback ownership challenge so stale PID files cannot -cause an unrelated process to be signaled. Guard state, scheduler health, -occurrence locks, and active-control discovery are operational machine state, -not portable library state. +cause an unrelated process to be signaled. Unreadable ownership records and +records for live processes that fail the challenge are retained for repair; the +unverified PID is not signaled. An inhibitor lease reports an unexpected helper +exit, allowing runtime health to show that sleep protection was lost. + +System-volume leases snapshot the prior mixer state. A partial change triggers +a rollback attempt, and a failed restore leaves the lease retryable. Guard +state, scheduler health, occurrence locks, and active-control discovery are +operational machine state, not portable library state. ## Provider Layer @@ -98,9 +131,23 @@ not portable library state. by edge protection. - Playlist imports are modeled as first-class `playlist` stations. -Radio Browser calls use mirror fallback and a durable cache. If a live request -fails and stale cached data exists, the provider returns stale data rather than -blanking the interface. +Radio Browser calls use the canonical endpoint first, discover current HTTPS +servers from the directory's DNS service record when fallback is needed, and +keep a durable cache. If a live request fails and stale cached data exists, the +provider returns stale data rather than blanking the interface. A cache write failure +does not discard a successful directory response. Request deadlines remain +active while response bodies are consumed, and unused bodies are canceled +where possible. + +`RADIOCLI_OFFLINE=1` blocks public requests made by RadioCLI's Node HTTP client. +Cached directory results and saved direct stream URLs remain usable. +`RADIOCLI_LOW_BANDWIDTH=1` makes the geotagged atlas cache-only; smaller directory +queries remain available. Both flags skip automatic AirPlay discovery. Offline +mode also blocks manual discovery; low-bandwidth mode allows an explicit +refresh. Authenticated local controls remain available. These policies do not +block the external player's network access when a user requests a direct +stream. Proxy configuration and the exact network boundaries are described in +[Reliability](./reliability.mdx#network-requests). Explore and Nearby share the same geospatial path: RadioCLI caches the full available Radio Browser geotagged station atlas, filters out invalid coordinates, @@ -113,7 +160,7 @@ so map exploration is not capped to globally popular stations. `PlayerController` owns playback process lifecycle. - Default local output: `mpv` -- Fallback backend: `ffplay` for playback only +- Playback-only fallbacks: `ffplay` and VLC - Experimental macOS output: AirPlay/RAOP through Bonjour discovery, FFmpeg, and RadioCLI's bundled sender bridge - `mpv` is controlled over JSON IPC for pause, mute, volume, readiness checks, @@ -130,11 +177,11 @@ other non-Radio-Browser stations use their exposed stream URL. Once a URL is resolved, `PlayerController` starts `mpv` with `--no-video`, `--force-window=no`, a local JSON IPC endpoint, a forced media title, and the -configured volume. That endpoint is a Unix socket on macOS/Linux and a named -pipe on native Windows. `ffplay` is available as a playback-only fallback, but it -does not provide a reliable IPC surface for pause, mute, volume, play/pause -media-key handling, metadata, or readiness checks. RadioCLI labels that mode as -limited instead of updating UI state optimistically. +configured volume. That endpoint is a named pipe on native Windows and a Unix +socket on Unix-like targets whose runtime supports it. `ffplay` and VLC are +playback-only fallbacks; they do not provide a reliable IPC surface for pause, +mute, volume, play/pause media-key handling, metadata, or readiness checks. +RadioCLI labels that mode as limited instead of updating UI state optimistically. On macOS, the optional AirPlay backend discovers RAOP receivers with Bonjour and streams through a child worker that feeds decoded PCM from `ffmpeg` into a @@ -215,11 +262,30 @@ The store is local JSON. It keeps: - alarm definitions, next snooze override, and recent run outcome - settings -Corrupt store/cache files are renamed with a `.bad-*` suffix before defaults are -used, so user data is not silently overwritten. - -Writes use a temp-file-and-rename flow so interrupted writes are less likely to -leave partial JSON behind. +The library distinguishes missing data, malformed JSON or schema data, and an +unreadable file. A missing library starts with defaults. Corrupt +library bytes must first be preserved under a `.bad-*` name; a failed backup +leaves the original file in place and reports an error. Permission and I/O +failures are never treated as corruption or as a missing current library that +allows fallback to an older legacy file. Provider caches use separate best-effort +recovery. + +Library writes take a lock, merge concurrent changes, write a private temporary +file beside the destination, and atomically rename it into place. POSIX file +permissions are set before replacement. A failed write preserves the previous +disk and in-memory state, and cleanup cannot mask the original error. Windows +uses the user directory's ACLs rather than POSIX modes. + +A readable library remains usable when its file or directory is read-only. The +TUI catches optional recent, listening, track, search, and update-history save +failures separately from playback and navigation. Stop, sleep-timer expiry, +alarm takeover, and quit still stop the player. Explicit settings and favorite +changes report a failed save without claiming success. Error messages suggest +a writable `RADIOCLI_HOME` without exposing the private path. + +The platform changes introduce no new library or backup schema. Existing +`RADIOCLI_HOME`, `RADIO_ATLAS_HOME`, and legacy Radio Atlas locations retain +their meaning, including the existing macOS runtime namespaces. Full library backup/restore includes alarm definitions. Native scheduler files, runtime health, Alarm Guard state, occurrence locks, and authenticated local diff --git a/apps/docs/content/docs/changelog.mdx b/apps/docs/content/docs/changelog.mdx index ab6e621..e1a8645 100644 --- a/apps/docs/content/docs/changelog.mdx +++ b/apps/docs/content/docs/changelog.mdx @@ -6,6 +6,71 @@ description: Release history for RadioCLI. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.2.4 - 2026-09-08 + +### Added + +- Added a shared platform layer for runtime eligibility, capabilities, executable + discovery, package installation, storage paths, networking, IPC, desktop + helpers, and terminal launching. +- Added first-class Node.js 22 and 24 CI coverage for macOS Apple Silicon and + Intel, Ubuntu x64 and arm64, Windows x64, and Windows 11 arm64, including + packed installation and native mpv IPC playback checks. +- Added repeatable packed CLI and mpv playback checks in full FreeBSD, OpenBSD, + and NetBSD guest kernels, plus explicit installation and capability guidance + for additional Unix, WSL, Termux, Haiku, illumos, and AIX environments. +- Added offline, low-bandwidth, screen-reader, reduced-motion, ASCII, limited- + color, and noninteractive terminal modes with deterministic full, compact, + and micro rendering coverage. + +### Changed + +- Centralized native package recipes and guided setup so dry runs and executed + commands use the same platform-specific definitions. +- Expanded `radiocli doctor --json` with host, runtime, network, storage, + terminal, playback, desktop, scheduling, and optional-feature capabilities. +- Reorganized installation, platform, architecture, reliability, privacy, and + contributor documentation as stable reference material with explicit support + boundaries and troubleshooting paths. + +### Fixed + +- Kept playback and the TUI usable when optional history, presence, cache, or + library writes fail, while preserving recoverable data after interrupted or + permission-denied writes. +- Hardened alarm deletion, native scheduler cleanup, terminal reopening, Alarm + Guard ownership, system-volume restoration, and forced-process teardown. +- Preserved exact arguments, data locations, and supported environment selectors + across macOS, Linux, BSD, and Windows child processes without forwarding + unrelated shell state. +- Improved player discovery, mpv readiness and IPC handling, provider timeouts, + mirror fallback, offline cache behavior, and degraded terminal rendering. +- Recovered ARM Linux playback from mpv audio-service initialization failures + by retrying once with ALSA first, retained bounded player diagnostics, and + stopped system-wide backend failures from skipping through every station. +- Replaced stale Radio Browser hosts with live DNS service discovery and added + a TLS-verified resolver fallback for temporary hostname failures without + bypassing configured proxies. +- Preserved full RGB theme colors when a terminal explicitly advertises + truecolor, even when its Node.js stream reports a conservative 256-color + depth; explicit 256-color, 16-color, and no-color modes remain unchanged. +- Kept retained Search results playable after clearing the submitted query and + added `Ctrl+F` to favorite the selected result while query input is active, + without intercepting an ordinary `f` typed into the search text. +- Matched Search favorite confirmations to the Library by dismissing added and + removed messages automatically after the same brief interval. +- Corrected Windows terminal launches to create independent interactive console + handles and retain the intended user data location. + +### Security + +- Updated documentation-site dependencies to patched releases. +- Limited child-process environments to the settings required for RadioCLI data + identity and native integrations, excluding unrelated credentials and proxy + secrets. +- Made native cleanup and ownership checks fail closed when a job, helper, + process, or saved state cannot be identified safely. + ## 0.2.3 - 2026-09-07 ### Added diff --git a/apps/docs/content/docs/contributing.mdx b/apps/docs/content/docs/contributing.mdx index b8f5d66..2c5e9f8 100644 --- a/apps/docs/content/docs/contributing.mdx +++ b/apps/docs/content/docs/contributing.mdx @@ -15,18 +15,22 @@ npm run build npm run test ``` -Install `mpv` for the best playback experience: +Install `mpv` for playback controls and metadata polling. On a macOS +configuration supported by Homebrew: ```bash brew install mpv ``` -On Linux, use your package manager's `mpv` package. On Windows, use: +On Linux, use your package manager's `mpv` package. On Windows x64, use: ```powershell winget install --id shinchiro.mpv -e ``` +Use the [platform installation routes](platforms.mdx#installation-and-playback-prerequisites) +for Windows arm64 and other package managers. + ## Verification Run these before opening a pull request: @@ -36,17 +40,42 @@ npm run check npm run lint npm run test npm run build +npm run smoke:mcp +npm run pack:check +npm run check:package +npm run fresh:check -- --require-mpv +npm run docs:check +npm run docs:build +node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs ``` -Run `npm run smoke:playback` when changing playback code. It opens a public -stream briefly, so do not run it in environments where audio or network access -is not acceptable. +Run `npm run smoke:playback` when changing playback code. By default it generates +a local silent WAV and exercises an installed player. The packed smoke uses +real mpv with null audio and verifies IPC controls. A requested external stream +is a separate live-network test. + +Run `npm ci` in `apps/docs` before the documentation checks. Test maintained +Node 22/24 lines separately, and keep dependency installation separate from +concurrent tests that use the same `node_modules` directory. Live provider checks are useful before releases, but ordinary pull requests -should stay deterministic. Run `npm run verify:release` for a full maintainer +should stay deterministic. Run `npm run smoke:data` explicitly for live directory +verification. Run `npm run verify:release` for a full maintainer pre-release pass that includes live data, playback, packaging, and fresh-install checks. +For platform changes, record OS/product version, kernel, CPU and endianness, +libc where relevant, exact Node/player versions, artifact hash, and whether +execution was native, a same-CPU guest, foreign-CPU emulation, or a mock. +Compilation, a lockfile entry, or a downloaded executable cannot establish +runtime support. Use the [platform matrix](platforms.mdx) tier requirements. + +Review visual changes against the fixed-size captures described in +[Design](design.mdx#visual-regression-captures). Explain intentional changes and +preserve rich-terminal fixtures when adding degraded rendering. Exercise +permission failures and missing optional tools without weakening core playback, +authentication, or cleanup behavior. + ## Development Principles - Keep public provider adapters isolated. diff --git a/apps/docs/content/docs/design.mdx b/apps/docs/content/docs/design.mdx index 37be53a..3e8ae7c 100644 --- a/apps/docs/content/docs/design.mdx +++ b/apps/docs/content/docs/design.mdx @@ -1,6 +1,6 @@ --- title: Design -description: Product rationale for Ink, mpv, provider adapters, location behavior, and the receiver UI. +description: Product rationale for the receiver UI, terminal adaptation, playback backends, and provider adapters. --- RadioCLI is meant to feel like a product, not a terminal proof of concept. @@ -17,10 +17,10 @@ Radio streams are messy: redirects, playlists, HLS, odd codecs, and flaky metadata are normal. `mpv` already handles that complexity well and exposes JSON IPC for controls and metadata. -`ffplay` remains as a playback fallback because it is often already installed -with FFmpeg, but it does not provide the control and state surface RadioCLI needs -for a polished interactive receiver. `mpv` is the intended backend for pause, -mute, volume, media keys, metadata, and readiness checks. +`ffplay` and VLC provide playback fallbacks when `mpv` is unavailable. Their +limited control and state reporting is reflected in the UI. `mpv` is the +intended backend for pause, mute, volume, media keys, metadata, and readiness +checks. ## Why Provider Adapters @@ -48,9 +48,10 @@ The UI borrows from old receiver and car head-unit displays: direct labels, compact hierarchy, visible status, and no fake copy. The app should be usable in a terminal session, not a landing page rendered in ANSI. -The app shell paints its own near-black background instead of relying on the -terminal profile color. That keeps the receiver, tabs, stats panels, and footer -visually cohesive across terminal themes. +On terminals with sufficient color support, the app shell paints a near-black +background that keeps the receiver, tabs, stats panels, and footer visually +cohesive. Transparent Background, limited-color terminals, and `NO_COLOR` +let the terminal keep its own background. The Now Playing screen supports multiple visualizer styles so the display can feel like a receiver without locking the product into one animation. The default @@ -81,6 +82,47 @@ and computes local distance inside the app. The list reports the radius covered by the returned stations so the user can tell whether a cursor position is dense, sparse, or limited by provider geotag coverage. +## Terminal Adaptation + +Full, compact, and micro layouts keep the current selection and keyboard actions +available as space shrinks. Compact and micro views shorten labels and adjust +the receiver and list areas. The selected station's full name remains available +to screen-reader output. + +Unicode and rich color are retained when the terminal supports them. Explicit +ASCII settings or a bare `C`/`POSIX` locale replace decorative glyphs; UTF-8 +locales and SSH sessions retain Unicode. ASCII mode preserves international +station text. Sixteen-color terminals drop the dark fills, and `NO_COLOR` +removes color entirely. + +Reduce Motion stops receiver animation, loading animation, and scrolling labels. +Screen-reader mode also omits receiver visualizers and mouse reporting while +keeping keyboard controls and text status. `TERM=dumb` produces static frames +with ASCII decoration and no color. These environment adaptations apply to the +current launch and leave saved appearance preferences intact. The exact +overrides are listed in [TUI Controls](getting-started/tui-controls.mdx). + +### Visual Regression Captures + +The deterministic Ink snapshot suite covers every screen and settings group, +all receiver styles at full size, representative receivers at smaller sizes, +Unicode and ASCII decoration, output without color, and focused ANSI color and +selection checks. The fixtures use these sizes: + +| Layout | Columns × rows | +| --- | --- | +| Full | 100 × 30 | +| Compact | 50 × 16 | +| Micro | 24 × 8 | + +Time, timezone, station data, and animation frames are fixed so failures show +real rendering differences. ASCII fixtures use ASCII only for decoration while +preserving international station names. The +[snapshot suite](https://github.com/Ciphore/RadioCLI/blob/master/src/ui/visual-baseline.test.tsx) +verifies renderer output; native fonts, physical displays, audio, and desktop +integration are outside its scope. See [Platforms](/docs/platforms) for native +feature coverage. + ## Navigation And Stats The horizontal tab rail is intentionally dense: it keeps the app feeling like a diff --git a/apps/docs/content/docs/getting-started/install.mdx b/apps/docs/content/docs/getting-started/install.mdx index 59c08fc..b24cda0 100644 --- a/apps/docs/content/docs/getting-started/install.mdx +++ b/apps/docs/content/docs/getting-started/install.mdx @@ -14,17 +14,20 @@ radiocli setup radiocli ``` -On macOS, Homebrew is the all-in-one option because the formula also installs -Node.js, `mpv`, and FFmpeg: +On macOS configurations supported by Homebrew, its formula also installs Node.js, +`mpv`, and FFmpeg: ```bash brew install ciphore/tap/radiocli radiocli ``` -Both paths install the same `radiocli` command. The npm package is the universal -option; its guided `radiocli setup` step installs selected system playback tools -through Homebrew, WinGet, Scoop, Chocolatey, or the detected Linux package manager. +Both paths install the same `radiocli` command. The npm package also has +experimental community installation paths described below. `radiocli setup` +installs selected system playback tools only after showing a plan and receiving +confirmation or `--yes`. npm lifecycle scripts never install system packages. +Consult the [platform matrix](../platforms.mdx) before selecting a runtime or +claiming that a particular OS/CPU combination has been verified. ## Upgrade An Existing Installation @@ -49,7 +52,8 @@ older registration is already stale, run `radiocli mcp status` followed by RadioCLI needs: -- Homebrew on macOS: installs RadioCLI, Node.js, `mpv`, and FFmpeg +- Homebrew on its supported macOS configurations: installs RadioCLI, Node.js, + `mpv`, and FFmpeg - npm, pnpm, or Bun on macOS, Linux, and Windows: Node.js 22 or newer - `mpv` for playback, pause, mute, volume, media keys, metadata, and readiness checks @@ -71,19 +75,22 @@ npx @ciphore/radiocli This is the fastest way to try the TUI. You still need `mpv`, `ffplay`, or VLC for audio — run `npx @ciphore/radiocli doctor` to check. -## Recommended: macOS With Homebrew +## macOS With Homebrew -Homebrew is the lowest-friction macOS path. The tap formula installs RadioCLI, -Node.js, `mpv`, and FFmpeg through Homebrew: +The tap formula installs RadioCLI, Node.js, `mpv`, and FFmpeg through Homebrew. +Check Homebrew's current OS/CPU policy; Intel and older macOS installations may +need a compatible Node distribution and MacPorts or another native player route. +See the [platform matrix](../platforms.mdx#installation-and-playback-prerequisites). ```bash brew install ciphore/tap/radiocli radiocli ``` -Run `radiocli doctor` any time you want to verify local playback readiness. +Run `radiocli doctor` to check executable discovery and launch diagnostics. +Tuning a station verifies the actual audio path. -## Universal npm Install +## npm Install The npm package is `@ciphore/radiocli`, and the installed executable is `radiocli`. npm installs RadioCLI and its JavaScript dependencies only; it does @@ -97,7 +104,7 @@ radiocli The setup receiver detects tools that are already installed, offers `mpv`, FFmpeg/`ffplay`, and VLC, shows every native command before it runs, and finishes -with playback verification. `mpv` is selected by default everywhere. FFmpeg is +by checking executable availability. `mpv` is selected by default everywhere. FFmpeg is also selected by default on macOS because it enables experimental AirPlay. ```text @@ -127,8 +134,9 @@ radiocli setup --mcp --headless-agent # opt out of external terminal windows radiocli setup --no-mcp # disable/remove MCP entries ``` -Agent playback opens the RadioCLI TUI in a terminal window by default on every -platform. On macOS, the first request from each agent host (for example, ChatGPT) +Agent playback requests the RadioCLI TUI in a supported installed graphical +terminal. Missing desktop/session support is reported explicitly; select +`--headless-agent` for command-only playback. On macOS, the first request from each agent host (for example, ChatGPT) also produces Apple's Automation consent dialog. RadioCLI cannot grant that permission on the host's behalf; approve it when prompted. If a user does not want external terminal windows or OS app-control prompts, choose headless mode @@ -137,7 +145,9 @@ during setup or under **Settings → Agent control & MCP**. RadioCLI uses an animated activity bar and elapsed time while each package manager runs. Package managers do not report a portable, reliable percentage, so the bar intentionally shows activity rather than inventing completion data. -In redirected output or CI, setup emits plain start/result log lines instead. +In redirected output, `TERM=dumb`, screen-reader or reduced-motion mode, setup +emits plain start/result log lines instead. ASCII mode and the terminal's color +depth also apply to setup. pnpm and Bun can install the same package for users who already use those package managers: @@ -159,8 +169,9 @@ brew install ffmpeg ``` RadioCLI discovers AirPlay/RAOP receivers with Bonjour and decodes streams with -FFmpeg. The sender bridge is bundled with RadioCLI, so users should not install -extra AirPlay packages manually. Passcode-protected receivers prompt in the TUI; +FFmpeg. The sender is an optional package dependency and must install and load +successfully; `--omit=optional` leaves it out. Sender diagnostics report an absent +or unusable bridge. Passcode-protected receivers prompt in the TUI; enter the code with `:airplay-code 1234` or the AirPlay Code screen. RadioCLI requires an explicit AirPlay receiver selection from Settings or `:airplay` before it tunes through AirPlay. Changing Audio output while a station is @@ -197,9 +208,16 @@ radiocli setup radiocli ``` +On Alpine use a musl-native Node package; a glibc binary is not interchangeable. +Alpine and other musl targets remain experimental until the selected CPU and +runtime have repeatable execution evidence. On OpenRC, runit, a container, or any +system without an accessible systemd user manager, the player, library and TUI +remain usable. Background alarm scheduling is unavailable; RadioCLI does not +substitute cron/at jobs with weaker occurrence or snooze guarantees. + ## Windows -Use Windows Terminal or PowerShell with Node.js 22 or newer and native `mpv`: +Use Windows Terminal or PowerShell with a native Node 22/24 runtime and `mpv`: ```powershell winget install --id OpenJS.NodeJS.LTS -e @@ -222,8 +240,80 @@ scoop bucket add extras scoop install mpv ``` -WSL remains supported through the Linux install path, but native Windows is the -recommended Windows Terminal path. +Windows x86 has a Node 22 route; Node 24 no longer provides it. Windows ARM64 +needs matching native binaries: the WinGet mpv recipe above is x64-only, while +the Node LTS recipe and Scoop's mpv package have ARM64 routes. These catalogs do not establish +runtime or audio verification on their own. Windows XP and older Windows +versions below the standard runtime minimum have no legacy client. + +WSL uses the Linux installation path and remains experimental. Its Linux audio +path, user manager and graphical environment need separate verification; +Windows package discovery does not provide Linux playback tools. + +## BSD And Additional Runtimes + +Install a native Node 22+ runtime and matching npm first, then install the same +npm package. These are package instructions, with verification status in the +[platform matrix](../platforms.mdx). No new background alarm adapter is provided +for these systems. + +| Environment | Explicit prerequisite path | Limitations | +| --- | --- | --- | +| FreeBSD | As root: `pkg install -y node22 npm-node22 mpv` | Confirm packages for the OS/CPU; ffplay needs FFmpeg's SDL option. | +| OpenBSD | `doas pkg_add -I node mpv` | Packaged Node must be 22+; use the release's own repository. | +| NetBSD/pkgsrc | Install the matching X11 base set, then `pkgin -y install 'nodejs>=22<23' mpv` | The binary mpv package links to OS X11 libraries even for headless audio. Configure the correct pkgsrc repository; run as root or through a privilege helper. | +| Android/Termux | `pkg install -y nodejs-lts npm mpv` | Run as the app user without sudo/root; Android audio/background limits apply. | +| Haiku | `pkgman install -y mpv` | HaikuPorts' Node 20 is below the minimum; a working custom Node 22+ port is required before RadioCLI can run. | +| illumos/SmartOS | `pkgin -y install 'nodejs>=22<23' mpv` | Requires a configured compatible pkgsrc catalog; IPS `pkg` is a different tool and is not used. | +| AIX | Provision a compatible native Node 22/24 runtime manually | No verified mpv/ffplay package recipe or RadioCLI runtime/audio evidence. DNF availability alone is insufficient. | + +Setup detects FreeBSD `pkg`, OpenBSD `pkg_add`, pkgsrc `pkgin`, Haiku `pkgman`, +and a distinct `termux-pkg` recipe that invokes Termux's `pkg`. For example: + +```bash +radiocli setup --only=mpv --dry-run +radiocli setup --package-manager=termux-pkg --only=mpv --dry-run +``` + +Do not select the FreeBSD `pkg` recipe on Termux or illumos. Setup checks the host +before invoking these recipes and uses root, sudo or doas where applicable. +Termux's main FFmpeg package omits ffplay; enabling an X11 repository and its +display/audio services remains a manual choice. Haiku's `ffmpeg8_tools` recipe +does not resolve its Node runtime blocker. pkgsrc uses versioned FFmpeg/ffplay +pairs, so setup reports that component as manual rather than guessing a package. +On a minimal NetBSD installation, select the matching X11 base distribution set +through the OS installation tools before installing mpv. See NetBSD's +[distribution-set instructions](https://www.netbsd.org/docs/guide/en/chap-exinst.html); +the package manager cannot supply missing base-system X11 libraries itself. + +The recorded BSD guests installed a local packed tarball into fresh projects, +omitting the optional native macOS AirPlay sender. After providing the matching +Node/npm/player prerequisites, a manual registry installation uses: + +```bash +npm install -g --omit=optional @ciphore/radiocli +``` + +## Player Paths And Constrained Sessions + +Set an absolute executable path when the player is outside the standard search +locations. The value is a single executable, with no shell or appended arguments: + +```bash +RADIOCLI_MPV_PATH='/opt/Audio tools/mpv' radiocli doctor +``` + +PowerShell uses `$env:RADIOCLI_MPV_PATH = 'C:\Audio tools\mpv.exe'` before the +command. The other overrides are `RADIOCLI_FFPLAY_PATH`, `RADIOCLI_VLC_PATH` and +`RADIOCLI_FFMPEG_PATH`. Invalid explicit paths produce diagnostics instead of +silently choosing another executable. Run `radiocli alarm sync` after changing +the paths retained by native alarm jobs. + +For SSH, use a terminal allocation such as `ssh -t` for the TUI, or use CLI/MCP +commands and explicit headless playback. Browser, clipboard and terminal +reopening depend on the actual desktop session. See +[troubleshooting](../troubleshooting.mdx) for ASCII/color controls, offline caches, +proxies, IPv6 and writable data directories. ## Optional ffplay Fallback @@ -248,8 +338,9 @@ control. In `ffplay` mode RadioCLI labels the backend as `ffplay fallback`, shows limited-control footer text, and `radiocli doctor` reports `controls=limited`. -CI covers command-mode typecheck, tests, builds, package checks, and fresh -install smoke checks on Ubuntu, macOS, and Windows. +The required CI matrix covers Node 22/24 on Ubuntu, macOS and Windows with native +x64/arm64 jobs and packed-install mpv checks. BSD guest jobs are opt-in/weekly. +The platform matrix records actual results and the limits of those checks. ## Alarm Scheduling Requirements @@ -260,7 +351,7 @@ RadioCLI on supported hosts: - Native Windows uses Task Scheduler interactive-token tasks. - Linux requires a working systemd user manager and `systemctl --user`. -Linux systems without systemd user scheduling are reported as unsupported; WSL +Background alarms are unavailable without systemd user scheduling on Linux; WSL must provide a working user manager and an interactive audio path to use alarms. All platforms require a logged-in audio session and local speakers when the job runs. The terminal itself may close after an alarm is saved and registered. diff --git a/apps/docs/content/docs/getting-started/tui-controls.mdx b/apps/docs/content/docs/getting-started/tui-controls.mdx index 03f6dc5..b726f46 100644 --- a/apps/docs/content/docs/getting-started/tui-controls.mdx +++ b/apps/docs/content/docs/getting-started/tui-controls.mdx @@ -1,6 +1,6 @@ --- title: TUI Controls -description: Navigation, playback, settings, command palette, and learned media keys. +description: Navigation, playback, terminal rendering, accessibility, and learned media keys. --- RadioCLI keeps shortcuts at the bottom of the terminal. When playback is active, @@ -17,7 +17,8 @@ volume or mute state, and an active sleep timer. | `t` | Cycle display color | | `v` | Cycle receiver style | | `+` / `-` | Adjust volume | -| `f` | Favorite the selected or playing station | +| `f` | Favorite the selected or playing station outside active text input | +| `Ctrl+F` | Favorite the selected Search result while its query input is active | | `a` | Schedule the selected or playing station | | `!` | Open controls for any alarm already playing in the background | | `O` | Open the station homepage in your browser | @@ -26,7 +27,7 @@ volume or mute state, and an active sleep timer. | `q` or `Ctrl+C` twice | Quit cleanly | Pause, mute, volume, and play/pause media-key control require the `mpv` backend. -When only `ffplay` is active, RadioCLI keeps playback and station skipping +When `ffplay` or VLC is active, RadioCLI keeps playback and station skipping available but labels interactive playback controls as limited and shows an `Install mpv` warning instead of pretending the control worked. @@ -35,7 +36,7 @@ available but labels interactive playback controls as limited and shows an | Screen | Controls | | --- | --- | | Overview | `Up` / `Down` move, `Enter` open, number jump, `:` command | -| Search input | type query, `Up` / `Down` move results, `Ctrl+Up` / `Ctrl+Down` recall search history, `Enter` search or tune, `Esc` finish | +| Search input | type query, `Up` / `Down` move results, `Ctrl+Up` / `Ctrl+Down` recall search history, `Enter` search or tune, `Ctrl+F` favorite, `Esc` finish | | Search results | `/` edit query, `Up` / `Down` or `n` / `p` move, `Enter` tune, `f` favorite, `b` home | | Explore | click map, `WASD` fine move, `Shift+WASD` jump, `Up` / `Down` station, `Enter` tune, `f` favorite, `[` / `]` page, `b` home | | Countries | `/` filter, `Up` / `Down` move, `Enter` open stations, `w` map, `b` home | @@ -56,7 +57,8 @@ Explore keeps an aspect-correct Braille world map in full, compact, and micro layouts. Compact windows always place stations beside the map, while true micro places the selected station below it. Long selected station and metadata lines scroll after a short pause in compact and micro layouts. Enable Reduce Motion to -keep those lines truncated instead. +keep those lines truncated instead. ASCII mode substitutes plain characters for +the map's decorative glyphs. Press `?` from any screen to open Help, a full reference of keybindings and `:` commands. In the command palette, `Tab` completes the command name. @@ -82,7 +84,7 @@ The editor covers: - independent alarm volume, fade-in seconds, automatic stop time, optional fallback station, and fixed local-speaker output - missed-run grace, best-effort OS wake request, and Alarm Guard -- a next-occurrence review, a no-state-change test tune, Save, and Cancel +- a next-occurrence preview, a no-state-change test tune, Save, and Cancel Time is a segmented digital control whose active hours or minutes are highlighted in the current display color. Use `Up` and `Down` to change hours, press `Enter` @@ -175,17 +177,63 @@ Useful command entries: ## Display And Compatibility Modes Settings includes toggles for terminals and devices that need them, each also -available as a command: +available as a command. These choices are saved in your local library: - **Resume last station on launch** (`:resume on`) re-tunes your most recent station when RadioCLI starts, like a radio powering back on. - **Transparent background** (`:background on`) drops the dark panel fill so - light-themed terminals keep their own background. RadioCLI also honors the - [`NO_COLOR`](https://no-color.org) convention automatically. + light-themed terminals keep their own background. - **ASCII-safe display** (`:ascii on`) maps braille, block, and box-drawing - glyphs to ASCII for terminals and fonts without those glyphs. + glyphs, borders, and decorative punctuation to ASCII. International station + names and metadata remain intact; this is not a transliteration setting. - **Reduce motion** (`:motion on`) freezes the receiver and loading animations - to save CPU and bandwidth over SSH and on low-power devices. + and keeps long lines truncated instead of scrolling. + +RadioCLI retains its Unicode receiver display on terminals that support it, +including UTF-8 sessions over SSH. Locale detection checks the first non-empty +value in `LC_ALL`, `LC_CTYPE`, then `LANG`. A bare `C` or `POSIX` locale selects +ASCII decoration; UTF-8 locales such as `C.UTF-8` do not. SSH alone does not +change the display mode. + +Environment variables override rendering for that launch without rewriting +saved appearance settings: + +| Variable | Effect | +| --- | --- | +| `RADIOCLI_ASCII=1` | Force ASCII decoration | +| `RADIOCLI_UNICODE=1` | Force Unicode decoration unless ASCII is also explicitly enabled or `TERM=dumb` | +| `NO_COLOR=1` | Remove foreground colors and background fills; any non-empty value enables this | +| `FORCE_COLOR=0`, `1`, `2`, or `3` | Select no color, 16 colors, 256 colors, or truecolor | +| `RADIOCLI_DISABLE_ANIMATION=1` | Freeze receiver and loading animations and stop scrolling text | +| `RADIOCLI_SCREEN_READER=1` or `INK_SCREEN_READER=true` | Enable screen-reader output with text status, no receiver visualizers, no animation, and no mouse reporting | +| `TERM=dumb` | Show static frames with ASCII decoration and no color or animation | + +Detected 16-color terminals retain their own background; 256-color and truecolor +terminals can use the dark receiver panels. `NO_COLOR`, screen-reader mode, and +`TERM=dumb` take precedence over `FORCE_COLOR`. + +For one launch in a POSIX shell: + +```bash +RADIOCLI_ASCII=1 RADIOCLI_DISABLE_ANIMATION=1 radiocli +``` + +In PowerShell, set the variables for the current shell before launching: + +```powershell +$env:RADIOCLI_SCREEN_READER = '1' +radiocli +``` + +Screen-reader mode keeps keyboard navigation, station and playback labels, +selection, and command entry available. Decorative receiver frames are omitted, +and long labels are exposed as text without a scrolling marquee. + +The TUI requires interactive keyboard input, including with `TERM=dumb`. +Use [CLI commands](cli.mdx) such as `radiocli doctor --json` and +`radiocli search "jazz"` in pipelines or scripts. For display problems, see +[Troubleshooting](../troubleshooting.mdx); runtime and optional feature coverage +is listed under [Platforms](/docs/platforms). ## Source-List Queues diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 6ba0170..3a79433 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -3,15 +3,16 @@ "pages": [ "index", "getting-started", + "platforms", "alarms", "demo", "architecture", "design", "reliability", + "troubleshooting", "privacy-security", "contributing", "release-packaging", - "changelog", - "roadmap" + "changelog" ] } diff --git a/apps/docs/content/docs/platforms.mdx b/apps/docs/content/docs/platforms.mdx new file mode 100644 index 0000000..0662f87 --- /dev/null +++ b/apps/docs/content/docs/platforms.mdx @@ -0,0 +1,217 @@ +--- +title: Platforms +description: Runtime requirements, installation routes, native feature limits, and the evidence behind RadioCLI's platform tiers. +--- + +RadioCLI requires Node.js 22 or newer. Local audio needs a working player. +The reference matrix below covers Node.js 22 and 24. Runtime eligibility, +installed tools, and verified RadioCLI behavior are tracked separately for each +OS version, CPU, libc, and Node release line. + +Run `radiocli doctor --json` to inspect the capabilities of the current machine. +Use `radiocli setup --dry-run` to preview the native playback packages and +commands for the detected operating system. + +## Support Tiers + +| Tier | Required evidence | +| --- | --- | +| First-class | A repeatable installation with verified prerequisites, packed runtime and playback checks on the target, and maintained required CI whose failures block releases | +| Supported | A verified, repeatable runtime, packed installation, and playback path without required CI coverage | +| Experimental | A code path or package route exists, but project verification is incomplete | +| Unsupported | A known requirement blocks the configuration, such as Node.js below 22 or an incompatible OS/CPU runtime | + +A package recipe, a mocked platform test, or a downloaded binary does not +establish a verified installation. A Linux container exercises its host kernel; +a full BSD guest exercises a BSD kernel. Foreign-CPU emulation is recorded as +emulation. Each result applies to the configuration and artifact actually tested. + +The upstream Node rating is a separate axis: repeatable project evidence can +establish a supported configuration on a community runtime. Known runtime +requirements still apply. + +Doctor reports `support.scope=current-installation`. It checks runtime +requirements and available tools without running packed-install or audio +acceptance tests, and does not import a reference machine's CI evidence. Its +`support.tier` therefore remains experimental for an unverified local installation, +even when the reference configuration below is supported or first-class. A known +runtime blocker is reported as unsupported. Individual capability results explain +what can be used in the current session. + +## Tested Configurations + +The required CI matrix covers macOS, Ubuntu, and Windows on Node.js 22 and 24. +BSD package checks run in full guest kernels. The table describes exactly what +those checks exercise; it does not imply support for unlisted desktop, audio, +terminal, or hardware combinations. + +| Configuration | Automated coverage | Project tier | +| --- | --- | --- | +| macOS arm64; Node 22 and 24 | Full tests plus packed installation with normal and omitted optional dependencies; native mpv decoding and IPC controls through null audio output | First-class | +| macOS Intel x64; Node 22 and 24 | Full tests plus both packed-install and native mpv playback modes | First-class | +| Ubuntu glibc x64 and arm64; Node 22 and 24 | Full tests, both packed-install modes, native mpv IPC playback, and separate source playback checks | First-class | +| Windows Server x64; Node 22 and 24 | Full tests, both packed-install modes, native mpv named-pipe playback, PowerShell argument handling, and console TTY checks | First-class for the tested server environment; Windows desktop behavior depends on the local terminal and audio setup | +| Windows 11 arm64; Node 22 and 24 | Full tests, both packed-install modes, native arm64 mpv named-pipe playback, PowerShell argument handling, and console TTY checks | First-class for the tested Windows 11 environment | +| FreeBSD 14.4 and OpenBSD 7.9 x64; Node 22 | Packed CLI, cache, import/export, MCP, and native mpv IPC playback with `--omit=optional` in full QEMU guests | Supported for the core package route; desktop applications and background scheduling are not covered | +| NetBSD 10.1 x64; Node 22 | The same core guest checks after installation of the matching X11 base libraries | Supported for the core package route; desktop applications and background scheduling are not covered | +| Alpine/musl, WSL, other Linux CPUs, other BSD CPUs, Termux, illumos, AIX | Runtime and package detection paths are available, but complete installation and playback checks are not part of the maintained matrix | Experimental | +| Standard HaikuPorts Node 20 installation; Node below 22; Windows XP; 32-bit macOS; Windows x86 with Node 24 | The normal runtime route does not meet RadioCLI's requirement. A Haiku player package does not resolve its Node version blocker. | Unsupported for these configurations | + +The native matrix contains 12 required OS, CPU, and Node combinations: macOS +arm64 and Intel, Ubuntu x64 and arm64, and Windows x64 and arm64, each on Node 22 +and 24. A configured job counts as first-class only while its installation, +test, packaging, and playback checks remain required release gates. + +The mpv checks verify decoding and IPC through null audio output. Physical audio +devices, terminal applications, screen readers, desktop window opening, and +actual wake delivery depend on the user's hardware and operating-system policy. +The [visual regression suite](design.mdx#visual-regression-captures) verifies +terminal output at fixed sizes. + +## Node.js Runtime Reference + +The version numbers here are upstream binary or build baselines. Node.js also +excludes OS releases whose vendor support has ended, even if they meet a listed +minimum. A newer Node major may meet RadioCLI's package minimum while remaining +outside this Node 22/24 reference matrix. The primary references are the +[Node 22 platform list](https://github.com/nodejs/node/blob/v22.x/BUILDING.md#platform-list) +and [Node 24 platform list](https://github.com/nodejs/node/blob/v24.x/BUILDING.md#platform-list). + +| System and CPU | OS / libc baseline | Node 22 upstream status | Node 24 upstream status | +| --- | --- | --- | --- | +| macOS x64 / arm64 | macOS 11 for Node 22; macOS 13.5 for Node 24 | Tier 1 | Tier 1 | +| Windows x64 | Windows 10 / Server 2016 or newer | Tier 1 | Tier 1 | +| Windows arm64 | Windows 10 or newer | Tier 2 | Tier 2 | +| Windows x86 | Windows 10 or newer; upstream tests use WoW64 | Binaries provided; running is Tier 1, native compilation experimental | No Windows x86 runtime | +| glibc Linux x64 / arm64 | Kernel 4.18+, glibc 2.28+ | Tier 1 | Tier 1 | +| glibc Linux ARMv7 | Kernel 4.18+, glibc 2.28+ | Tier 1 | Experimental; community build needed | +| glibc Linux ppc64le / s390x | Kernel 4.18+, glibc 2.28+; POWER8+ for ppc64le | Tier 2 | Tier 2 | +| glibc Linux x86 | Kernel 3.10+, glibc 2.17+ | Experimental | Experimental | +| glibc Linux riscv64 | Node 24 reference: kernel 5.19+, glibc 2.36+ | Not listed; community route needs verification | Experimental | +| Linux big-endian ppc64 | A compatible community Node 22+ port must be established | No standard upstream route listed | No standard upstream route listed | +| musl Linux x64, including Alpine | Kernel 3.10+, musl 1.1.19+; use a musl build | Experimental | Experimental | +| musl Linux arm64 / s390x; ARMv7 on Node 22 | Distribution-specific build and runtime libraries | Distribution route; verify the selected package | Distribution route for arm64 / s390x; ARMv7 unverified | +| WSL | A compatible Linux runtime inside the selected WSL distribution | WSL-specific issues are outside upstream native Linux support | Same limitation | + +The glibc release binaries also need a compatible libstdc++; the reference +symbol version is `GLIBCXX_3.4.25`, or `GLIBCXX_3.4.28` for Node 22 ARMv7. +Linux ppc64le is little-endian; it is distinct from both Linux big-endian ppc64 +and the big-endian POWER runtime used by AIX. A generic `arm` label alone does +not establish ARMv7 compatibility. + +Node 24 publishes x64-musl archives while retaining an experimental musl +rating. Its current release files do not include ARMv7 binaries. Use the +[Node 24 release manifest](https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt) +and [distribution image architecture records](https://github.com/nodejs/docker-node/blob/main/versions.json) +to check the available build; a glibc Linux archive is not a musl or Android +Bionic archive. Distribution images do not establish RadioCLI audio or desktop +verification. + +### Additional OS Runtime Routes + +| System | OS / CPU reference | Node route and limit | +| --- | --- | --- | +| FreeBSD | Upstream experimental baseline: FreeBSD 13.2+ x64; verified guest: 14.4 x64 | Node 22/24 ports exist. Other CPUs use port-specific routes; an architecture allowlist does not establish a binary package for every release. | +| OpenBSD | Release and CPU must provide Node 22+; verified guest: 7.9 x64 | The current Node port is 24.x and lists amd64, aarch64, i386, powerpc64, and riscv64. Release binary repositories can supply an older Node; the recorded 7.9 guest supplied Node 22.23.2. | +| NetBSD | Release-specific pkgsrc packages; selected guest: 10.1 x64 | Node 22/24 pkgsrc recipes exist. The guest supplied Node 22.23.0. Other CPUs need separate package and runtime verification. | +| Android / Termux | Android 7+ with Termux's Bionic packages | `nodejs-lts` currently provides Node 24. Recipe targets include aarch64, arm, i686, and x86_64; device execution is unverified. Android outside Termux has no established RadioCLI install route. | +| SmartOS | Upstream reference: SmartOS 18+ x64 | Node 22/24 list Tier 2; use a suitable maintained distribution or pkgsrc build. | +| Other illumos distributions | x64 package routes depend on the distribution | Generic SunOS kernel 5.11 does not identify SmartOS or prove its version requirements. Verify the selected Node port and backend. | +| AIX | AIX 7.2 TL04+, big-endian POWER8+ | Node 22/24 list Tier 2; Node 24 also needs libstdc++12. A native player installation route remains unverified. | +| Haiku | A custom working Node 22+ port would be required | The inspected HaikuPorts catalog provides Node 20.15.1, below RadioCLI's minimum. The normal package route is blocked. | + +These routes come from the [FreeBSD Node ports](https://github.com/freebsd/freebsd-ports/blob/main/www/node22/Makefile), +[OpenBSD Node port](https://github.com/openbsd/ports/blob/master/lang/node/Makefile), +[pkgsrc Node 22](https://github.com/NetBSD/pkgsrc/blob/trunk/lang/nodejs22/Makefile) +and [Node 24 recipes](https://github.com/NetBSD/pkgsrc/blob/trunk/lang/nodejs24/Makefile), +[Termux installation requirements](https://github.com/termux/termux-app#installation), +[Termux Node LTS recipe](https://github.com/termux/termux-packages/blob/master/packages/nodejs-lts/build.sh), +and [HaikuPorts Node catalog](https://github.com/haikuports/haikuports/tree/master/net-libs/nodejs). +They describe available routes, not completed RadioCLI tests. + +## Installation And Playback Prerequisites + +After installing a compatible Node runtime and npm, the standard package command +is `npm install -g @ciphore/radiocli`. `mpv` provides IPC playback controls; +`ffplay` and VLC are limited-control fallbacks. Custom player installations can +use the [absolute path overrides](troubleshooting.mdx#playback-tool-installed-but-not-found). + +The recipes below were checked against package sources. Only the recorded +configurations in the evidence table have completed RadioCLI verification. +System package commands need the host's appropriate administrator access; +Termux `pkg` and Haiku `pkgman` run without `sudo`. + +| System | Playback / runtime route | Packaging limits and primary source | +| --- | --- | --- | +| macOS | Compatible Node plus npm; Homebrew `mpv`, or `sudo port install mpv` with MacPorts | Homebrew's OS/CPU policy is narrower than Node's. [MacPorts mpv](https://ports.macports.org/port/mpv/) is a separate route. | +| Windows | x64: `winget install --id shinchiro.mpv -e`; arm64: Scoop's `mpv`; x86: a matching native i686 build | The checked [WinGet mpv manifest](https://github.com/microsoft/winget-pkgs/blob/master/manifests/s/shinchiro/mpv/0.41.0/shinchiro.mpv.installer.yaml) is x64-only. [Scoop](https://github.com/ScoopInstaller/Extras/blob/master/bucket/mpv.json) lists x64 and arm64; [mpv release assets](https://github.com/mpv-player/mpv/releases/tag/v0.41.0) also include i686. | +| glibc Linux | `mpv` from the configured distribution catalog; setup selects apt, dnf, pacman, or zypper | Verify the actual Node version and media repository. A distribution's `nodejs` package can still be below 22. See [mpv's package references](https://mpv.io/installation/). | +| Alpine / musl | `apk add nodejs npm mpv`, with a compatible repository | The inspected [Alpine 3.24 x64 Node package](https://pkgs.alpinelinux.org/package/v3.24/main/x86_64/nodejs) is 24.x. Other releases/CPUs need separate checks. | +| FreeBSD | `pkg install -y node22 npm-node22 mpv`, or a matching Node 24/npm pair | Node ports omit npm, so install the matching npm package. [FFmpeg's SDL option](https://github.com/freebsd/freebsd-ports/blob/main/multimedia/ffmpeg/Makefile) controls whether `ffplay` is present. | +| OpenBSD | `doas pkg_add -I node mpv` | Check that the selected release supplies Node 22+. The [`pkg_add` manual](https://man.openbsd.org/pkg_add) defines `-I` as noninteractive. | +| NetBSD / pkgsrc | Install the matching NetBSD X11 base set, then `pkgin -y install 'nodejs>=22<23' mpv`, when that line exists in the repository | The binary mpv package links to system X11 libraries even for null output; a minimal OS image can lack them. Binary Node packages are named `nodejs`, with npm included. Use an available Node 24 range if needed. [pkgin](https://pkgin.net/) manages the catalog; [pkgsrc ffplay](https://github.com/NetBSD/pkgsrc/blob/trunk/multimedia/ffplay7/Makefile) uses versioned packages and executables such as `ffplay7`. | +| SmartOS / illumos | Bootstrap the appropriate pkgsrc environment, then use its `pkgin` Node/mpv packages | RadioCLI's setup recipe uses existing `pkgin`; it does not treat IPS `pkg` as FreeBSD `pkg`. See [SmartOS packages](https://docs.smartos.org/working-with-packages/) and [illumos binary packages](https://pkgsrc.smartos.org/). | +| Termux | `pkg install -y nodejs-lts npm mpv` as the normal app user | Setup identifies this as `termux-pkg`. Its [main FFmpeg recipe](https://github.com/termux/termux-packages/blob/master/packages/ffmpeg/build.sh) omits `ffplay`; the [separate ffplay package](https://github.com/termux/termux-packages/blob/master/x11-packages/ffplay/build.sh) needs `x11-repo` and its display/audio environment. | +| Haiku | `pkgman install -y mpv`; `ffmpeg8_tools` provides FFmpeg/ffplay | These [player recipes](https://github.com/haikuports/haikuports/tree/master/media-video) do not resolve the Node 20 runtime blocker. No working normal RadioCLI install is claimed. | +| AIX | Provision a compatible Node runtime and native player manually | No verified `mpv` or `ffplay` package recipe was established. The [IBM Toolbox catalog](https://www.ibm.com/support/pages/aix-toolbox-open-source-software-downloads-alpha) is not evidence that a `dnf install mpv` command exists. | + +Homebrew applies its own [support-tier policy](https://docs.brew.sh/Support-Tiers) +to Intel macOS. That policy is independent of Node's x64 runtime eligibility. +Likewise, a Windows x64 media package does not establish native ARM64 or x86 +playback; use an executable built for the target architecture. + +The optional AirPlay sender has native installation dependencies. A package +installed with `--omit=optional` retains the normal local-player path but omits +that sender. Installs with and without optional dependencies are separate +acceptance checks; neither establishes an AirPlay receiver connection. +The recorded BSD guests installed the packed tarball with `--omit=optional` +in fresh temporary projects and executed the generated npm bin link. For a +manual registry installation, use `npm install -g --omit=optional @ciphore/radiocli`; +that global registry route and normal optional-dependency installation were +not the recorded guest checks. + +## Playback And Desktop Compatibility + +These tables describe implemented adapters and their prerequisites. Tool +discovery is checked separately from successful execution. Shared TUI code +supports Unicode, ASCII decoration, limited color, screen-reader output, and +reduced motion; it still needs interactive keyboard input. See +[TUI Controls](getting-started/tui-controls.mdx) for rendering options. + +| Host | Local playback / TUI | Browser and clipboard | Terminal reopening | +| --- | --- | --- | --- | +| macOS | mpv Unix-socket IPC; ffplay/VLC fallback; shared TUI | `open` and `pbcopy` in a local desktop session | Supported installed macOS terminal; Apple Terminal/iTerm2 may require Automation consent | +| Windows | mpv named-pipe IPC; ffplay/VLC fallback; shared TUI | `explorer` and `clip` in a local desktop session | Windows Terminal or a PowerShell console | +| Linux, glibc or musl | Shared player/TUI paths; actual backend and audio service required | `xdg-open`; `wl-copy`, `xclip`, or `xsel`, with an appropriate desktop session | An installed supported GUI terminal and display session | +| WSL | Linux player/TUI paths require separate WSL verification | Linux session helpers; Windows desktop access is not inferred | Linux terminal path; depends on the guest's display/session | +| FreeBSD / OpenBSD / NetBSD | Shared player/TUI paths; exact guest results above do not establish a desktop terminal application | `xdg-open` and the available Wayland/X11 clipboard helper | Unix terminal adapter; installed terminal and display required, launch unverified | +| Termux | Bionic runtime and Termux mpv path; device/TUI/audio execution unverified | `termux-open-url`; `termux-clipboard-set` needs Termux:API | No separate terminal-reopening adapter | +| illumos / AIX / custom Haiku or other ports | Core execution requires a separately verified Node/player pair | No automatic browser or clipboard adapter | No terminal-reopening adapter | + +The Termux helper contracts are defined by +[`termux-open-url`](https://github.com/termux/termux-tools/blob/master/scripts/termux-open-url.in) +and [`termux-clipboard-set`](https://github.com/termux/termux-api-package/blob/master/scripts/termux-clipboard-set.in). +Clipboard access also requires the helper package and a compatible, correctly +signed [Termux:API app](https://github.com/termux/termux-api#installation); +Android permission and execution checks still apply. + +## Alarm, Power, And Output Paths + +| Host | Background alarms | System volume | Sleep inhibition and wake | AirPlay | +| --- | --- | --- | --- | --- | +| macOS | Per-user launchd LaunchAgent | macOS output-volume scripting | `caffeinate` for idle sleep; no privileged exact-wake event | Experimental; FFmpeg, Bonjour, usable sender, and explicit receiver selection required | +| Windows | Task Scheduler with interactive user token | Core Audio through PowerShell | `SetThreadExecutionState`; `WakeToRun` can be requested, subject to hardware and policy | No adapter | +| Linux, including musl | Accessible systemd user manager and timer required | Working `wpctl`, `pactl`, or `amixer` | Live `systemd-inhibit` lease; no privileged `WakeSystem` request | No adapter | +| WSL | Linux systemd user path only when its session is usable; unverified | Linux mixer path, not Windows host volume | Linux inhibitor path; Windows wake behavior is not inferred | No adapter | +| FreeBSD / OpenBSD / NetBSD | No background scheduler adapter | No system-volume adapter | No sleep-inhibitor or wake adapter | No adapter | +| Termux / illumos / AIX / Haiku / other ports | No background scheduler adapter | No system-volume adapter | No sleep-inhibitor or wake adapter | No adapter | + +Finding `systemctl` does not prove a working systemd user manager. Finding a +mixer or inhibitor command does not prove it can control this session. Player +volume remains separate from system output volume, and local playback can remain +usable when optional desktop or scheduling features are unavailable. + +Use `radiocli alarm doctor` and **Verify alarm setup** for the current machine. +The latter performs native actions and bounded sample playback. The +[Alarms guide](alarms.mdx), [Reliability](reliability.mdx), and +[Troubleshooting](troubleshooting.mdx) describe repair paths and delivery limits. diff --git a/apps/docs/content/docs/privacy-security.mdx b/apps/docs/content/docs/privacy-security.mdx index 83c064c..707892c 100644 --- a/apps/docs/content/docs/privacy-security.mdx +++ b/apps/docs/content/docs/privacy-security.mdx @@ -8,11 +8,17 @@ RadioCLI does not require secrets or accounts. ## Local Agent Control Agent control is opt-in. Its MCP transport is stdio, and active playback uses a -short-lived authenticated HTTP endpoint bound only to `127.0.0.1`. The random -bearer token and port are stored in a user-private runtime file. Requests are -serialized, stale discovery records are removed, and a single-owner lock keeps -two local agents from starting competing players. The endpoint is not exposed -to the LAN or internet. +short-lived authenticated HTTP endpoint bound to literal loopback: `127.0.0.1`, +with `::1` as the fallback when IPv4 loopback is unavailable. The random bearer +token, address, and port are stored in a user-private runtime file. Requests +are serialized, and a single-owner lock keeps two local agents from starting +competing players. The endpoint is not exposed to the LAN or internet. + +Agent, alarm, and Guard clients accept only those literal loopback addresses. +They make direct Node HTTP requests with `agent: false`, so public HTTP proxy +settings cannot forward local control or bearer tokens to a proxy. Loopback +requests still require authentication; their local address does not replace +the token check. MCP and `radiocli agent` can play only stations saved by RadioCLI or opaque IDs returned by its search and browse operations. The agent interface does not @@ -28,7 +34,7 @@ external terminal windows or app-control prompts. ## Privacy Notes - Nearby location is enabled by default for new libraries, but no location - request is made until you open Nearby. + request is made until you open Nearby; offline mode blocks that request. - Location lookup uses approximate IP-based location from `ipapi.co`. Disable it with `l`, Settings, or `:location off`. - Explore uses only the cursor coordinate you move in the terminal. @@ -37,10 +43,42 @@ external terminal windows or app-control prompts. - RadioCLI does not proxy audio. It resolves public stream URLs and hands playback to `mpv`, `ffplay`, or the experimental macOS AirPlay worker. - AirPlay discovery uses Bonjour on your local network. AirPlay streaming runs - through a local worker and the bundled sender bridge. + through a local worker and the bundled sender bridge. Offline mode blocks + receiver discovery. Low-bandwidth mode skips automatic discovery but permits + an explicit refresh. - Favoriting a Radio Browser station sends a best-effort vote to that public directory by default. Disable **Share favorite votes with Radio Browser** in - Settings to keep favorites local-only. + Settings to keep favorites local-only. Offline mode also prevents the vote + request. + +## Public Network Controls + +`RADIOCLI_OFFLINE=1` blocks public HTTP requests made by RadioCLI's Node client. +It can still read cached directory data and hand a saved direct URL to the +native player when the user asks to tune. The player's stream connection is +outside this directory-request policy; offline mode is not an OS firewall or +an audio-download feature. + +`RADIOCLI_LOW_BANDWIDTH=1` avoids downloading the geotagged station atlas and +uses a cached copy when available. Smaller searches, location lookups, health +checks, and manual controls remain available, so it is not a privacy substitute +for offline mode or disabling location and directory voting. + +Both flags suppress automatic AirPlay discovery, and offline mode also blocks +manual receiver discovery before starting Bonjour. Existing authenticated +loopback controls and the stdio MCP transport continue to work offline. + +Public HTTP(S) proxies require a compatible Node release and +`NODE_USE_ENV_PROXY=1` at process startup. `NO_PROXY` and the proxy URLs are +handled by Node; RadioCLI does not install a global dispatcher or change TLS +verification. Invalid proxy configuration errors identify the setting without +echoing credentials. SOCKS and `ALL_PROXY` alone are unsupported. See +[Reliability](./reliability.mdx#network-requests) for version requirements and +the distinction between public requests and local control. + +Homepage actions validate HTTP(S) URLs before opening or displaying them. +Stream-copy fallbacks validate media targets and sanitize terminal text, so a +rejected URL is not echoed into the terminal. ## Alarm Data And Local Control @@ -55,28 +93,56 @@ The following state is deliberately machine-local and excluded from backups: - scheduler and power-inhibitor health records - Alarm Guard process/lease state and occurrence locks - active-alarm discovery files and their random bearer tokens +- shared system-volume snapshots and participating process IDs + +System-volume ownership lives in `.radiocli/system-volume` under the OS account's +home directory. It is shared across `RADIOCLI_HOME` and XDG profiles so separate +alarm runners cannot restore the same output device independently. The saved +snapshot is removed after successful final restoration or playback handoff. After restoring a backup on another machine, run `radiocli alarm sync` to create that machine's native jobs. Alarm playback resolves the configured public station and connects to its stream just like normal playback; RadioCLI does not upload the alarm or proxy its audio. -While an alarm is playing, the detached runner opens an HTTP control endpoint on -`127.0.0.1` only. Discovery files and 32-byte random bearer tokens use private -machine-local storage permissions where the OS supports POSIX modes. Dismiss, -snooze, keep-playing, and Guard ownership requests must authenticate; there is no -network-facing control server. The detached runner may open the locally saved -terminal emulator to expose those controls. It does not pass the bearer token on -the command line; the TUI discovers it through the private machine-local file. +While an alarm is playing, the detached runner opens its own authenticated +loopback endpoint using the same IPv4-to-IPv6 fallback. New control files store +32-byte random bearer tokens with mode `0600`; new directories request mode +`0700` where the filesystem enforces POSIX modes. Windows relies on private +user-directory ACLs; POSIX mode arguments do not configure those ACLs. Dismiss, +snooze, keep-playing, and Guard ownership requests must authenticate. The detached +runner may open the locally saved terminal emulator to expose those controls. +It does not pass the bearer token on the command line; the TUI discovers it +through the private machine-local file. + +A Guard PID is insufficient evidence of ownership. If its authenticated +challenge fails while the process is still alive, or its ownership record is +unreadable, RadioCLI retains the record for repair and does not signal that PID. +An ambiguous native-job removal likewise retains the definition and artifacts +instead of hiding a possibly active registration. + +## Local Data Integrity + +Library access failures are reported without exposing the private storage path. +Permission and I/O errors do not rename intact data as corrupt or reset the +library. A corrupt library is reset only after its original bytes have been +preserved in a backup. Failed atomic writes leave the previous saved state +intact; readable read-only libraries remain available to the TUI, with warnings +for optional history that could not be saved. + +The platform changes preserve the library and backup schemas, existing +`RADIOCLI_HOME` and `RADIO_ATLAS_HOME` overrides, legacy Radio Atlas data paths, +and existing runtime namespaces. See [Architecture](./architecture.mdx#persistence) +for the separation between portable library data and machine-local records. ## Location Lookup Nearby stations are useful, but IP-based location lookup is privacy-sensitive. For new libraries the feature is on, and opening Nearby requests an approximate -location from `ipapi.co`. RadioCLI uses the returned city, region, country, -latitude, and longitude to sort the local geotagged station atlas. Turn it off -before opening Nearby with Settings or `:location off` if you do not want that -request; the choice is stored locally. +location from `ipapi.co` unless offline mode is active. RadioCLI uses the +returned city, region, country, latitude, and longitude to sort the local +geotagged station atlas. Turn it off before opening Nearby with Settings or +`:location off` if you do not want that request; the choice is stored locally. ## Reporting Security Issues diff --git a/apps/docs/content/docs/release-packaging.mdx b/apps/docs/content/docs/release-packaging.mdx index 655868f..cb0c2d4 100644 --- a/apps/docs/content/docs/release-packaging.mdx +++ b/apps/docs/content/docs/release-packaging.mdx @@ -14,9 +14,10 @@ packages from lifecycle scripts. The explicit, user-invoked `radiocli setup` command may install selected native tools after displaying and confirming its plan. Native playback and interactive controls come from `mpv`, with `ffplay` from FFmpeg as an optional playback-only fallback. -Linux users install `mpv` through their distro package manager. Windows users -install Node.js and `mpv` through `winget` or Scoop, then install RadioCLI -through npm. +Install a compatible Node runtime and a native player before using local audio. +The [platform matrix](platforms.mdx) records the verified OS/CPU combinations, +runtime minimums, package routes, and limitations. npm installation alone does +not establish native playback or desktop integration. ## NPM Release @@ -31,7 +32,7 @@ npm pack --dry-run Test the packed tarball locally: ```bash -VERSION=0.2.3 +VERSION=0.2.4 npm pack npm install -g "./ciphore-radiocli-$VERSION.tgz" radiocli --help @@ -58,15 +59,52 @@ bun add -g @ciphore/radiocli radiocli doctor ``` -Cross-platform release smoke checks should cover macOS, Linux, and Windows -startup paths: +The fresh-install harness builds one tarball, installs it into separate temporary +projects with normal dependencies and with `--omit=optional`, and executes only +that installed artifact. It checks CLI/doctor, offline cached providers, library +import/export, and MCP status. Its temporary paths contain +spaces and non-ASCII characters. Require a real native mpv for release evidence: ```bash -npm run fresh:check # macOS/Linux -npm run fresh:check # Windows, from PowerShell -radiocli doctor +npm run fresh:check -- --require-mpv +node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs ``` +The playback check generates a local WAV, uses mpv's null audio output, and +verifies readiness, IPC volume, pause, resume, and stop. It does not prove physical +audio-device output, a receiver connection, wake delivery, or a particular +terminal application. Set `RADIOCLI_SMOKE_EVIDENCE_DIR` to preserve JSON records +containing the artifact SHA-256, runtime identity, installation mode, and results. +Set `RADIOCLI_MPV_PATH` when the verified native executable is outside `PATH`. + +### Release Gates And Experimental Checks + +The required CI matrix preserves Ubuntu, macOS, and Windows on Node 22/24 and +adds Linux arm64, macOS Intel, and Windows 11 arm64. All 12 jobs require packed +mpv smoke checks in both installation modes; Linux also has source playback +checks. Documentation checks run on Node 22/24. All required jobs must pass on +the release candidate before publication; a failed required job blocks release. +Inspect the recorded runner OS/version and CPU before carrying evidence into +a release. Repository protection settings and publication remain owner-managed. + +All workflow actions use immutable commit pins. macOS and Windows playback +fixtures come from explicitly selected official mpv release assets, checked +against pinned SHA-256 values and native CPU headers before execution. Linux +uses its runner's distribution package. These are CI fixtures, not packages +installed by npm or a new public distribution channel. + +Weekly or manually requested FreeBSD, OpenBSD, and NetBSD checks boot full QEMU +guest kernels, install the Linux-built JavaScript tarball with guest-native Node +and mpv, and label the result as guest verification. They do not infer BSD +behavior from a Linux container, and they do not establish foreign-CPU or desktop +audio support. The [evidence matrix](platforms.mdx#project-tiers-and-current-evidence) +records actual outcomes and remaining verification gaps. + +Do not publish a release with a failed required job. An experimental job failure +must remain visible and its platform tier must retain the corresponding limit. +Repository protection settings, registry access, and release ownership remain +under maintainer control. + ## Homebrew Tap The public tap is `Ciphore/homebrew-tap`, and users install with: @@ -79,7 +117,7 @@ The reusable formula template lives in `packaging/homebrew/radiocli.rb.template` After each npm publish, fetch the npm tarball URL and SHA: ```bash -VERSION=0.2.3 +VERSION=0.2.4 TARBALL="$(npm view @ciphore/radiocli@$VERSION dist.tarball)" curl -L "$TARBALL" -o "/tmp/radiocli-$VERSION.tgz" shasum -a 256 "/tmp/radiocli-$VERSION.tgz" @@ -103,7 +141,7 @@ radiocli doctor ## Windows And Linux Native Playback -Windows setup uses native Node.js and native `mpv`: +Windows x64 setup uses native Node.js and native `mpv`: ```powershell winget install --id OpenJS.NodeJS.LTS -e @@ -117,6 +155,9 @@ directory to `PATH`. RadioCLI resolves that standard installation directly and also checks registered Windows application locations. Doctor should report an `mpv_path`, its `mpv_discovery` source, and `mpv_launch=ready`. +The inspected WinGet `mpv` recipe is x64-only. Scoop's `mpv` recipe includes x64 and +arm64 builds; verify the installed executable matches the intended runtime. +Windows x86 remains a Node 22 route requiring separate player and native evidence. Scoop users can install `mpv` with: ```powershell @@ -139,8 +180,9 @@ smoke checks should treat it as playback-only. `radiocli doctor` reports `controls=limited` when only `ffplay` is available and `controls=full` when `mpv` is available. -AirPlay is experimental on macOS. The npm package bundles the sender bridge, -and the Homebrew formula installs FFmpeg so the one-command macOS path exposes -AirPlay without extra user setup. Release checks should keep high/critical +AirPlay is experimental on macOS. Its optional sender dependency must install +and load successfully, and Bonjour and FFmpeg must be usable. The Homebrew +formula installs FFmpeg; that does not establish a working receiver connection. +Release checks should keep high/critical sender dependency advisories out of the shipped tree. AirPlay should remain an explicit session output rather than the automatic startup fallback. diff --git a/apps/docs/content/docs/reliability.mdx b/apps/docs/content/docs/reliability.mdx index dd86f53..5405d61 100644 --- a/apps/docs/content/docs/reliability.mdx +++ b/apps/docs/content/docs/reliability.mdx @@ -15,14 +15,16 @@ requirement. - station codecs can be mislabeled - ICY metadata can be absent, malformed, or delayed - terminal dimensions can change mid-session +- a data directory can become read-only, inaccessible, or full +- a native service can fail even when its command is installed ## Current Defenses -- Radio Browser mirror fallback +- Radio Browser DNS service discovery and mirror fallback - sharded, bounded provider cache with per-entry corruption isolation and stale fallback - cached geotagged station atlas for Explore and Nearby, with local distance-first ranking -- bounded network calls for provider and location lookups +- public HTTP deadlines that cover headers and response bodies - tune timeout - skip-to-next on failed tune when enabled - `mpv` IPC readiness check @@ -34,8 +36,9 @@ requirement. playback states - metadata polling through `mpv`, avoiding a second stream listener - user-controllable approximate-location lookup, requested only when Nearby opens -- corrupt store/cache backups +- required corrupt-library backups and separate best-effort cache recovery - locked, atomic local JSON writes with concurrent-process merge protection +- nonfatal optional TUI history saves, with explicit errors for failed edits - defensive playlist import - functional compact navigation for tiny terminals - native per-user alarm registration on launchd, Windows Task Scheduler, and @@ -45,6 +48,70 @@ requirement. - authenticated loopback controls for alarms that began while no TUI was open - best-effort playback sleep inhibition and optional pre-alarm Guard +## Network Requests + +Fallback Radio Browser endpoints come from its current DNS service record +instead of a fixed list of server names. Mirror attempts share a bounded +deadline. If the native hostname lookup reports a temporary `EAI_AGAIN` +failure, RadioCLI can retry through Node's DNS-protocol resolver. The original +hostname remains in the HTTPS request and certificate check. This recovery is +disabled whenever a proxy is configured so it cannot bypass the requested +network route. + +A response that sends headers but stalls while sending JSON cannot keep a +request open indefinitely. Usable stale cache entries remain available after a +request fails, and a failed cache write does not turn a successful response into +a directory outage. + +RadioCLI's public HTTP client supports HTTP(S) proxies through Node's native +`fetch`. On Node 22.21 or newer in the 22 release line, or Node 24 or newer, set +`NODE_USE_ENV_PROXY=1` before starting RadioCLI along with `HTTP_PROXY` or +`HTTPS_PROXY`; Node also reads `NO_PROXY`. A configured proxy without the +required startup opt-in is reported instead of silently using a direct +connection. See [Node's proxy configuration documentation](https://nodejs.org/learn/http/enterprise-network-configuration#proxy-configuration). + +SOCKS proxies and `ALL_PROXY` as the sole proxy configuration are unsupported by +this client and produce an actionable error. Proxy support leaves Node's TLS +verification and certificate configuration unchanged. It does not configure +the external player's networking. Authenticated local control uses literal +loopback addresses and direct HTTP requests regardless of proxy settings. + +| Setting | Behavior | +| --- | --- | +| `RADIOCLI_OFFLINE=1` | Block RadioCLI's public Node HTTP requests, including directory, location, vote, and update requests; use available cached directory results and saved direct stream URLs | +| `RADIOCLI_LOW_BANDWIDTH=1` | Use only a cached geotagged atlas for Explore and Nearby; smaller searches, health checks, location lookups, and manual controls remain available | + +A missing cache entry is reported rather than fetched in offline mode. These +flags do not put the operating system or native player offline: a requested +direct stream can still connect to its station. They do not download audio for +offline listening. + +Both flags skip automatic AirPlay receiver discovery. Offline mode also blocks +manual discovery and reports the setting; low-bandwidth mode allows an explicit +receiver refresh. Existing authenticated loopback controls and the stdio MCP +transport remain available offline. + +## Library Write Failures + +Library access errors preserve the existing bytes. An unreadable file is not +renamed as corrupt or replaced with defaults, and an inaccessible current +library does not trigger a switch to an older legacy file. If corrupt +JSON cannot be backed up, RadioCLI leaves it intact and reports the backup +failure. + +Atomic replacement requires a writable library file and destination directory. +A failed lock, write, permission change, or rename leaves the previous committed +state intact. Cleanup errors cannot replace the original failure or make a +completed replacement appear to have failed. + +When the library can be read but cannot be written, the TUI can still tune, +navigate, stop, and quit. Optional history failures produce a library warning; +they do not turn successful playback into a failed tune or prevent a sleep +timer or alarm takeover from stopping it. Settings and favorite edits remain +explicit saves and report failure without changing the saved state. Check the +directory's permissions or choose a private writable location with +`RADIOCLI_HOME`. + ## Scheduled Radio Delivery Saving an enabled alarm resolves its next occurrence and registers one native @@ -70,14 +137,27 @@ that audio. | Windows | Task Scheduler interactive-token task | `StartWhenAvailable` | `WakeToRun` is requested when enabled, but hardware and power policy decide | | Linux with systemd user session | systemd user timer | persistent timer catches up after the user session resumes | No `WakeSystem` claim without system privileges | | Linux without systemd user scheduling | Unsupported | None | None | +| BSD, Termux, and other experimental targets | No verified alarm adapter | None claimed | None claimed | + +An installed scheduler executable alone is insufficient. Registration and +diagnostics check the user service; a Linux host without a reachable systemd +user manager cannot register reliable background alarms. RadioCLI does not +substitute cron or a process-local timer for an unsupported native scheduler. +The [platform support matrix](./platforms.mdx) separates runtime eligibility +from verified RadioCLI behavior. Terminal reopening supports Apple Terminal, iTerm2, WezTerm, Ghostty, and kitty -on macOS; Windows Terminal with Command Prompt fallback on Windows; and common +on macOS; Windows Terminal or a PowerShell console on Windows; and common GUI terminal emulators on Linux. Integrated terminals that cannot be launched independently fall back to the platform terminal where possible. If a graphical terminal is unavailable, audio still plays and opening `radiocli` manually reveals the authenticated ringing controls. +The Windows console launcher waits for its temporary PowerShell bootstrap to +finish creating a separate console. Successful bootstrap completion still does +not establish TUI readiness; the caller verifies the expected session. The +console adapter requires PowerShell to permit .NET/native interop. + All three supported adapters require the computer to be powered on and the user to have a logged-in interactive audio session with usable local speakers. A powered-off machine cannot run RadioCLI. Exact behavior across ordinary sleep, @@ -95,6 +175,16 @@ power and does not override shutdown, hibernate, explicit sleep, lid policy, logout, or other OS decisions. Guard state is machine-local and is reconciled again after each alarm change. +If an inhibitor helper exits unexpectedly, runtime health reports lost sleep +protection while playback can continue. If Guard ownership cannot be verified, +its record is retained for repair and its PID is not signaled. A failed native +job removal is accepted only after the exact job is confirmed absent; otherwise +the alarm definition and repair artifacts remain available. Deleting an alarm +first saves it as disabled. If that save fails, deletion stops before native +cleanup; if cleanup or the final save fails, the disabled definition remains +available for repair or another deletion attempt. RadioCLI reports +these cases as unresolved rather than claiming protection or cleanup succeeded. + Recurring alarms use minute precision, ISO weekdays, and an explicit IANA timezone. In a spring daylight-saving gap, RadioCLI uses the first valid minute later on that civil date. In a fall overlap, it fires the first matching instant @@ -104,7 +194,8 @@ definitions keep their own IANA timezone, while the platform job contains the already-resolved next instant. When a native job starts late, the missed-run grace decides whether it still -plays. A due alarm tries its primary station twice, with a short bounded backoff, +plays. An early launch does not mark the occurrence complete or consume its due +dispatch. A due alarm tries its primary station twice, with a short bounded backoff, then tries the configured fallback. It stops after the requested number of minutes unless playback is transferred into the interactive TUI. True fade-in requires `mpv`; `ffplay` and VLC start at the target volume because they lack dependable runtime @@ -112,13 +203,25 @@ volume control. Scheduled audio is local-speaker only; unattended AirPlay is intentionally disabled. Before starting the player, RadioCLI also checks the default local system output. -If it is muted or below the alarm's configured volume, the runner unmutes it and -raises it to at least that level, then restores the previous device state when -the alarm ends. This uses macOS output-volume scripting, Windows Core Audio +If it is muted or below the alarm's configured volume, the runner attempts to +unmute it and raise it to at least that level. Overlapping alarm runners share +the original output state across library profiles: only the last runner to +release it attempts restoration. Transferring playback to the interactive TUI +preserves the current output level. A partial mixer change triggers a rollback +attempt; a failed restore is reported and remains retryable. If every runner +is forcibly terminated, recovery occurs on a later system-volume operation +after any surviving mixer helper exits. An incomplete helper record blocks +automatic adjustment and preserves the saved output state. This uses macOS +output-volume scripting, Windows Core Audio through built-in PowerShell, or `wpctl`, `pactl`, or `amixer` on Linux. If the host exposes none of those controls, player volume still applies and runtime diagnostics report that the system output could not be raised. +If diagnostics report an unknown mixer helper after a crash, restart the +computer before clearing `native-write.json` and the `lock` directory from +`.radiocli/system-volume` under the OS account's home directory. Keep +`state.json`: it contains the output state needed for the next recovery attempt. + ## Alarm Troubleshooting Use the following in order: @@ -164,13 +267,4 @@ the launchd label alone does not change executable identity. with a movable scan cursor and true distance ranking, but it can only place stations that providers expose with valid coordinates. -## Next Reliability Work - -- per-station health cache -- explicit failed-stream reasons in station lists -- "never show again" station hiding -- retry/backoff by station/provider -- optional stream HEAD/probe before tune - -See [Architecture](./architecture.mdx) for the provider and playback boundaries, -and [Roadmap](./roadmap.mdx) for planned follow-up work. +See [Architecture](./architecture.mdx) for the provider and playback boundaries. diff --git a/apps/docs/content/docs/roadmap.mdx b/apps/docs/content/docs/roadmap.mdx deleted file mode 100644 index d10b0b7..0000000 --- a/apps/docs/content/docs/roadmap.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: Roadmap -description: Shipped scope and focused follow-up work for RadioCLI. ---- - -## Shipped In 0.1 - -- Ink TUI -- Radio Browser provider -- `mpv` playback, `ffplay` fallback, and experimental macOS AirPlay output -- search, countries, world map, nearby, library -- playlist import/export -- provider cache and tune timeouts -- open-source repo hygiene - -## Shipped In 0.2.2 - -- TUI-first one-time and recurring radio alarms, listed under Overview before - Settings -- native per-user background scheduling with launchd, Windows Task Scheduler, - and systemd user timers -- missed-run grace, primary retry, fallback station, stop-after, snooze, and - authenticated ringing controls -- automatic reopening of the saved supported terminal for ringing controls -- optional OS wake requests where available and machine-local Alarm Guard idle - sleep inhibition - -## Follow-up Work - -- native scheduling adapters for Linux systems without systemd user sessions -- broader wake-from-sleep capability detection as OS and hardware APIs allow -- a signed macOS app bundle and Service Management helper so Background Activity - can identify RadioCLI instead of the Node runtime -- unattended remote audio outputs; alarms currently use local speakers only - -See [Changelog](./changelog.mdx) for the full release history. diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx new file mode 100644 index 0000000..3f44cb0 --- /dev/null +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -0,0 +1,201 @@ +--- +title: Troubleshooting +description: Diagnose playback tools, terminal rendering, local storage, and optional desktop integrations. +--- + +Start with the report for the feature that is failing: + +```bash +radiocli doctor +radiocli doctor --json +radiocli check +radiocli alarm doctor +``` + +`doctor` reports local playback tools and individual platform capabilities. +`check` adds provider health and the library path. `alarm doctor` reports native +scheduling and recorded delivery health. An available player does not establish that a +desktop session, system mixer, or scheduler is usable. See +[Platforms](/docs/platforms) for runtime requirements and verified coverage. + +## Playback Tool Installed But Not Found + +`radiocli doctor` prints the resolved `mpv` path, discovery method, version, and +launch result. RadioCLI checks `PATH`, known package-manager locations, and +supported application directories. An executable found outside `PATH` can be +used directly. + +For a custom installation, set an absolute path to the native executable: + +| Variable | Tool | +| --- | --- | +| `RADIOCLI_MPV_PATH` | `mpv` | +| `RADIOCLI_FFPLAY_PATH` | `ffplay` | +| `RADIOCLI_VLC_PATH` | `vlc` or `cvlc` | +| `RADIOCLI_FFMPEG_PATH` | FFmpeg, including the decoder used for macOS AirPlay | + +The value is a file path, with no command arguments. Windows overrides must +point to a native `.exe` or `.com` file; `.cmd` and `.bat` wrappers are not player +executables. A configured path that is invalid or cannot run is reported instead +of silently selecting another installation of that tool. Correct or remove the +override, then run Doctor again. + +Replace the example path with your installed executable. In a POSIX shell: + +```bash +RADIOCLI_MPV_PATH="/path with spaces/mpv" radiocli doctor +``` + +In PowerShell: + +```powershell +$env:RADIOCLI_MPV_PATH = 'C:\path with spaces\mpv.exe' +radiocli doctor +``` + +If no player is installed, use `radiocli setup --dry-run` to inspect the proposed +installation, or follow [Install](getting-started/install.mdx). `ffplay` and VLC +provide fallback playback, but pause, mute, interactive volume, metadata, and +play/pause media keys require `mpv`. + +On minimal NetBSD installations, the packaged mpv also needs the matching OS +X11 base set. Missing libraries under `/usr/X11R7/lib` can make the package +installation fail or prevent the executable from starting, even with null audio +and video. Install the matching distribution set through NetBSD's OS tools, +then retry the player package and `mpv --version` before checking RadioCLI. + +## Missing Glyphs, Incorrect Colors, Or Too Much Motion + +Use **Settings → Appearance** or the command palette: + +```text +:ascii on +:background on +:motion on +``` + +ASCII mode replaces decorative glyphs while preserving station names and +metadata. Transparent Background removes the panel fill. Reduce Motion freezes +receiver and loading animations and stops scrolling long labels. + +For a single launch, use `RADIOCLI_ASCII=1`, `NO_COLOR=1`, or +`RADIOCLI_DISABLE_ANIMATION=1`. Any non-empty `NO_COLOR` value removes colors and +fills, even when `FORCE_COLOR` is set. Sixteen-color terminals automatically +keep their own background. + +If ASCII appeared unexpectedly, check `LC_ALL`, `LC_CTYPE`, and `LANG`, in that +order. The first non-empty value determines the locale check: bare `C` and +`POSIX` select ASCII decoration, while UTF-8 locales retain Unicode. An explicit +`RADIOCLI_ASCII=1` also selects ASCII; `RADIOCLI_UNICODE=1` restores Unicode when +ASCII is not explicitly enabled and the terminal is not `dumb`. SSH alone does +not select ASCII or reduced motion. + +Set `RADIOCLI_SCREEN_READER=1` or `INK_SCREEN_READER=true` for screen-reader +output. Receiver visualizers, mouse reporting, and scrolling labels are +disabled; keyboard navigation and commands remain available. All +environment overrides leave your saved appearance settings intact. See +[TUI Controls](getting-started/tui-controls.mdx) for the complete list. + +## TUI Input Or Desktop Actions Are Unavailable + +Open `radiocli` from a terminal with interactive keyboard input. `TERM=dumb` +uses static frames with ASCII decoration and no color or animation. The TUI +still needs interactive stdin. Use command mode for redirected input or scripts: + +```bash +radiocli doctor --json +radiocli search "jazz" +radiocli alarm list --json +``` + +Browser opening, clipboard access, and opening another terminal also depend on +the current desktop session. On Linux, clipboard integration needs a working +Wayland or X11 session and an available helper such as `wl-copy`, `xclip`, or +`xsel`. When automatic copying is unavailable, RadioCLI displays the stream URL +for manual copying. Keyboard navigation remains available without mouse support. + +For agent playback on a machine without a usable graphical terminal, explicitly +choose headless mode with `radiocli setup --mcp --headless-agent`. See +[CLI Usage](getting-started/cli.mdx) for agent controls. + +## Offline Directories, Proxies, And IPv6 + +Check the network policy in `radiocli doctor --json`. `RADIOCLI_OFFLINE=1` +blocks RadioCLI's public directory, location, vote, update, and receiver-discovery +requests. Cached directories and saved or imported direct station +URLs remain usable. A cache miss stays a cache miss until you run online again; +offline mode does not download stations or audio in the background. + +`RADIOCLI_LOW_BANDWIDTH=1` uses the cached geotagged atlas and skips automatic +receiver discovery. Smaller searches and manual requests remain available. +Both settings leave the external player's networking separate: tuning a live +stream still needs a working route to that station. + +For public HTTP(S) proxies, use a compatible Node release and set +`NODE_USE_ENV_PROXY=1` before starting RadioCLI, together with `HTTP_PROXY` or +`HTTPS_PROXY` and any required `NO_PROXY` exceptions. Node 22 needs 22.21 or newer +for this public-fetch route; Node 24 also provides it. A missing startup opt-in, +invalid proxy URL, SOCKS proxy, or `ALL_PROXY`-only configuration produces a +diagnostic. Correct that configuration before retrying. The setting does not +configure mpv or another player's proxy behavior. See +[Network requests](reliability.mdx#network-requests). + +Authenticated local controls bind to `127.0.0.1`, falling back to `::1` when +IPv4 loopback is unavailable, and bypass public proxy settings. Public directory +and media connectivity still depend on each endpoint's addresses and the host's +DNS and routing. A successful local IPv6 control check does not establish public +IPv6 reachability. Diagnose the provider request and the external player's +stream connection separately; neither requires exposing local control to the LAN. + +RadioCLI discovers current Radio Browser servers automatically. It also recovers +from many temporary `EAI_AGAIN` lookup failures without weakening HTTPS checks. +If every request still reports a name-resolution error, the machine cannot reach +its configured DNS service; correct that system or network configuration and +retry. When a proxy is configured, RadioCLI leaves name resolution to that route +instead of bypassing it. + +## Library Is Read-Only Or Cannot Be Saved + +A readable library remains usable for browsing and playback. If a history write +fails, the TUI shows **Library not saved** and continues tuning, navigation, +stop, and quit. Failed writes do not become saved listening history, and +explicit favorite, setting, or alarm saves report their failure. + +Use the library path from `radiocli check` to inspect the file and its parent +directory. Atomic saves need write access to both. Check filesystem permissions, +Windows ACLs, or a read-only mount as appropriate. Windows ACLs are not configured +by POSIX `chmod` modes. + +You can set `RADIOCLI_HOME` to a private writable directory. This selects a +separate data location; it does not move the existing library automatically. +Preserve your existing data before changing locations, and restart RadioCLI +after correcting the path or permissions. + +## Optional Native Features Need Attention + +- **macOS:** terminal reopening through Apple Terminal or iTerm2 may need + Automation consent from the launching app. Experimental AirPlay also needs + Bonjour, FFmpeg, a usable sender, and an explicitly selected receiver. Local + playback remains a separate output option. +- **Windows:** scheduled alarms use Task Scheduler with the interactive user + token. A requested `WakeToRun` setting still depends on hardware and power + policy. Use native Windows RadioCLI for Windows agent and terminal setup; + WSL has a separate Linux environment. The PowerShell console, system-volume, + and sleep-inhibitor adapters require permitted .NET/native interop; constrained + PowerShell policy can prevent those optional operations. +- **Linux:** background alarms require an accessible systemd user manager. + A machine without that session can still use normal playback when its player + and audio device are available. A found `systemctl` executable alone does not + establish working user scheduling. + +Use **Verify alarm setup** in the Alarms screen to check registration, terminal +delivery, sleep protection, system volume, and bounded sample playback on the +current machine. Player volume and system output volume are separate: when a +system mixer cannot be controlled, set the output device volume manually. +macOS and Linux do not request privileged exact-wake events. Alarm Guard holds +off idle sleep only while its inhibitor is alive. + +Follow the [Alarms guide](alarms.mdx) and +[Reliability](reliability.mdx) for repair steps and wake limitations. Optional +feature failures are reported individually; the platform name alone does not +guarantee their availability. diff --git a/apps/docs/lib/seo.ts b/apps/docs/lib/seo.ts index 90292b3..abfae45 100644 --- a/apps/docs/lib/seo.ts +++ b/apps/docs/lib/seo.ts @@ -94,7 +94,7 @@ export function getHomeJsonLd() { codeRepository: repositoryUrl, downloadUrl: 'https://www.npmjs.com/package/@ciphore/radiocli', installUrl: absoluteUrl('/docs/getting-started/install'), - softwareVersion: '0.2.3', + softwareVersion: '0.2.4', license: licenseUrl, isAccessibleForFree: true, author: { diff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json index 013cdc2..70aa622 100644 --- a/apps/docs/package-lock.json +++ b/apps/docs/package-lock.json @@ -1,19 +1,19 @@ { "name": "radiocli-docs", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "radiocli-docs", - "version": "0.2.3", + "version": "0.2.4", "hasInstallScript": true, "dependencies": { "fumadocs-core": "16.9.3", "fumadocs-mdx": "15.0.10", "fumadocs-ui": "16.9.3", "lucide-react": "^1.17.0", - "next": "16.3.0", + "next": "16.3.5", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -537,9 +537,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -555,13 +555,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -577,20 +577,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -600,9 +600,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -616,9 +616,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -632,9 +632,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -651,9 +651,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -670,9 +670,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -689,9 +689,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -708,9 +708,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -727,9 +727,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -746,9 +746,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -765,9 +765,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -784,9 +784,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -805,13 +805,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -830,13 +830,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -855,13 +855,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -880,13 +880,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -905,13 +905,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -930,13 +930,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -955,13 +955,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -980,17 +980,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1000,16 +1000,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1019,9 +1019,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -1038,9 +1038,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -1057,9 +1057,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1163,15 +1163,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", - "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", + "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", - "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", + "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", "cpu": [ "arm64" ], @@ -1185,9 +1185,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", - "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", + "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", "cpu": [ "x64" ], @@ -1201,9 +1201,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", - "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", + "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", "cpu": [ "arm64" ], @@ -1220,9 +1220,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", - "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", + "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", "cpu": [ "arm64" ], @@ -1239,9 +1239,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", - "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", + "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", "cpu": [ "x64" ], @@ -1258,9 +1258,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", - "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", + "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", "cpu": [ "x64" ], @@ -1277,9 +1277,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", - "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", + "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", "cpu": [ "arm64" ], @@ -1293,9 +1293,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", - "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", + "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", "cpu": [ "x64" ], @@ -2235,9 +2235,9 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -2729,9 +2729,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3724,9 +3724,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -5139,13 +5139,13 @@ } }, "node_modules/next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", - "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", + "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0", - "@swc/helpers": "0.5.15", + "@next/env": "16.3.5", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", @@ -5158,15 +5158,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.0", - "@next/swc-darwin-x64": "16.3.0", - "@next/swc-linux-arm64-gnu": "16.3.0", - "@next/swc-linux-arm64-musl": "16.3.0", - "@next/swc-linux-x64-gnu": "16.3.0", - "@next/swc-linux-x64-musl": "16.3.0", - "@next/swc-win32-arm64-msvc": "16.3.0", - "@next/swc-win32-x64-msvc": "16.3.0", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.3.5", + "@next/swc-darwin-x64": "16.3.5", + "@next/swc-linux-arm64-gnu": "16.3.5", + "@next/swc-linux-arm64-musl": "16.3.5", + "@next/swc-linux-x64-gnu": "16.3.5", + "@next/swc-linux-x64-musl": "16.3.5", + "@next/swc-win32-arm64-msvc": "16.3.5", + "@next/swc-win32-x64-msvc": "16.3.5", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -5660,9 +5660,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -5677,31 +5677,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { "@types/node": { diff --git a/apps/docs/package.json b/apps/docs/package.json index 94a4778..53ccb16 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,6 +1,6 @@ { "name": "radiocli-docs", - "version": "0.2.3", + "version": "0.2.4", "private": true, "description": "Public website and documentation for RadioCLI.", "type": "module", @@ -16,7 +16,7 @@ "fumadocs-mdx": "15.0.10", "fumadocs-ui": "16.9.3", "lucide-react": "^1.17.0", - "next": "16.3.0", + "next": "16.3.5", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/package-lock.json b/package-lock.json index 3740627..636d8db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ciphore/radiocli", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ciphore/radiocli", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/server": "^2.0.0", diff --git a/package.json b/package.json index 7ab9227..7ad95c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ciphore/radiocli", - "version": "0.2.3", + "version": "0.2.4", "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.", "type": "module", "license": "MIT", diff --git a/scripts/fresh-check.mjs b/scripts/fresh-check.mjs index 5287656..a37ac75 100755 --- a/scripts/fresh-check.mjs +++ b/scripts/fresh-check.mjs @@ -1,79 +1,34 @@ #!/usr/bin/env node -import {existsSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdtempSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join, resolve} from 'node:path'; -import {spawnSync} from 'node:child_process'; -import process from 'node:process'; import {fileURLToPath} from 'node:url'; +import {npmCommand, runCommand, runPackedSmoke, smokeEnvironment} from './packed-smoke.mjs'; const root = resolve(fileURLToPath(new URL('..', import.meta.url))); -const tempProject = mkdtempSync(join(tmpdir(), 'radiocli-fresh-')); -const tempStore = mkdtempSync(join(tmpdir(), 'radiocli-store-')); -let packedTarball; +const args = process.argv.slice(2); +if (args.some(arg => arg !== '--require-mpv')) throw new Error('Usage: npm run fresh:check [-- --require-mpv]'); +const temporary = mkdtempSync(join(tmpdir(), 'radiocli-pack-')); try { - if (existsSync(join(root, 'tsconfig.build.json'))) { - run('npm', ['run', 'build', '--silent'], {cwd: root}); + const npm = npmCommand(); + const env = smokeEnvironment(join(temporary, 'state')); + // npm pack runs the normal prepack build, and writes only into a temp folder. + const packed = runCommand(npm.command, [...npm.args, 'pack', '--json', '--dry-run=false', '--pack-destination', temporary], {cwd: root, env, timeout: 180_000}); + const info = JSON.parse(packed.stdout); + if (info.length !== 1 || !info[0]?.filename) throw new Error('npm pack did not return exactly one package.'); + const tarball = join(temporary, info[0].filename); + if (!existsSync(tarball)) throw new Error(`npm pack did not create ${tarball}`); + const evidenceRoot = process.env.RADIOCLI_SMOKE_EVIDENCE_DIR; + for (const omitOptional of [false, true]) { + await runPackedSmoke(tarball, { + omitOptional, + requireMpv: args.includes('--require-mpv'), + executionMethod: process.env.RADIOCLI_SMOKE_EXECUTION_METHOD ?? 'local process', + evidencePath: evidenceRoot ? join(evidenceRoot, `${omitOptional ? 'omit-optional' : 'normal'}.json`) : undefined + }); } - - const pack = run('npm', ['pack', '--json', '--dry-run=false'], {cwd: root, env: freshEnv()}); - const packageInfo = JSON.parse(pack.stdout)[0]; - packedTarball = resolve(root, packageInfo.filename); - - if (!existsSync(packedTarball)) { - throw new Error(`npm pack did not create ${packedTarball}`); - } - - writeFileSync(join(tempProject, 'package.json'), '{"private":true,"type":"module"}\n', 'utf8'); - run('npm', ['install', '--no-audit', '--fund=false', packedTarball], {cwd: tempProject, env: freshEnv(), timeout: 180_000}); - - const bin = process.platform === 'win32' - ? join(tempProject, 'node_modules', '.bin', 'radiocli.cmd') - : join(tempProject, 'node_modules', '.bin', 'radiocli'); - - run(bin, ['help'], {cwd: tempProject, env: freshEnv(), timeout: 30_000}); - const check = run(bin, ['check'], {cwd: tempProject, env: freshEnv(), timeout: 45_000}); - - process.stdout.write(check.stdout); - console.log('fresh_check=ok'); + console.log('fresh_check=ok installs=normal,omit-optional'); } finally { - if (packedTarball) { - rmSync(packedTarball, {force: true}); - } - - rmSync(tempProject, {force: true, recursive: true}); - rmSync(tempStore, {force: true, recursive: true}); -} - -function freshEnv() { - const env = { - ...process.env, - RADIOCLI_HOME: tempStore, - RADIOCLI_DISABLE_ANIMATION: '1' - }; - delete env.npm_config_dry_run; - delete env.NPM_CONFIG_DRY_RUN; - return env; -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd ?? root, - env: options.env ?? process.env, - encoding: 'utf8', - shell: process.platform === 'win32', - timeout: options.timeout ?? 120_000 - }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - process.stderr.write(result.stdout ?? ''); - process.stderr.write(result.stderr ?? ''); - throw new Error(`${command} ${args.join(' ')} exited with ${result.status}`); - } - - return result; + rmSync(temporary, {force: true, recursive: true}); } diff --git a/scripts/install-smoke-mpv.mjs b/scripts/install-smoke-mpv.mjs new file mode 100644 index 0000000..02faf2a --- /dev/null +++ b/scripts/install-smoke-mpv.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {runCommand} from './packed-smoke.mjs'; + +// CI-only release fixtures, verified against the release API on 2026-09-07. +// https://github.com/mpv-player/mpv/releases/tag/v0.41.0 +// Keep hashes fixed: a replaced release asset must fail rather than change CI silently. +const assets = { + 'darwin-x64': {name: 'mpv-v0.41.0-macos-15-intel.zip', sha256: '41003617ab4f7784394b5ddea7ce51b3e0838e8cfc8166ad1a378b2eda3b583c'}, + 'darwin-arm64': {name: 'mpv-v0.41.0-macos-26-arm.zip', sha256: '09820c0d84f6687446b84eb9df81fcf6a26ebe869cee58ea1857d7948cfb7c71'}, + 'win32-x64': {name: 'mpv-v0.41.0-x86_64-pc-windows-msvc.zip', sha256: '4e197f729f5071c6772f35fffd96e0f36e3e8a044bd9479b136bb09b7c6a80ff'}, + 'win32-arm64': {name: 'mpv-v0.41.0-aarch64-pc-windows-msvc.zip', sha256: 'a822abeffd0ac88951f4084f3425f949842aa17d616f880637ebe9041e482e97'} +}; + +export function selectMpvAsset(platform, architecture) { + const asset = assets[`${platform}-${architecture}`]; + if (!asset) throw new Error(`No pinned native mpv smoke asset for ${platform}/${architecture}.`); + return {...asset, url: `https://github.com/mpv-player/mpv/releases/download/v0.41.0/${asset.name}`}; +} + +export function validateArchivePaths(paths) { + for (const path of paths) { + const normalized = path.replaceAll('\\', '/'); + assert.ok(!/[\0\r\n]/.test(normalized) && !normalized.startsWith('/') && !/^[a-z]:/i.test(normalized) && !normalized.split('/').includes('..'), `Unsafe archive path: ${JSON.stringify(path)}`); + } +} + +function zipEntryNames(bytes) { + const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + assert.ok(end >= 0 && end + 22 <= bytes.length, 'Invalid ZIP directory.'); + const count = bytes.readUInt16LE(end + 10); + let offset = bytes.readUInt32LE(end + 16); + const names = []; + for (let index = 0; index < count; index++) { + assert.ok(offset + 46 <= bytes.length && bytes.readUInt32LE(offset) === 0x02014b50, 'Invalid ZIP entry.'); + const nameLength = bytes.readUInt16LE(offset + 28); + const next = offset + 46 + nameLength + bytes.readUInt16LE(offset + 30) + bytes.readUInt16LE(offset + 32); + assert.ok(next <= bytes.length, 'Truncated ZIP entry.'); + names.push(bytes.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')); + offset = next; + } + assert.ok(names.length > 0, 'The pinned ZIP must contain files.'); + return names; +} + +/** Inspect the executable before running it; emulated x64 is not ARM64 evidence. */ +export function verifyNativeBinary(bytes, platform, architecture) { + if (platform === 'win32') { + assert.ok(bytes.length >= 64 && bytes.toString('ascii', 0, 2) === 'MZ', 'Invalid Windows PE header.'); + const offset = bytes.readUInt32LE(0x3c); + assert.ok(offset + 6 <= bytes.length && bytes.readUInt32LE(offset) === 0x00004550, 'Invalid Windows PE header.'); + const cpu = ({[0x8664]: 'x64', [0xaa64]: 'arm64'})[bytes.readUInt16LE(offset + 4)]; + assert.equal(cpu, architecture, 'mpv executable architecture does not match Node and the runner.'); + return {architectures: [cpu]}; + } + assert.ok(platform === 'darwin' && bytes.length >= 32, 'Unknown executable format.'); + const cpuName = cpu => ({[0x01000007]: 'x64', [0x0100000c]: 'arm64'})[cpu]; + let architectures; + if (bytes.readUInt32BE(0) === 0xcafebabe) { + const slices = []; + for (let index = 0; index < bytes.readUInt32BE(4); index++) { + const offset = 8 + index * 20; + assert.ok(offset + 20 <= bytes.length, 'Truncated universal Mach-O header.'); + slices.push({architecture: cpuName(bytes.readUInt32BE(offset)), offset: bytes.readUInt32BE(offset + 8), size: bytes.readUInt32BE(offset + 12)}); + } + architectures = slices.map(slice => slice.architecture).filter(Boolean); + const selected = slices.find(slice => slice.architecture === architecture); + assert.ok(selected && selected.offset + selected.size <= bytes.length, 'mpv executable architecture does not match the runner.'); + bytes = bytes.subarray(selected.offset, selected.offset + selected.size); + } + assert.ok(bytes.length >= 32 && bytes.readUInt32LE(0) === 0xfeedfacf, 'Invalid 64-bit Mach-O header.'); + const cpu = cpuName(bytes.readUInt32LE(4)); + assert.equal(cpu, architecture, 'mpv executable architecture does not match Node and the runner.'); + let minimumMacos; + let offset = 32; + for (let index = 0; index < bytes.readUInt32LE(16); index++) { + assert.ok(offset + 8 <= bytes.length, 'Truncated Mach-O command.'); + const command = bytes.readUInt32LE(offset); + const size = bytes.readUInt32LE(offset + 4); + assert.ok(size >= 8 && offset + size <= bytes.length, 'Invalid Mach-O command.'); + const versionOffset = command === 0x32 && size >= 24 ? 12 : command === 0x24 && size >= 16 ? 8 : null; + if (versionOffset !== null) { + const version = bytes.readUInt32LE(offset + versionOffset); + minimumMacos = `${version >>> 16}.${(version >>> 8) & 0xff}.${version & 0xff}`; + } + offset += size; + } + return {architectures: architectures ?? [cpu], ...(minimumMacos ? {minimumMacos} : {})}; +} + +function versionAtLeast(actual, minimum) { + const left = actual.split('.').map(Number); + const right = minimum.split('.').map(Number); + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference) return difference > 0; + } + return true; +} + +async function install() { + const asset = selectMpvAsset(process.platform, process.arch); + const root = mkdtempSync(join(process.env.RUNNER_TEMP ?? tmpdir(), 'radiocli-ci-mpv-')); + const extracted = join(root, 'unpacked'); + mkdirSync(extracted); + let completed = false; + try { + let bytes; + if (process.env.RADIOCLI_SMOKE_MPV_ARCHIVE) bytes = readFileSync(process.env.RADIOCLI_SMOKE_MPV_ARCHIVE); + else { + const response = await fetch(asset.url, {signal: AbortSignal.timeout(120_000)}); + assert.ok(response.ok, `mpv asset download failed: HTTP ${response.status}.`); + bytes = Buffer.from(await response.arrayBuffer()); + } + assert.equal(createHash('sha256').update(bytes).digest('hex'), asset.sha256, 'mpv release archive SHA256 mismatch.'); + validateArchivePaths(zipEntryNames(bytes)); + const archive = join(root, asset.name); + writeFileSync(archive, bytes); + let executable; + let macos; + if (process.platform === 'darwin') { + runCommand('/usr/bin/ditto', ['-x', '-k', archive, extracted]); + const packed = join(extracted, 'mpv.tar.gz'); + validateArchivePaths(runCommand('/usr/bin/tar', ['-tzf', packed]).stdout.split('\n').filter(Boolean)); + runCommand('/usr/bin/tar', ['-xzf', packed, '-C', extracted]); + executable = join(extracted, 'mpv.app', 'Contents', 'MacOS', 'mpv'); + macos = runCommand('/usr/bin/sw_vers', ['-productVersion']).stdout.trim(); + } else { + // Fixed PowerShell code. Neither archive path is interpolated into code. + runCommand('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'Expand-Archive -LiteralPath $env:RADIOCLI_MPV_ARCHIVE -DestinationPath $env:RADIOCLI_MPV_EXTRACT -Force'], { + env: {...process.env, RADIOCLI_MPV_ARCHIVE: archive, RADIOCLI_MPV_EXTRACT: extracted} + }); + executable = join(extracted, 'mpv.exe'); + } + const binary = verifyNativeBinary(readFileSync(executable), process.platform, process.arch); + if (binary.minimumMacos) assert.ok(versionAtLeast(macos, binary.minimumMacos), `mpv requires macOS ${binary.minimumMacos}; this runner has ${macos}.`); + const version = runCommand(executable, ['--version']).stdout.split('\n')[0]; + const evidence = {asset: asset.url, sha256: asset.sha256, platform: process.platform, architecture: process.arch, ...binary, ...(macos ? {macos} : {}), version, executable}; + if (process.env.GITHUB_PATH) appendFileSync(process.env.GITHUB_PATH, `${dirname(executable)}\n`); + if (process.env.GITHUB_ENV) appendFileSync(process.env.GITHUB_ENV, `RADIOCLI_MPV_PATH=${executable}\n`); + if (process.env.RADIOCLI_SMOKE_EVIDENCE_DIR) { + mkdirSync(process.env.RADIOCLI_SMOKE_EVIDENCE_DIR, {recursive: true}); + writeFileSync(join(process.env.RADIOCLI_SMOKE_EVIDENCE_DIR, 'mpv-asset.json'), `${JSON.stringify(evidence, null, 2)}\n`); + } + console.log(`mpv_asset=${JSON.stringify(evidence)}`); + completed = true; + } finally { + // Successful installs live only in runner temp for subsequent smoke steps. + if (!completed) rmSync(root, {recursive: true, force: true}); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await install().catch(error => { console.error(error.stack ?? error.message); process.exitCode = 1; }); +} diff --git a/scripts/install-smoke-mpv.test-node.mjs b/scripts/install-smoke-mpv.test-node.mjs new file mode 100644 index 0000000..c2aa0dd --- /dev/null +++ b/scripts/install-smoke-mpv.test-node.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +async function helpers() { + const module = await import('./install-smoke-mpv.mjs').catch(() => ({})); + assert.equal(typeof module.selectMpvAsset, 'function', 'CI must select a pinned native mpv asset before downloading'); + return module; +} + +test('Windows ARM selects an aarch64 build and rejects unsupported architectures', async () => { + const {selectMpvAsset} = await helpers(); + assert.match(selectMpvAsset('win32', 'arm64').name, /aarch64/); + assert.match(selectMpvAsset('win32', 'x64').name, /x86_64/); + assert.throws(() => selectMpvAsset('win32', 'ia32'), /No pinned native mpv/); + assert.throws(() => selectMpvAsset('unknown', 'arm64'), /No pinned native mpv/); +}); + +test('a Windows x64 executable cannot satisfy ARM64 playback evidence', async () => { + const {verifyNativeBinary} = await helpers(); + const binary = Buffer.alloc(128); + binary.write('MZ'); + binary.writeUInt32LE(64, 0x3c); + binary.write('PE\0\0', 64); + binary.writeUInt16LE(0x8664, 68); + assert.throws(() => verifyNativeBinary(binary, 'win32', 'arm64'), /architecture/); + binary.writeUInt16LE(0xaa64, 68); + assert.deepEqual(verifyNativeBinary(binary, 'win32', 'arm64').architectures, ['arm64']); +}); + +test('Mach-O inspection verifies CPU and records its declared minimum macOS', async () => { + const {verifyNativeBinary} = await helpers(); + const binary = Buffer.alloc(56); + binary.writeUInt32LE(0xfeedfacf, 0); + binary.writeUInt32LE(0x01000007, 4); + binary.writeUInt32LE(1, 16); + binary.writeUInt32LE(24, 20); + binary.writeUInt32LE(0x32, 32); + binary.writeUInt32LE(24, 36); + binary.writeUInt32LE(1, 40); + binary.writeUInt32LE(0x000d0500, 44); + assert.deepEqual(verifyNativeBinary(binary, 'darwin', 'x64'), {architectures: ['x64'], minimumMacos: '13.5.0'}); + assert.throws(() => verifyNativeBinary(binary, 'darwin', 'arm64'), /architecture/); +}); + +test('archive paths cannot leave the isolated extraction directory', async () => { + const {validateArchivePaths} = await helpers(); + validateArchivePaths(['mpv.exe', 'mpv.app/Contents/MacOS/mpv']); + for (const path of ['../mpv.exe', '/tmp/mpv', 'C:\\mpv.exe', 'a/../../mpv', 'a\\..\\mpv']) { + assert.throws(() => validateArchivePaths([path]), /Unsafe archive path/); + } +}); + +test('unknown binary formats fail before executing downloaded code', async () => { + const {verifyNativeBinary} = await helpers(); + assert.throws(() => verifyNativeBinary(Buffer.from('#!/bin/sh'), 'win32', 'arm64'), /PE|format|header/); + assert.throws(() => verifyNativeBinary(Buffer.alloc(64), 'darwin', 'x64'), /Mach-O|format|header/); +}); diff --git a/scripts/packed-smoke.mjs b/scripts/packed-smoke.mjs new file mode 100644 index 0000000..ad4fbc2 --- /dev/null +++ b/scripts/packed-smoke.mjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import {spawn, spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {arch, endianness, release, tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath, pathToFileURL} from 'node:url'; +import {setTimeout as delay} from 'node:timers/promises'; + +const script = fileURLToPath(import.meta.url); + +/** npm.cmd is a shell program; execute npm's JS entry with the selected Node. */ +export function npmCommand({execPath = process.execPath, platform = process.platform, env = process.env} = {}) { + const candidates = [env.npm_execpath, + join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + resolve(dirname(execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')]; + const path = Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] ?? ''; + for (const directory of path.split(platform === 'win32' ? ';' : ':').filter(Boolean)) { + candidates.push(join(directory, 'npm'), join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js')); + } + for (const candidate of candidates) { + if (!candidate || !existsSync(candidate)) continue; + const entry = realpathSync(candidate); + if (entry.endsWith('npm-cli.js')) return {command: execPath, args: [entry]}; + } + throw new Error('Cannot locate npm-cli.js. Install npm alongside Node.js or run this script through npm.'); +} + +export function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, env: options.env ?? process.env, encoding: 'utf8', shell: false, + windowsHide: true, timeout: options.timeout ?? 120_000, maxBuffer: 16 * 1024 * 1024 + }); + if (result.error || result.status !== (options.expectedStatus ?? 0)) { + throw new Error(`${command} failed: ${result.error?.message ?? `exited with ${result.status}`}\n${result.stdout ?? ''}${result.stderr ?? ''}`); + } + return result; +} + +export function smokeEnvironment(home) { + const env = {...process.env, RADIOCLI_HOME: home, RADIOCLI_OFFLINE: '1', RADIOCLI_DISABLE_ANIMATION: '1', RADIOCLI_MPV_AUDIO_OUTPUT: 'null', NO_COLOR: '1'}; + for (const key of Object.keys(env)) { + if (['node_path', 'node_options', 'npm_config_dry_run', 'npm_config_omit', 'npm_config_include', 'npm_config_ignore_scripts'].includes(key.toLowerCase())) delete env[key]; + } + return env; +} + +/** Imports only the installed tarball; never the checkout's dist or node_modules. */ +export async function runPackedSmoke(tarball, {omitOptional = false, requireMpv = false, executionMethod = 'local process', evidencePath} = {}) { + tarball = resolve(tarball); + assert.ok(existsSync(tarball), `Packed tarball is missing: ${tarball}`); + assert.ok(Number(process.versions.node.split('.')[0]) >= 22, 'Packed runtime checks require Node.js 22 or newer.'); + if (process.env.RADIOCLI_EXPECTED_ARCH) assert.equal(process.arch, process.env.RADIOCLI_EXPECTED_ARCH, 'The packed runtime must match the requested native runner architecture.'); + const temporary = mkdtempSync(join(tmpdir(), 'radiocli packed & # % 日本 ')); + const project = join(temporary, 'project'); + const home = join(temporary, 'state'); + mkdirSync(project); + mkdirSync(home); + const env = smokeEnvironment(home); + const networkLog = join(temporary, 'unexpected-network.log'); + const guardPath = join(temporary, 'network-guard.mjs'); + // --import uses ESM URL resolution: drive letters and URL delimiters must + // remain file-path data on Windows and on POSIX paths containing # or %. + const guard = pathToFileURL(guardPath).href; + writeFileSync(guardPath, `import {appendFileSync} from 'node:fs';\nglobalThis.fetch = () => { appendFileSync(process.env.RADIOCLI_SMOKE_NETWORK_LOG, 'fetch attempted\\n'); return Promise.reject(new Error('Packed smoke forbids live network requests.')); };\n`); + env.RADIOCLI_SMOKE_NETWORK_LOG = networkLog; + try { + writeFileSync(join(project, 'package.json'), '{"private":true,"type":"module"}\n'); + const npm = npmCommand(); + console.log(`packed_install=${omitOptional ? 'omit-optional' : 'normal'} method=${executionMethod}`); + runCommand(npm.command, [...npm.args, 'install', '--no-audit', '--fund=false', '--omit=dev', '--ignore-scripts=false', + ...(omitOptional ? ['--omit=optional'] : ['--include=optional']), tarball], {cwd: project, env, timeout: 300_000}); + const packageRoot = join(project, 'node_modules', '@ciphore', 'radiocli'); + const metadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); + const cli = join(packageRoot, metadata.bin.radiocli); + assert.ok(existsSync(cli), 'The installed package is missing its declared CLI entry.'); + if (omitOptional) assert.equal(existsSync(join(project, 'node_modules', 'node-airtunes2')), false, 'The portable install unexpectedly installed the optional native sender.'); + const runCli = (args, options = {}) => runCommand(process.execPath, ['--import', guard, cli, ...args], {cwd: project, env, timeout: 45_000, ...options}); + assert.match(runCli(['help']).stdout, /Usage:/); + assert.equal(runCli(['version']).stdout.trim(), metadata.version); + const doctor = JSON.parse(runCli(['doctor', '--json']).stdout); + assert.equal(doctor.radioCliVersion, metadata.version); + assert.equal(doctor.platform, process.platform); + assert.equal(doctor.architecture, process.arch); + assert.ok(doctor.capabilities && doctor.host, 'doctor must report host facts and capabilities.'); + + // A missing offline cache must fail promptly; stale persisted data must work. + // The preload makes accidental live-directory access an explicit failure. + assert.match(runCli(['countries'], {expectedStatus: 1}).stderr, /offline|cached/i); + seedProviderCache(home); + assert.match(runCli(['countries']).stdout, /ZZ\t2\tSmoke Islands/); + assert.match(runCli(['search', 'packed-smoke']).stdout, /Packed Smoke Radio/); + assert.match(runCli(['search', 'uncached-fixture'], {expectedStatus: 1}).stderr, /offline|cached/i); + const stationName = 'Émission 日本 & $(literal)'; + const stationUrl = 'https://example.invalid/packed-smoke?x=1&y=2'; + assert.match(runCli(['add-url', stationUrl, stationName]).stdout, /added=/); + const playlist = join(project, 'input & 日本.m3u'); + const exported = join(project, 'output & 日本.m3u'); + writeFileSync(playlist, '#EXTM3U\n#EXTINF:-1,Imported Fixture\nhttps://example.invalid/imported\n'); + assert.match(runCli(['import', playlist]).stdout, /imported=1/); + runCli(['export', exported]); + const contents = readFileSync(exported, 'utf8'); + assert.ok(contents.includes(stationName) && contents.includes(stationUrl), 'The exported playlist lost the custom URL or Unicode name.'); + assert.match(contents, /Imported Fixture\nhttps:\/\/example\.invalid\/imported/); + const libraryPath = join(home, 'radiocli.json'); + const library = JSON.parse(readFileSync(libraryPath, 'utf8')); + assert.equal(library.imported.length, 2, 'Both CLI processes must persist to the isolated store.'); + library.settings.agentControl.enabled = true; + library.settings.agentControl.openUiOnPlay = false; + library.settings.automaticUpdateChecks = false; + writeFileSync(libraryPath, `${JSON.stringify(library)}\n`); + const mcpTools = await smokeMcp({cli, cwd: project, env, nodeArgs: ['--import', guard]}); + + let binWrapper = 'pending'; + if (process.platform !== 'win32') { + const wrapper = join(project, 'node_modules', '.bin', 'radiocli'); + assert.equal(runCommand(wrapper, ['version'], {cwd: project, env, timeout: 45_000}).stdout.trim(), metadata.version); + binWrapper = 'posix npm link'; + } else if (process.env.CI) { + // Fixed PowerShell code, with the generated path passed only through env. + const wrapper = join(project, 'node_modules', '.bin', 'radiocli.ps1'); + assert.ok(existsSync(wrapper), 'npm did not create its PowerShell bin shim.'); + const result = runCommand('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', '& $env:RADIOCLI_SMOKE_BIN version; exit $LASTEXITCODE'], { + cwd: project, env: {...env, RADIOCLI_SMOKE_BIN: wrapper}, timeout: 45_000 + }); + assert.equal(result.stdout.trim(), metadata.version); + binWrapper = 'PowerShell npm shim'; + } + let playback = 'not exercised: mpv unavailable'; + if (doctor.backends.includes('mpv')) { + const result = runCommand(process.execPath, ['--import', guard, script, '--exercise-playback', packageRoot, home], {cwd: project, env, timeout: 60_000}); + assert.match(result.stdout, /packed_playback=ok/); + process.stdout.write(result.stdout); + playback = 'mpv: local WAV, IPC readiness/volume/pause/resume/stop, ao=null; no audio hardware assertion'; + } else if (requireMpv) throw new Error('This job requires real mpv playback, but the installed CLI did not detect mpv.'); + assert.equal(existsSync(networkLog), false, 'A packed runtime fixture attempted a live network request.'); + const evidence = { + executionMethod, platform: process.platform, architecture: arch(), endianness: endianness(), kernel: release(), + node: process.version, npm: runCommand(npm.command, [...npm.args, '--version'], {cwd: project, env}).stdout.trim(), + package: `${metadata.name}@${metadata.version}`, install: omitOptional ? 'omit-optional' : 'normal', + tarballSha256: createHash('sha256').update(readFileSync(tarball)).digest('hex'), + host: doctor.host, binWrapper, offlineCache: 'passed', mcpTools, playback, + checks: ['help', 'version', 'doctor-json', 'offline-cache-hit-and-miss', 'add-url', 'import', 'export', 'MCP-stdio'], result: 'passed' + }; + if (evidencePath) { + mkdirSync(dirname(resolve(evidencePath)), {recursive: true}); + writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); + } + console.log(`packed_smoke=${JSON.stringify(evidence)}`); + return evidence; + } finally { + rmSync(temporary, {recursive: true, force: true}); + } +} + +function seedProviderCache(home) { + const entries = {}; + const add = (path, params, value) => { + const query = new URLSearchParams(Object.entries(params).sort(([a], [b]) => a.localeCompare(b))); + entries[`${path}?${query}`] = {createdAt: Date.now() - 2 * 24 * 60 * 60 * 1000, value}; + }; + add('/json/countries', {hidebroken: 'true'}, [{name: 'Smoke Islands', iso_3166_1: 'ZZ', stationcount: 2}]); + for (const field of ['name', 'tag', 'country', 'language']) { + add('/json/stations/search', {hidebroken: 'true', limit: '20', offset: '0', order: 'clickcount', reverse: 'true', [field]: 'packed-smoke'}, [ + {stationuuid: 'packed-smoke-station', name: 'Packed Smoke Radio', url_resolved: 'https://example.invalid/cached', tags: 'packed-smoke'} + ]); + } + writeFileSync(join(home, 'radiocli-cache.json'), `${JSON.stringify({version: 1, entries})}\n`); +} + +export async function smokeMcp({cli, cwd, env, nodeArgs = []}) { + const child = spawn(process.execPath, [...nodeArgs, cli, 'mcp', 'serve'], {cwd, env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe']}); + const closed = new Promise(resolveClosed => child.once('close', resolveClosed)); + try { + return await new Promise((resolveResult, reject) => { + let output = ''; + let stderr = ''; + let settled = false; + let toolCount = 0; + const finish = (error, value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + error ? reject(error) : resolveResult(value); + }; + const timeout = setTimeout(() => finish(new Error(`MCP smoke timed out. ${stderr}`)), 30_000); + const send = message => child.stdin.write(`${JSON.stringify({jsonrpc: '2.0', ...message})}\n`); + child.stderr.on('data', chunk => { stderr += String(chunk); }); + child.stdin.on('error', error => finish(error)); + child.once('error', error => finish(error)); + child.once('close', code => finish(new Error(`MCP server exited with ${code}. ${stderr}`))); + child.stdout.on('data', chunk => { + output += String(chunk); + let newline; + while ((newline = output.indexOf('\n')) >= 0 && !settled) { + const line = output.slice(0, newline).trim(); + output = output.slice(newline + 1); + if (!line) continue; + try { + const response = JSON.parse(line); + assert.equal(response.error, undefined, 'MCP returned a protocol error.'); + if (response.id === 1) { + assert.equal(response.result?.serverInfo?.name, 'radiocli'); + send({method: 'notifications/initialized'}); + send({id: 2, method: 'tools/list', params: {}}); + } else if (response.id === 2) { + const tools = response.result?.tools ?? []; + for (const name of ['radio_status', 'radio_search', 'radio_play', 'radio_stop', 'radio_alarm_list']) assert.ok(tools.some(tool => tool.name === name), `MCP tool missing: ${name}`); + toolCount = tools.length; + send({id: 3, method: 'tools/call', params: {name: 'radio_status', arguments: {}}}); + } else if (response.id === 3) { + assert.notEqual(response.result?.isError, true, 'MCP radio_status failed.'); + const status = JSON.parse(response.result?.content?.find(item => item.type === 'text')?.text ?? 'null'); + assert.equal(status?.connected, false, 'An isolated MCP smoke must not find a user session.'); + finish(null, toolCount); + } + } catch (error) { + finish(new Error(`${error instanceof SyntaxError ? 'MCP server wrote non-JSON stdout' : 'MCP smoke failed'}: ${error.message}`)); + } + } + }); + send({id: 1, method: 'initialize', params: {protocolVersion: '2025-11-25', capabilities: {}, clientInfo: {name: 'radiocli-packed-smoke', version: '1.0.0'}}}); + }); + } finally { + child.stdin.destroy(); + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + const force = setTimeout(() => child.kill('SIGKILL'), 1000); + force.unref(); + await closed; + clearTimeout(force); + } + } +} + +async function exercisePlayback(packageRoot, home) { + const {PlayerController} = await import(pathToFileURL(join(packageRoot, 'dist', 'player', 'player-controller.js')).href); + const {JsonLibraryStore} = await import(pathToFileURL(join(packageRoot, 'dist', 'storage', 'store.js')).href); + const {resolveCommand} = await import(pathToFileURL(join(packageRoot, 'dist', 'platform', 'executables.js')).href); + const mpv = resolveCommand('mpv'); + assert.ok(mpv, 'mpv executable is required.'); + console.log(runCommand(mpv, ['--version']).stdout.split('\n')[0]); + const store = new JsonLibraryStore(); + store.updateSettings({preferredBackend: 'mpv', volume: 0, automaticUpdateChecks: false}); + const player = new PlayerController(() => store.snapshot().settings); + const wav = join(home, 'local silence.wav'); + writeFileSync(wav, silentWav()); + try { + await player.play({id: 'packed-playback', provider: 'playlist', name: 'Packed playback', tags: ['smoke'], streamUrl: wav}, wav); + await delay(750); + assert.equal(player.getState().ready, true, 'mpv must acknowledge playback readiness over IPC.'); + assert.equal(player.getState().state, 'playing'); + assert.equal((await player.setVolume(31)).ok, true, 'mpv must acknowledge the volume command.'); + assert.equal(player.getState().volume, 31); + assert.equal((await player.pause()).ok, true); + assert.equal(player.getState().state, 'paused'); + assert.equal((await player.resume()).ok, true); + assert.equal(player.getState().state, 'playing'); + } finally { + await player.stop(); + } + assert.equal(player.diagnostics().active, false, 'The playback child must stop.'); + assert.equal(player.getState().state, 'stopped'); + console.log('packed_playback=ok source=local-wav audio-output=null controls=IPC-volume-pause-resume-stop'); +} + +function silentWav() { + const dataBytes = 10 * 44_100 * 2 * 2; + const buffer = Buffer.alloc(44 + dataBytes); + buffer.write('RIFF'); + buffer.writeUInt32LE(36 + dataBytes, 4); + buffer.write('WAVEfmt ', 8); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(2, 22); + buffer.writeUInt32LE(44_100, 24); + buffer.writeUInt32LE(44_100 * 4, 28); + buffer.writeUInt16LE(4, 32); + buffer.writeUInt16LE(16, 34); + buffer.write('data', 36); + buffer.writeUInt32LE(dataBytes, 40); + return buffer; +} + +async function main(args) { + if (args[0] === '--exercise-playback') return exercisePlayback(args[1], args[2]); + const options = {}; + let tarball; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--omit-optional') options.omitOptional = true; + else if (arg === '--require-mpv') options.requireMpv = true; + else if (arg === '--execution-method') options.executionMethod = args[++index]; + else if (arg === '--evidence') options.evidencePath = args[++index]; + else if (!arg.startsWith('-') && !tarball) tarball = arg; + else throw new Error(`Unexpected packed-smoke argument: ${arg}`); + } + if (!tarball) throw new Error('Usage: node scripts/packed-smoke.mjs [--omit-optional] [--require-mpv] [--execution-method ] [--evidence ]'); + await runPackedSmoke(tarball, options); +} + +if (process.argv[1] && resolve(process.argv[1]) === script) { + await main(process.argv.slice(2)).catch(error => { console.error(error.stack ?? error.message); process.exitCode = 1; }); +} diff --git a/scripts/packed-smoke.test-node.mjs b/scripts/packed-smoke.test-node.mjs new file mode 100644 index 0000000..3b04cf3 --- /dev/null +++ b/scripts/packed-smoke.test-node.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +// Run explicitly with node --test; this name avoids Vitest's test-file glob. +import {mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {test} from 'node:test'; + +async function helpers() { + const module = await import('./packed-smoke.mjs').catch(() => ({})); + assert.equal(typeof module.npmCommand, 'function', 'the packed smoke must provide a shell-free npm launcher'); + return module; +} + +function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), 'radiocli smoke & 日本 ')); + t.after(() => rmSync(directory, {recursive: true, force: true})); + return directory; +} + +test('npm runs its JavaScript entry directly and preserves shell-sensitive arguments', async t => { + const {npmCommand, runCommand} = await helpers(); + const directory = fixture(t); + const cli = join(directory, 'npm-cli.js'); + writeFileSync(cli, 'console.log(JSON.stringify(process.argv.slice(2)));\n'); + const args = ['install', join(directory, 'a & b; $(never-run).tgz'), '100% literal !']; + const invocation = npmCommand({env: {npm_execpath: cli}}); + assert.equal(invocation.command, process.execPath); + assert.equal(invocation.args[0], realpathSync(cli)); + const result = runCommand(invocation.command, [...invocation.args, ...args]); + assert.deepEqual(JSON.parse(result.stdout), args); +}); + +test('npm discovery resolves the Windows Node distribution layout without executing a cmd shim', async t => { + const {npmCommand} = await helpers(); + const directory = fixture(t); + const node = join(directory, 'node.exe'); + const cli = join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'); + mkdirSync(dirname(cli), {recursive: true}); + writeFileSync(node, ''); + writeFileSync(cli, ''); + assert.deepEqual(npmCommand({execPath: node, platform: 'win32', env: {}}), {command: node, args: [realpathSync(cli)]}); +}); + +test('npm discovery fails clearly when only an unusable shim is available', async t => { + const {npmCommand} = await helpers(); + const directory = fixture(t); + writeFileSync(join(directory, 'npm.cmd'), '@echo off\r\n'); + assert.throws(() => npmCommand({execPath: join(directory, 'node.exe'), platform: 'win32', env: {PATH: directory}}), /npm-cli\.js/); +}); + +test('command failures include stderr and retain a nonzero failure', async () => { + const {runCommand} = await helpers(); + assert.throws(() => runCommand(process.execPath, ['-e', 'console.error("fixture failure"); process.exit(7)']), /fixture failure/); +}); + +test('a stalled child cannot turn a smoke test into an unbounded job', async () => { + const {runCommand} = await helpers(); + assert.throws(() => runCommand(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {timeout: 100}), /timed out|ETIMEDOUT/); +}); + +test('MCP smoke rejects non-JSON stdout from the actual child process', async t => { + const {smokeMcp} = await helpers(); + const directory = fixture(t); + const cli = join(directory, 'broken-mcp.cjs'); + writeFileSync(cli, 'console.log("unexpected banner"); setInterval(() => {}, 1000);\n'); + await assert.rejects(smokeMcp({cli, cwd: directory, env: process.env}), /non-JSON stdout/); +}); + +test('MCP smoke reports an early server exit instead of waiting for its deadline', async t => { + const {smokeMcp} = await helpers(); + const directory = fixture(t); + const cli = join(directory, 'closed-mcp.cjs'); + writeFileSync(cli, 'console.error("fixture server exited"); process.exit(3);\n'); + await assert.rejects(smokeMcp({cli, cwd: directory, env: process.env}), /fixture server exited|exited with 3/); +}); diff --git a/src/agent/headless-host.test.ts b/src/agent/headless-host.test.ts new file mode 100644 index 0000000..20c1210 --- /dev/null +++ b/src/agent/headless-host.test.ts @@ -0,0 +1,234 @@ +import {chmodSync, existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {PlayerController} from '../player/player-controller.js'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import type {PlaybackState, Station} from '../types.js'; +import {runHeadlessAgentHost} from './headless-host.js'; +import {connectRadioSession, type RadioSessionClient, type RadioSessionCommand} from './session.js'; + +const first: Station = {id: 'first', provider: 'playlist', name: 'First Radio', tags: [], streamUrl: 'https://radio.example/first'}; +const second: Station = {...first, id: 'second', name: 'Second Radio', streamUrl: 'https://radio.example/second'}; +const historyMethods = ['addRecent', 'startListeningSession', 'finishActiveListeningSession', 'checkpointActiveListeningSession'] as const; +const signalNames = ['SIGTERM', 'SIGINT'] as const; +let root: string; +let playback: PlaybackState; +let originalListeners: Map>; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'radiocli headless 音楽-')); + vi.stubEnv('RADIOCLI_HOME', root); + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']}); + originalListeners = new Map(signalNames.map(signal => [signal, new Set(process.listeners(signal))])); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.spyOn(process, 'kill').mockReturnValue(true); + playback = {backend: 'mpv', state: 'idle', volume: 70, muted: false, ready: false}; + vi.spyOn(PlayerController.prototype, 'refreshDetectedBackends').mockReturnValue(['mpv']); + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...playback})); + vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (station, url) => { + playback = {...playback, state: 'playing', stationName: station.name, streamUrl: url, ready: true}; + }); + vi.spyOn(PlayerController.prototype, 'stop').mockImplementation(async () => { + playback = {backend: 'none', state: 'stopped', volume: playback.volume, muted: false, ready: false}; + }); + vi.spyOn(PlayerController.prototype, 'pause').mockImplementation(async () => { + playback = {...playback, state: 'paused'}; + return {ok: true}; + }); + vi.spyOn(PlayerController.prototype, 'resume').mockImplementation(async () => { + playback = {...playback, state: 'playing'}; + return {ok: true}; + }); + vi.spyOn(PlayerController.prototype, 'setVolume').mockImplementation(async volume => { + playback = {...playback, volume}; + return {ok: true}; + }); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async station => ({url: station.streamUrl!, station})); + vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(true); +}); + +afterEach(async () => { + // Restore storage access before cleanup so a failing regression cannot leak a + // real loopback listener. Player and process mocks stay in place until closed. + for (const method of [...historyMethods, 'updateSettings', 'toggleFavorite'] as const) { + if (vi.isMockFunction(JsonLibraryStore.prototype[method])) vi.mocked(JsonLibraryStore.prototype[method]).mockRestore(); + } + const libraryPath = join(root, 'radiocli.json'); + if (existsSync(libraryPath)) chmodSync(libraryPath, 0o600); + const shutdown = hostSignalHandler('SIGTERM'); + if (shutdown) { + shutdown('SIGTERM'); + await vi.waitFor(() => expect(existsSync(join(root, 'runtime', 'agent-session.json'))).toBe(false)); + } + for (const signal of signalNames) { + for (const listener of process.listeners(signal)) { + if (!originalListeners.get(signal)?.has(listener)) process.removeListener(signal, listener); + } + } + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + rmSync(root, {recursive: true, force: true}); +}); + +function hostSignalHandler(signal: NodeJS.Signals): NodeJS.SignalsListener | undefined { + return process.listeners(signal).find(listener => !originalListeners.get(signal)?.has(listener)); +} + +async function startHost(): Promise<{client: RadioSessionClient; libraryPath: string; sessionPath: string}> { + new JsonLibraryStore().updateSettings({shareDirectoryVotes: false}); + let startupError: unknown; + void runHeadlessAgentHost().catch(error => {startupError = error;}); + await vi.waitFor(() => { + if (startupError) throw startupError; + expect(hostSignalHandler('SIGTERM')).toBeDefined(); + }); + const sessionPath = join(root, 'runtime', 'agent-session.json'); + const client = await connectRadioSession(sessionPath); + expect(client).not.toBeNull(); + return {client: client!, libraryPath: join(root, 'radiocli.json'), sessionPath}; +} + +function storageFailure(code = 'ENOSPC'): Error { + return Object.assign(new Error(`${code}: write failed at ${join(root, 'private library.json')}\u001b[31m`), {code}); +} + +function failHistory(method: typeof historyMethods[number], code = 'ENOSPC'): void { + vi.spyOn(JsonLibraryStore.prototype, method).mockImplementation(() => {throw storageFailure(code);}); +} + +describe('headless host optional persistence', () => { + it('stops through the authenticated session after its real library becomes read-only', async context => { + if (process.platform === 'win32') context.skip('POSIX file permissions are not enforced by Windows ACLs.'); + if (process.getuid?.() === 0) context.skip('Root bypasses POSIX file write permissions.'); + const {client, libraryPath, sessionPath} = await startHost(); + await client.call({type: 'play', station: first}); + const saved = readFileSync(libraryPath, 'utf8'); + chmodSync(libraryPath, 0o400); + + const stopped = await client.call({type: 'stop'}); + + expect(stopped).toMatchObject({ok: true, message: 'Stopped RadioCLI.', status: { + station: null, queue: [], playback: {state: 'stopped'}, persistenceWarning: expect.stringContaining('RADIOCLI_HOME') + }}); + expect(stopped.status.persistenceWarning).toContain('EACCES'); + expect(stopped.status.persistenceWarning).not.toContain(root); + expect(readFileSync(libraryPath, 'utf8')).toBe(saved); + expect(await client.status()).toMatchObject({playback: {state: 'stopped'}}); + vi.advanceTimersByTime(50); + expect(process.kill).toHaveBeenCalledWith(process.pid, 'SIGTERM'); + hostSignalHandler('SIGTERM')!('SIGTERM'); + await vi.waitFor(() => expect(existsSync(sessionPath)).toBe(false)); + expect(existsSync(`${sessionPath}.owner`)).toBe(false); + }); + + it.each(['addRecent', 'startListeningSession'] as const)('keeps a successful tune when %s fails', async method => { + const {client} = await startHost(); + failHistory(method); + + const played = await client.call({type: 'play', station: first}); + + expect(played).toMatchObject({ok: true, message: 'Playing First Radio.', status: { + station: first, playback: {state: 'playing', ready: true}, persistenceWarning: expect.stringContaining('ENOSPC') + }}); + expect(played.status.persistenceWarning).toContain('RADIOCLI_HOME'); + expect(played.status.persistenceWarning).not.toContain(root); + expect(played.status.persistenceWarning).not.toContain('\u001b'); + }); + + it('retunes the queue when finishing the previous history entry fails', async () => { + const {client} = await startHost(); + await client.call({type: 'play', station: first, queue: [first, second]}); + failHistory('finishActiveListeningSession'); + + const next = await client.call({type: 'next'}); + + expect(next).toMatchObject({ok: true, status: {station: second, playback: {state: 'playing'}, persistenceWarning: expect.any(String)}}); + }); + + it('yields playback to an alarm when finishing history fails', async () => { + const {client} = await startHost(); + await client.call({type: 'play', station: first}); + failHistory('finishActiveListeningSession'); + + const yielded = await client.call({type: 'alarm-preempt'}); + + expect(yielded).toMatchObject({ok: true, status: {station: null, queue: [], playback: {state: 'stopped'}, persistenceWarning: expect.any(String)}}); + expect(await client.status()).toMatchObject({station: null, playback: {state: 'stopped'}}); + expect(process.kill).not.toHaveBeenCalledWith(process.pid, 'SIGTERM'); + }); + + it.each(['pause', 'resume'] as const)('keeps acknowledged %s controls successful after a history failure', async type => { + const {client} = await startHost(); + await client.call({type: 'play', station: first}); + if (type === 'resume') await client.call({type: 'pause'}); + failHistory(type === 'pause' ? 'finishActiveListeningSession' : 'startListeningSession'); + + const controlled = await client.call({type}); + + expect(controlled).toMatchObject({ok: true, status: { + playback: {state: type === 'pause' ? 'paused' : 'playing'}, persistenceWarning: expect.any(String) + }}); + }); + + it('contains heartbeat write failures and keeps the authenticated owner responsive', async () => { + const {client} = await startHost(); + await client.call({type: 'play', station: first}); + failHistory('checkpointActiveListeningSession'); + + expect(() => vi.advanceTimersByTime(30_000)).not.toThrow(); + + expect(await client.status()).toMatchObject({station: first, playback: {state: 'playing'}, persistenceWarning: expect.stringContaining('ENOSPC')}); + }); + + it.each(signalNames)('stops playback and removes session ownership on %s despite a history failure', async signal => { + const {client, sessionPath} = await startHost(); + await client.call({type: 'play', station: first}); + failHistory('finishActiveListeningSession'); + + hostSignalHandler(signal)!(signal); + + await vi.waitFor(() => expect(existsSync(sessionPath)).toBe(false)); + expect(existsSync(`${sessionPath}.owner`)).toBe(false); + expect(playback.state).toBe('stopped'); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('retains the warning through optional saves and clears it after an explicit save succeeds', async () => { + const {client} = await startHost(); + failHistory('addRecent'); + await client.call({type: 'play', station: first}); + vi.advanceTimersByTime(30_000); + expect((await client.status()).persistenceWarning).toContain('RADIOCLI_HOME'); + + const saved = await client.call({type: 'update-settings', settings: {theme: 'amber'}}); + + expect(saved.ok).toBe(true); + expect(saved.status.persistenceWarning).toBeUndefined(); + expect(new JsonLibraryStore().snapshot().settings.theme).toBe('amber'); + }); + + const explicitCommands: RadioSessionCommand[] = [ + {type: 'update-settings', settings: {theme: 'amber'}}, + {type: 'set-volume', volume: 42}, + {type: 'set-favorite', favorite: true} + ]; + it.each(explicitCommands)('does not claim success for a failed explicit $type save', async command => { + const {client, libraryPath} = await startHost(); + await client.call({type: 'play', station: first}); + const saved = readFileSync(libraryPath, 'utf8'); + const method = command.type === 'set-favorite' ? 'toggleFavorite' : 'updateSettings'; + vi.spyOn(JsonLibraryStore.prototype, method).mockImplementation(() => { + throw new Error('Unable to write the RadioCLI library (EACCES). Set RADIOCLI_HOME to a private writable directory.'); + }); + + await expect(client.call(command)).rejects.toThrow('RADIOCLI_HOME'); + + expect(readFileSync(libraryPath, 'utf8')).toBe(saved); + expect(new JsonLibraryStore().snapshot()).toMatchObject({favorites: [], settings: {volume: 70, theme: 'green'}}); + expect(ProviderManager.prototype.vote).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agent/headless-host.ts b/src/agent/headless-host.ts index bfcdb9e..70edb53 100644 --- a/src/agent/headless-host.ts +++ b/src/agent/headless-host.ts @@ -12,21 +12,38 @@ export async function runHeadlessAgentHost(): Promise { player.refreshDetectedBackends(); let station: Station | null = null; let queue: Station[] = []; + let persistenceWarning: string | undefined; - const status = (): RadioSessionStatus => ({owner: 'headless', playback: player.getState(), station, queue, output: { + const persistHistory = (write: () => unknown): void => { + try { + write(); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : ''; + persistenceWarning = `Listening history was not saved${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`; + } + }; + const saveLibrary = (write: () => unknown): void => { + write(); + // Optional writes can be no-ops, so only an explicit successful save clears + // a prior warning. Explicit failures still reject the control request. + persistenceWarning = undefined; + }; + + const status = (): RadioSessionStatus => ({owner: 'headless', playback: player.getState(), station, queue, persistenceWarning, output: { preferredBackend: store.snapshot().settings.preferredBackend, preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice }}); const result = (message: string, ok = true, data?: RadioSessionResult['data']): RadioSessionResult => ({ok, message, status: status(), ...(data ? {data} : {})}); const play = async (next: Station, nextQueue: Station[] = [next]): Promise => { - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); const resolved = await providers.resolve(next); await player.play(next, resolved.url); station = next; queue = nextQueue.length ? nextQueue : [next]; - store.addRecent(next); - store.startListeningSession(next); + persistHistory(() => store.addRecent(next)); + persistHistory(() => store.startListeningSession(next)); return result(`Playing ${next.name}.`); }; @@ -38,16 +55,16 @@ export async function runHeadlessAgentHost(): Promise { } if (command.type === 'pause') { const control = await player.pause(); - if (control.ok) store.finishActiveListeningSession(); + if (control.ok) persistHistory(() => store.finishActiveListeningSession()); return result(control.message ?? 'Paused.', control.ok); } if (command.type === 'resume') { const control = await player.resume(); - if (control.ok && station) store.startListeningSession(station); + if (control.ok && station) persistHistory(() => store.startListeningSession(station!)); return result(control.message ?? 'Resumed.', control.ok); } if (command.type === 'stop') { - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); station = null; queue = []; @@ -55,7 +72,7 @@ export async function runHeadlessAgentHost(): Promise { return result('Stopped RadioCLI.'); } if (command.type === 'alarm-preempt') { - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); station = null; queue = []; @@ -63,7 +80,7 @@ export async function runHeadlessAgentHost(): Promise { } if (command.type === 'set-volume') { const control = await player.setVolume(command.volume); - if (control.ok) store.updateSettings({volume: player.getState().volume}); + if (control.ok) saveLibrary(() => store.updateSettings({volume: player.getState().volume})); return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok); } if (command.type === 'set-muted') { const control = await player.setMuted(command.muted); return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok); } @@ -71,7 +88,7 @@ export async function runHeadlessAgentHost(): Promise { const target = command.station ?? station; if (!target) return result('No active station to favorite.', false); const current = store.isFavorite(target); - if (current !== command.favorite) store.toggleFavorite(target); + if (current !== command.favorite) saveLibrary(() => store.toggleFavorite(target)); if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes) void providers.vote(target); return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`); } @@ -85,8 +102,10 @@ export async function runHeadlessAgentHost(): Promise { if (!device) return result('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices); if (device.local) return switchToLocal(); if (!detectPlaybackBackends().includes('airplay')) return result('AirPlay playback is unavailable; run radiocli doctor.', false); - store.updateSettings({preferredAirPlayDevice: device.id}); - store.updateSettings({preferredBackend: 'airplay'}); + saveLibrary(() => { + store.updateSettings({preferredAirPlayDevice: device.id}); + store.updateSettings({preferredBackend: 'airplay'}); + }); if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue); return result(`Audio output set to AirPlay receiver ${device.name}.`); } @@ -96,7 +115,7 @@ export async function runHeadlessAgentHost(): Promise { return result(control.message ?? 'AirPlay code sent.', control.ok); } if (command.type === 'update-settings') { - store.updateSettings(command.settings); + saveLibrary(() => store.updateSettings(command.settings)); return result('RadioCLI settings updated.'); } const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station!)) : -1; @@ -109,18 +128,18 @@ export async function runHeadlessAgentHost(): Promise { const backends = detectPlaybackBackends(); const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null; if (!backend) return result('No local playback backend is available. Run radiocli setup to install mpv.', false); - store.updateSettings({preferredBackend: backend}); + saveLibrary(() => store.updateSettings({preferredBackend: backend})); if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue); return result(`Audio output set to this device (${backend}).`); }; const session = await startRadioSession(handle); const checkpoint = setInterval(() => { - if (player.getState().state === 'playing') store.checkpointActiveListeningSession(); + if (player.getState().state === 'playing') persistHistory(() => store.checkpointActiveListeningSession()); }, 30_000); const shutdown = async () => { clearInterval(checkpoint); - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); await session.close(); }; diff --git a/src/agent/launcher.test.ts b/src/agent/launcher.test.ts new file mode 100644 index 0000000..61981d2 --- /dev/null +++ b/src/agent/launcher.test.ts @@ -0,0 +1,45 @@ +import {EventEmitter} from 'node:events'; +import {spawn as nodeSpawn,type ChildProcess,type SpawnOptions} from 'node:child_process'; +import {homedir} from 'node:os'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import {launchRadioTui} from './launcher.js'; + +vi.mock('node:child_process',async importOriginal=>({...await importOriginal(),spawn:vi.fn()})); +const calls:Array<{command:string;args:readonly string[];options?:SpawnOptions}>=[]; +const spawn=vi.fn((command:string,args:readonly string[],options?:SpawnOptions)=>{calls.push({command,args,options});const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',0);});return child;}); +beforeEach(()=>{calls.length=0;spawn.mockClear();vi.mocked(nodeSpawn).mockImplementation(spawn as unknown as typeof nodeSpawn);}); +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();}); +function nodeInvocation(args: readonly string[], environment: NodeJS.ProcessEnv = {}): {command: string; args: string[]} { + const encodedScript = args[args.indexOf('-EncodedCommand') + 1]; + const script = Buffer.from(encodedScript!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + const key = /GetEnvironmentVariable\('([^']+)'/.exec(script)?.[1]; + const source = encoded ? Buffer.from(encoded, 'base64').toString('utf8') : environment[key!]; + expect(source).toBeDefined(); + const invocation = JSON.parse(source!) as {command: string; args: string[]}; + return invocation.args.includes('-EncodedCommand') ? nodeInvocation(invocation.args) : invocation; +} + +describe('agent graphical launcher',()=>{ + it.each(['win32:console','win32:windows-terminal'])('encodes hostile paths and the data home without cmd interpolation in %s',async terminal=>{ + const home='C:\\Data %PATH%! & "quoted" \' 单播';const nodePath='C:\\Node %TEMP%! & 单播\\node.exe';const cliPath='C:\\Radio %PATH%! & 单播\\cli.js'; + await launchRadioTui(nodePath,cliPath,'encoded-agent-command',{platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:terminal,RADIOCLI_HOME:home},spawn,resolve:command=>command}); + const call=calls[0]!;expect(call.command).not.toBe('cmd.exe');const encoded=call.args[call.args.indexOf('-EncodedCommand')+1];expect(call.args).toContain('-EncodedCommand'); + const script=Buffer.from(encoded!,'base64').toString('utf16le'); + const invocation=nodeInvocation(call.args,call.options?.env);expect(invocation.command).toBe(nodePath); + const payload=JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')); + expect(payload).toEqual({args:[cliPath,'agent-ui','encoded-agent-command'],environment:{RADIOCLI_HOME:home,...(process.platform==='win32'?{USERPROFILE:homedir()}:{})}});expect(script).not.toContain(home);expect(script).not.toContain(cliPath); + }); + + it('surfaces an immediate native launcher failure after spawn',async()=>{ + const launch=vi.fn(()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',1);});return child;});vi.mocked(nodeSpawn).mockImplementation(launch as typeof nodeSpawn); + await expect(launchRadioTui('/node','/cli.js','encoded',{platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal'},spawn:launch,resolve:command=>command})).rejects.toThrow(/terminal.*exit|launcher.*exit/i); + }); + + it('does not launch a graphical terminal in a headless Unix session',async()=>{await expect(launchRadioTui('/node','/cli.js','encoded',{platform:'linux',env:{RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},spawn,resolve:command=>command})).rejects.toThrow(/graphical|display|headless/i);expect(spawn).not.toHaveBeenCalled();}); + it.each(['freebsd','openbsd','netbsd'] as const)('requests the same agent session command in a supported %s desktop',async platform=>{ + await launchRadioTui('/node','/cli.js','encoded',{platform,env:{DISPLAY:':0',TERMINAL:'xterm'},spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined}); + expect(calls[0]!.command).toBe('/usr/local/bin/xterm');expect(calls[0]!.args.slice(0,3)).toEqual(['-e','/node','-e']); + expect(JSON.parse(Buffer.from(calls[0]!.args.at(-1)!,'base64url').toString('utf8'))).toEqual({args:['/cli.js','agent-ui','encoded'],environment:{}}); + }); +}); diff --git a/src/agent/launcher.ts b/src/agent/launcher.ts index 91ba156..9d02026 100644 --- a/src/agent/launcher.ts +++ b/src/agent/launcher.ts @@ -1,69 +1,12 @@ -import {spawn, type ChildProcess} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {basename, join} from 'node:path'; -import {detectAlarmTerminal} from '../alarms/terminal-launcher.js'; +import {spawn} from 'node:child_process'; +import {launchTerminalCommand, type TerminalOptions} from '../platform/terminals.js'; +import {waitForLaunch} from '../platform/launch-command.js'; -const linuxTerminals = new Set(['alacritty', 'foot', 'ghostty', 'gnome-terminal', 'kitty', 'konsole', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'wezterm', 'xfce4-terminal', 'x-terminal-emulator']); - -export async function launchRadioTui(nodePath: string, cliPath: string, encodedCommand: string): Promise { - const terminal = detectAlarmTerminal(); - const direct = environmentCommand(nodePath, cliPath, ['agent-ui', encodedCommand]); - const command = `${direct.map(shellQuote).join(' ')}`; - if (terminal === 'darwin:apple-terminal') await launched(spawnDetached('/usr/bin/osascript', appleTerminalScript(command))); - else if (terminal === 'darwin:iterm') await launched(spawnDetached('/usr/bin/osascript', iTermScript(command))); - else if (terminal === 'darwin:wezterm') await launched(spawnDetached('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct])); - else if (terminal === 'darwin:ghostty') await launched(spawnDetached('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...direct])); - else if (terminal === 'darwin:kitty') await launched(spawnDetached('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...direct])); - else if (terminal === 'win32:windows-terminal') await launched(spawnDetached('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI', ...direct])); - else if (terminal === 'win32:console') await launched(spawnDetached('cmd.exe', ['/d', '/c', 'start', 'RadioCLI', 'cmd.exe', '/k', windowsCommand(direct)])); - else if (terminal.startsWith('linux:')) { - const executable = terminal.slice('linux:'.length); - if (!linuxTerminals.has(basename(executable))) throw new Error('Saved Linux terminal is not supported.'); - const name = basename(executable); - const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' - ? ['--'] - : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e']; - await launched(spawnDetached(executable, [...prefix, ...direct])); - } else { - throw new Error('No supported graphical terminal was found. Set agentControl.openUiOnPlay to false or open radiocli manually.'); - } +/** The agent service verifies the authenticated session after this request. */ +export async function launchRadioTui(nodePath: string, cliPath: string, encodedCommand: string, options: TerminalOptions = {}): Promise { + await launchTerminalCommand({...options, nodePath, args: [cliPath, 'agent-ui', encodedCommand], title: 'RadioCLI'}); } export async function launchHeadlessHost(nodePath: string, cliPath: string): Promise { - await launched(spawnDetached(nodePath, [cliPath, 'agent-host'])); -} - -function environmentCommand(nodePath: string, cliPath: string, args: string[]): string[] { - const command = [nodePath, cliPath, ...args]; - const radioCliHome = process.env.RADIOCLI_HOME; - if (!radioCliHome) return command; - if (process.platform === 'win32') { - return ['cmd.exe', '/d', '/c', `set "RADIOCLI_HOME=${cmdEscape(radioCliHome)}" && ${windowsCommand(command)}`]; - } - return ['/usr/bin/env', `RADIOCLI_HOME=${radioCliHome}`, ...command]; -} - -function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'`; } -function windowsCommand(values: string[]): string { return values.map(value => `"${value.replaceAll('"', '""')}"`).join(' '); } -function cmdEscape(value: string): string { return value.replaceAll('%', '%%').replaceAll('"', '""').replaceAll('^', '^^').replaceAll('&', '^&').replaceAll('|', '^|').replaceAll('<', '^<').replaceAll('>', '^>'); } -function appleTerminalScript(command: string): string[] { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; } -function iTermScript(command: string): string[] { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; } -function spawnDetached(command: string, args: readonly string[]): ChildProcess { return spawn(command, [...args], {detached: true, stdio: 'ignore', windowsHide: false}); } -function launched(child: ChildProcess): Promise { return new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', () => { child.unref(); resolve(); }); }); } - -export function resolveExecutable( - input: string, - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform -): string | undefined { - if ((input.includes('/') || input.includes('\\')) && existsSync(input)) return input; - const suffixes = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; - const pathDelimiter = platform === 'win32' ? ';' : ':'; - for (const directory of (env.PATH ?? '').split(pathDelimiter)) { - for (const suffix of suffixes) { - const path = join(directory, `${input}${suffix}`); - if (existsSync(path)) return path; - } - } - return undefined; + await waitForLaunch(spawn(nodePath, [cliPath, 'agent-host'], {detached: true, stdio: 'ignore', windowsHide: false})); } diff --git a/src/agent/mcp-install.ts b/src/agent/mcp-install.ts index 6ea248c..b427a55 100644 --- a/src/agent/mcp-install.ts +++ b/src/agent/mcp-install.ts @@ -6,7 +6,7 @@ import {applyEdits, modify, parse, type FormattingOptions} from 'jsonc-parser'; import type {Writable} from 'node:stream'; import {JsonLibraryStore} from '../storage/store.js'; import {defaultAgentControlSettings} from '../types.js'; -import {resolveExecutable} from './launcher.js'; +import {resolveCommandDetails} from '../platform/executables.js'; import type {AgentRuntime} from './service.js'; export type McpInstallResult = {client: string; status: 'configured' | 'removed' | 'not-found' | 'failed' | 'inherited'; detail: string}; @@ -41,7 +41,7 @@ export async function configureMcpIntegrations( : ['mcp', 'remove', 'radiocli'], ['mcp', 'remove', 'radiocli'])); } else results.push(notFound('Codex')); - const claude = resolveExecutable('claude'); + const claude = resolveCommandDetails('claude', {env: process.env}).path; if (claude) { results.push(await configureCliClient('Claude Code', claude, enabled ? ['mcp', 'add', '--scope', 'user', 'radiocli', '--', ...command] @@ -49,7 +49,7 @@ export async function configureMcpIntegrations( } else results.push(notFound('Claude Code')); const openCodePath = openCodeConfigPath(); - if (resolveExecutable('opencode') || existsSync(openCodePath)) { + if (resolveCommandDetails('opencode', {env: process.env}).path || existsSync(openCodePath)) { try { updateOpenCodeConfig(openCodePath, enabled, command); results.push({client: 'OpenCode', status: enabled ? 'configured' : 'removed', detail: openCodePath}); @@ -61,14 +61,14 @@ export async function configureMcpIntegrations( configureJsonClient(results, 'Cursor', cursorDetected(), join(homedir(), '.cursor', 'mcp.json'), ['mcpServers', 'radiocli'], enabled, { command: command[0], args: command.slice(1) }); - configureJsonClient(results, 'Gemini CLI', Boolean(resolveExecutable('gemini')), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, { + configureJsonClient(results, 'Gemini CLI', Boolean(resolveCommandDetails('gemini', {env: process.env}).path), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, { command: command[0], args: command.slice(1) }); configureJsonClient(results, 'VS Code / Copilot Agent Host', vsCodeDetected(), join(homedir(), '.copilot', 'mcp-config.json'), ['servers', 'radiocli'], enabled, { type: 'stdio', command: command[0], args: command.slice(1) }); - if (resolveExecutable('orca')) { + if (resolveCommandDetails('orca', {env: process.env}).path) { results.push({client: 'Orca', status: 'inherited', detail: 'Orca exposes MCP integrations through its configured Codex/Claude agent runtimes.'}); } else results.push(notFound('Orca')); @@ -195,7 +195,7 @@ export function probeMcpServer(command: string[], timeoutMs = 5_000): Promise<{o } function matchingRadioCliShim(runtime: AgentRuntime, platform: NodeJS.Platform): string | undefined { - const candidate = resolveExecutable('radiocli', process.env, platform); + const candidate = resolveCommandDetails('radiocli', {env: process.env, platform}).path; if (!candidate) return undefined; if (!usableDirectLauncher(candidate, platform)) return undefined; try { @@ -216,16 +216,16 @@ async function mcpClientStates(command: string[]): Promise { +async function claudeClientState(executable: string | null | undefined, expected: string[]): Promise { if (!executable) return managedClientState(false); const result = await run(executable, ['mcp', 'get', 'radiocli']); const detail = `${result.stdout}\n${result.stderr}`.trim(); diff --git a/src/agent/session.test.ts b/src/agent/session.test.ts index bd99347..18b1b6d 100644 --- a/src/agent/session.test.ts +++ b/src/agent/session.test.ts @@ -1,4 +1,6 @@ -import {mkdtempSync, rmSync} from 'node:fs'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {createServer} from 'node:http'; +import {spawn} from 'node:child_process'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {afterEach, describe, expect, it} from 'vitest'; @@ -54,4 +56,116 @@ describe('agent radio session', () => { await first.close(); } }); + + for (const host of ['127.0.0.1', '::1']) { + it(`authenticates directly to a discovered ${host} endpoint`, async context => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-family-')); + roots.push(root); + const path = join(root, 'session.json'); + let authenticatedRequests = 0; + const server = createServer((request, response) => { + if (request.headers.authorization === 'Bearer test-session-token') authenticatedRequests += 1; + request.resume(); + response.end(JSON.stringify({ok: true, message: 'ok', status: idle})); + }); + try { + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, host, resolve);}); + } catch (error) { + if (host === '::1' && ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip('The host does not provide IPv6 loopback.'); + } + throw error; + } + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + writeFileSync(path, JSON.stringify({version: 1, host, port: address.port, token: 'test-session-token', pid: process.pid, createdAt: new Date().toISOString()})); + const client = await connectRadioSession(path); + expect(client).not.toBeNull(); + expect(await client!.status()).toEqual(idle); + expect(authenticatedRequests).toBe(2); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + } + + it('rejects a discovery hostname without contacting the advertised port', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-host-')); + roots.push(root); + const path = join(root, 'session.json'); + let requests = 0; + const server = createServer((_request, response) => {requests += 1;response.end('{}');}); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + writeFileSync(path, JSON.stringify({version: 1, host: 'localhost', port: address.port, token: 'test-session-token', pid: process.pid, createdAt: new Date().toISOString()})); + expect(await connectRadioSession(path)).toBeNull(); + expect(requests).toBe(0); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } + }); + + it.skipIf(!process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))( + 'keeps radio and alarm control tokens away from environment proxies in a fresh Node process', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-control-proxy-')); + roots.push(root); + let proxyRequests = 0; + let proxySawAuthorization = false; + const proxy = createServer((request, response) => { + proxyRequests += 1; + proxySawAuthorization ||= Boolean(request.headers.authorization); + request.resume(); + response.writeHead(401); + response.end('{}'); + }); + await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve)); + try { + const address = proxy.address(); + if (!address || typeof address === 'string') throw new Error('No proxy address.'); + const env = {...process.env}; + for (const key of Object.keys(env)) if (/^(?:https?|all|no)_proxy$/i.test(key) || key === 'NODE_OPTIONS') delete env[key]; + env.NODE_USE_ENV_PROXY = '1'; + env.HTTP_PROXY = `http://127.0.0.1:${address.port}`; + env.NO_PROXY = ''; + const source = ` + import {join} from 'node:path'; + import {startRadioSession, connectRadioSession} from ${JSON.stringify(new URL('./session.ts', import.meta.url).href)}; + import {startActiveAlarmSession, connectActiveAlarm} from ${JSON.stringify(new URL('../alarms/active-session.ts', import.meta.url).href)}; + const root = process.argv[1]; + const idle = ${JSON.stringify(idle)}; + const radioPath = join(root, 'radio.json'); + const alarmPath = join(root, 'alarm.json'); + const radio = await startRadioSession(async () => ({ok: true, message: 'ok', status: idle}), radioPath); + const alarm = await startActiveAlarmSession({alarmId: 'test', scheduledAt: '2030-01-01T00:00:00Z', stationName: 'Local alarm', startedAt: '2030-01-01T00:00:00Z'}, {filePath: alarmPath, onDismiss() {}, onSnooze() {}, onKeepPlaying() {}}); + try { + const radioClient = await connectRadioSession(radioPath); + const alarmClient = await connectActiveAlarm(alarmPath); + console.log(JSON.stringify({radio: Boolean(radioClient), alarm: Boolean(alarmClient)})); + } finally { + await alarm.close(); + await radio.close(); + } + `; + const output = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', source, root], {env, timeout: 8000}); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => {stdout += String(chunk);}); + child.stderr.on('data', chunk => {stderr += String(chunk);}); + child.once('error', reject); + child.once('close', code => code === 0 ? resolve(stdout) : reject(new Error(`Control probe exited ${code}: ${stderr}`))); + }); + expect(proxyRequests).toBe(0); + expect(proxySawAuthorization).toBe(false); + expect(JSON.parse(output)).toEqual({radio: true, alarm: true}); + } finally { + proxy.closeAllConnections(); + await new Promise(resolve => proxy.close(() => resolve())); + } + } + ); }); diff --git a/src/agent/session.ts b/src/agent/session.ts index 280332b..ba12cfa 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -1,11 +1,13 @@ import {randomBytes} from 'node:crypto'; import {chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {createServer, request} from 'node:http'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import type {AirPlayDevice, AppSettings, PlaybackState, Station} from '../types.js'; +import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; export type RadioSessionStatus = { + persistenceWarning?: string; owner: 'tui' | 'headless'; playback: PlaybackState; station: Station | null; @@ -32,7 +34,7 @@ export type RadioSessionCommand = | {type: 'update-settings'; settings: Partial>}; export type RadioSessionResult = {ok: boolean; message: string; status: RadioSessionStatus; data?: AirPlayDevice[]}; -type Discovery = {version: 1; host: '127.0.0.1'; port: number; token: string; pid: number; createdAt: string}; +type Discovery = {version: 1; host: LoopbackHost; port: number; token: string; pid: number; createdAt: string}; export type RadioSessionClient = { call(command: RadioSessionCommand): Promise; @@ -70,15 +72,10 @@ export async function startRadioSession( }); }); try { - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Unable to create RadioCLI control endpoint.'); + const address = await listenLoopback(server); const discovery: Discovery = { version: 1, - host: '127.0.0.1', + host: address.host, port: address.port, token, pid: process.pid, @@ -104,7 +101,7 @@ export async function connectRadioSession(filePath = radioSessionPath()): Promis let discovery: Discovery; try { discovery = JSON.parse(readFileSync(filePath, 'utf8')) as Discovery; - if (discovery.version !== 1 || discovery.host !== '127.0.0.1' || !Number.isInteger(discovery.port) || !discovery.token) { + if (discovery.version !== 1 || !isLoopbackHost(discovery.host) || !Number.isInteger(discovery.port) || !discovery.token) { throw new Error('invalid'); } } catch { @@ -115,7 +112,9 @@ export async function connectRadioSession(filePath = radioSessionPath()): Promis const payload = JSON.stringify(command); return new Promise((resolve, reject) => { const req = request({ - host: '127.0.0.1', + host: discovery.host, + // Local control tokens must never be forwarded by an environment proxy. + agent: false, port: discovery.port, path: '/command', method: 'POST', @@ -184,9 +183,7 @@ async function waitForSession(timeoutMs: number): Promise { } function runtimeDirectory(): string { - if (process.env.RADIOCLI_HOME) return join(process.env.RADIOCLI_HOME, 'runtime'); - if (process.platform === 'win32') return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime'); - return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli'); + return platformPaths().runtime; } function acquireOwner(path: string): void { diff --git a/src/alarms/active-session.test.ts b/src/alarms/active-session.test.ts index dfe32f9..b2d6caf 100644 --- a/src/alarms/active-session.test.ts +++ b/src/alarms/active-session.test.ts @@ -4,10 +4,44 @@ import {join} from 'node:path'; import {createServer} from 'node:http'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {connectActiveAlarm,connectActiveAlarms, startActiveAlarmSession} from './active-session.js'; +import {createSystemVolumeController} from './system-volume.js'; +import {SystemVolumeOwnership} from './system-volume-ownership.js'; const dirs:string[]=[]; afterEach(()=>dirs.splice(0).forEach(path=>rmSync(path,{recursive:true,force:true}))); +it('waits for handoff ownership contention beyond the short discovery deadline',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-handoff-lock-'));dirs.push(root); + const directory=join(root,'volume'); + const run=async(_command:string,args:string[])=>({code:0,stdout:args.includes('set s to get volume settings')?'20,true':'',stderr:''}); + const lease=await createSystemVolumeController('darwin',run,()=>null,{directory}).acquireMinimum(70); + let unlock:()=>void=()=>{};let lockReady:()=>void=()=>{}; + const ready=new Promise(resolve=>{lockReady=resolve;}); + const holding=new SystemVolumeOwnership(directory).transaction(async()=>{lockReady();await new Promise(resolve=>{unlock=resolve;});}); + await ready; + let server:Awaited>|undefined; + let timer:NodeJS.Timeout|undefined; + let startedHandoff:()=>void=()=>{}; + const started=new Promise(resolve=>{startedHandoff=resolve;}); + let handedOff=false; + try{ + const file=join(root,'active.json'); + server=await startActiveAlarmSession({alarmId:'a',scheduledAt:'2030-01-01T00:00:00Z',stationName:'A',startedAt:'2030-01-01T00:00:00Z'},{ + filePath:file,onDismiss:vi.fn(),onSnooze:vi.fn(),onKeepPlaying:vi.fn(), + async onHandoff(){startedHandoff();await lease.release({preserve:true});handedOff=true;} + }); + const client=await connectActiveAlarm(file); + const handoff=client!.handoff().then(()=>({ok:true}),error=>({error})); + await started; + timer=setTimeout(unlock,1_200); + expect(await handoff).toEqual({ok:true}); + expect(handedOff).toBe(true); + }finally{ + clearTimeout(timer);unlock();await holding; + await server?.close();await lease.release(); + } +}); + describe('active alarm IPC',()=>{ it('discovers, authenticates, controls, and removes a local session',async()=>{ const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-'));dirs.push(root);const file=join(root,'active.json'); @@ -21,6 +55,41 @@ describe('active alarm IPC',()=>{ await server.close(); expect(await connectActiveAlarm(file)).toBeNull(); }); + + for (const host of ['127.0.0.1', '::1']) { + it(`authenticates directly to a discovered ${host} alarm endpoint`, async context => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-alarm-family-')); + dirs.push(root); + const file = join(root, 'active.json'); + const status = {alarmId: 'family', scheduledAt: '2030-01-01T00:00:00Z', stationName: 'Local radio', startedAt: '2030-01-01T00:00:00Z'}; + let authenticatedRequests = 0; + const server = createServer((request, response) => { + if (request.headers.authorization === 'Bearer test-alarm-token') authenticatedRequests += 1; + request.resume(); + response.end(JSON.stringify(status)); + }); + try { + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, host, resolve);}); + } catch (error) { + if (host === '::1' && ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip('The host does not provide IPv6 loopback.'); + } + throw error; + } + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + writeFileSync(file, JSON.stringify({version: 1, host, port: address.port, token: 'test-alarm-token', pid: process.pid, alarmId: 'family'})); + const client = await connectActiveAlarm(file); + expect(client).not.toBeNull(); + expect(await client!.status()).toEqual(status); + expect(authenticatedRequests).toBe(2); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + } }); it('keeps concurrent alarm sessions independently discoverable and owned',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-many-'));dirs.push(root);const first=await startActiveAlarmSession({alarmId:'one',scheduledAt:'2030-01-01T00:00:00Z',stationName:'One',startedAt:'2030-01-01T00:00:00Z'},{filePath:join(root,'one.json'),onDismiss:vi.fn(),onSnooze:vi.fn(),onKeepPlaying:vi.fn()});const second=await startActiveAlarmSession({alarmId:'two',scheduledAt:'2030-01-01T00:00:00Z',stationName:'Two',startedAt:'2030-01-01T00:00:00Z'},{filePath:join(root,'two.json'),onDismiss:vi.fn(),onSnooze:vi.fn(),onKeepPlaying:vi.fn()});expect(await connectActiveAlarms(root)).toHaveLength(2);await first.close();expect((await connectActiveAlarms(root))).toHaveLength(1);await second.close();}); diff --git a/src/alarms/active-session.ts b/src/alarms/active-session.ts index 3d0f7dd..16a1752 100644 --- a/src/alarms/active-session.ts +++ b/src/alarms/active-session.ts @@ -1,9 +1,10 @@ import {randomBytes} from 'node:crypto'; import {chmodSync, existsSync, mkdirSync, readFileSync,readdirSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {createServer, request} from 'node:http'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import type {Station} from '../types.js'; +import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; export type ActiveAlarmStatus = { alarmId: string; @@ -14,7 +15,7 @@ export type ActiveAlarmStatus = { state?: 'starting'|'playing'|'stopping'|'failed'; keepPlaying?: boolean; }; -type Discovery={version:1;host:'127.0.0.1';port:number;token:string;pid:number;alarmId:string;createdAt?:string}; +type Discovery={version:1;host:LoopbackHost;port:number;token:string;pid:number;alarmId:string;createdAt?:string}; export type ActiveAlarmHandlers={filePath?:string;onDismiss():void|Promise;onSnooze(minutes:number):void|Promise;onKeepPlaying():void|Promise;onHandoff?():void|Promise}; export type ActiveAlarmServer={update(status:Partial):void;close():Promise}; export type ActiveAlarmClient={status():Promise;dismiss():Promise;snooze(minutes:number):Promise;keepPlaying():Promise;handoff():Promise}; @@ -33,25 +34,26 @@ export async function startActiveAlarmSession(initial:ActiveAlarmStatus,handlers res.statusCode=404;res.end('{}'); }catch(error){res.statusCode=400;res.end(JSON.stringify({error:error instanceof Error?error.message:'request failed'}));} }); - await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>resolve());}); - const address=server.address();if(!address||typeof address==='string')throw new Error('Unable to create alarm control endpoint.'); - const discovery={version:1,host:'127.0.0.1',port:address.port,token,pid:process.pid,alarmId:initial.alarmId,createdAt:new Date().toISOString()} satisfies Discovery;const temp=`${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;try{mkdirSync(dirname(filePath),{recursive:true,mode:0o700});writeFileSync(temp,`${JSON.stringify(discovery)}\n`,{mode:0o600});renameSync(temp,filePath);if(process.platform!=='win32')chmodSync(filePath,0o600);}catch(error){rmSync(temp,{force:true});removeDiscoveryIfOwned(filePath,discovery);await new Promise(resolve=>server.close(()=>resolve()));throw error;} + const address=await listenLoopback(server); + const discovery={version:1,host:address.host,port:address.port,token,pid:process.pid,alarmId:initial.alarmId,createdAt:new Date().toISOString()} satisfies Discovery;const temp=`${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;try{mkdirSync(dirname(filePath),{recursive:true,mode:0o700});writeFileSync(temp,`${JSON.stringify(discovery)}\n`,{mode:0o600});renameSync(temp,filePath);if(process.platform!=='win32')chmodSync(filePath,0o600);}catch(error){rmSync(temp,{force:true});removeDiscoveryIfOwned(filePath,discovery);await new Promise(resolve=>server.close(()=>resolve()));throw error;} return {update(change){status={...status,...change};},async close(){await new Promise(resolve=>server.close(()=>resolve()));removeDiscoveryIfOwned(filePath,discovery);}}; } export async function connectActiveAlarm(filePath?:string):Promise{ if(!filePath){const clients=await connectActiveAlarms();return clients[0]??null;} if(!existsSync(filePath))return null;let discovery:Discovery; - try{discovery=JSON.parse(readFileSync(filePath,'utf8')) as Discovery;if(discovery.version!==1||discovery.host!=='127.0.0.1'||!Number.isInteger(discovery.port)||!discovery.token)throw new Error('invalid');} + try{discovery=JSON.parse(readFileSync(filePath,'utf8')) as Discovery;if(discovery.version!==1||!isLoopbackHost(discovery.host)||!Number.isInteger(discovery.port)||!discovery.token)throw new Error('invalid');} catch{return null;} - const call=async(method:string,path:string,body?:unknown)=>{const payload=body===undefined?'':JSON.stringify(body);return new Promise((resolve,reject)=>{const req=request({host:'127.0.0.1',port:discovery.port,path,method,headers:{authorization:`Bearer ${discovery.token}`,'content-type':'application/json','content-length':Buffer.byteLength(payload)}},res=>{let text='';res.on('data',value=>text+=String(value));res.on('end',()=>res.statusCode&&res.statusCode<300?resolve(text?JSON.parse(text):{}):reject(new Error('Alarm control request failed.')));});req.once('error',reject);req.setTimeout(1000,()=>req.destroy(new Error('Alarm control request timed out.')));req.end(payload);});}; + // A fresh direct agent keeps local control tokens out of environment proxies. + // Handoff can wait for the shared output lock (30s); discovery keeps its short deadline. + const call=async(method:string,path:string,body?:unknown)=>{const payload=body===undefined?'':JSON.stringify(body);return new Promise((resolve,reject)=>{const req=request({host:discovery.host,agent:false,port:discovery.port,path,method,headers:{authorization:`Bearer ${discovery.token}`,'content-type':'application/json','content-length':Buffer.byteLength(payload)}},res=>{let text='';res.on('data',value=>text+=String(value));res.on('end',()=>res.statusCode&&res.statusCode<300?resolve(text?JSON.parse(text):{}):reject(new Error('Alarm control request failed.')));});req.once('error',reject);req.setTimeout(path==='/handoff'?35_000:1000,()=>req.destroy(new Error('Alarm control request timed out.')));req.end(payload);});}; try{await call('GET','/status');}catch{if(!processAlive(discovery.pid)||discoveryAgeMs(filePath,discovery)>5*60_000)removeDiscoveryIfOwned(filePath,discovery);return null;} return {status:async()=>await call('GET','/status') as ActiveAlarmStatus,dismiss:async()=>{await call('POST','/dismiss');},snooze:async minutes=>{await call('POST','/snooze',{minutes});},keepPlaying:async()=>{await call('POST','/keep-playing');},handoff:async()=>{await call('POST','/handoff');}}; } export async function connectActiveAlarms(directory=defaultActiveAlarmDirectory()):Promise{if(!existsSync(directory))return[];const paths=readdirSync(directory).filter(name=>name.endsWith('.json')).map(name=>join(directory,name));const clients=await Promise.all(paths.map(path=>connectActiveAlarm(path)));return clients.filter((client):client is ActiveAlarmClient=>Boolean(client));} -function defaultActiveAlarmDirectory():string{if(process.env.RADIOCLI_HOME)return join(process.env.RADIOCLI_HOME,'runtime','active-alarms');if(process.platform==='darwin')return join(homedir(),'Library','Application Support','radiocli','runtime','active-alarms');if(process.platform==='win32')return join(process.env.LOCALAPPDATA??join(homedir(),'AppData','Local'),'RadioCLI','runtime','active-alarms');return join(process.env.XDG_RUNTIME_DIR??join(homedir(),'.local','state'),'radiocli','active-alarms');} +function defaultActiveAlarmDirectory():string{return join(platformPaths().alarmRuntime,'active-alarms');} function defaultActiveAlarmPath(alarmId='active',occurrenceAt='current'):string{return join(defaultActiveAlarmDirectory(),`${Buffer.from(`${alarmId}\0${occurrenceAt}`).toString('base64url')}.json`);} function readBody(req:import('node:http').IncomingMessage):Promise{return new Promise((resolve,reject)=>{let body='';req.on('data',value=>{body+=String(value);if(body.length>4096)req.destroy(new Error('Request too large.'));});req.on('end',()=>resolve(body));req.on('error',reject);});} function removeDiscoveryIfOwned(filePath:string,owner:Discovery){let current:Partial;try{current=JSON.parse(readFileSync(filePath,'utf8')) as Partial;}catch{return;}if(!sameDiscovery(current,owner))return;const quarantine=`${filePath}.closing-${process.pid}-${randomBytes(6).toString('hex')}`;try{renameSync(filePath,quarantine);const moved=JSON.parse(readFileSync(quarantine,'utf8')) as Partial;if(sameDiscovery(moved,owner)){rmSync(quarantine,{force:true});return;}if(!existsSync(filePath))renameSync(quarantine,filePath);}catch{if(existsSync(quarantine)&&!existsSync(filePath))try{renameSync(quarantine,filePath);}catch{}} } diff --git a/src/alarms/guard.test.ts b/src/alarms/guard.test.ts index 0751582..6937105 100644 --- a/src/alarms/guard.test.ts +++ b/src/alarms/guard.test.ts @@ -2,6 +2,7 @@ import {mkdirSync,mkdtempSync,readFileSync,rmSync,utimesSync,writeFileSync} from import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {request} from 'node:http'; +import {Server} from 'node:net'; import {describe,expect,it,vi} from 'vitest'; import type {Alarm} from '../types.js'; import {AlarmGuardService,runAlarmGuard,terminateGuardTree} from './guard.js'; @@ -45,14 +46,59 @@ describe('one-shot alarm guard',()=>{ }finally{rmSync(root,{recursive:true,force:true});} }); - it('does not signal a reused PID without a fresh ownership heartbeat',async()=>{ + it('retains a live unverified PID for repair without signaling it',async()=>{ const root=mkdtempSync(join(tmpdir(),'radiocli-guard-stale-')); try{ const directory=join(root,'guards');const store=new AlarmPowerGuardStore(join(root,'power.json')); const stop=vi.fn(async()=>true);const service=new AlarmGuardService(store,'node','cli.js',vi.fn(),()=>new Date(),directory,async()=>false,stop); const alarmId='a';const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`${alarmId}\0${occurrenceAt}`).toString('base64url')}.json`);mkdirSync(directory,{recursive:true}); writeFileSync(path,JSON.stringify({alarmId,occurrenceAt,pid:process.pid,port:43210,token:'a'.repeat(64)}),{mode:0o600}); - expect((await service.status()).active).toBe(false);expect(()=>readFileSync(path)).toThrow();expect(stop).not.toHaveBeenCalled(); + expect(await service.status()).toMatchObject({active:false,guards:[],unresolvedGuards:[{alarmId,occurrenceAt,pid:process.pid}]}); + expect(await service.stop(alarmId)).toBe(false);expect(readFileSync(path,'utf8')).toContain('"alarmId":"a"');expect(stop).not.toHaveBeenCalled(); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it('blocks replacement when the previous live guard cannot prove ownership',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-unresolved-')); + try{ + const directory=join(root,'guards');const store=new AlarmPowerGuardStore(join(root,'power.json')); + const stop=vi.fn(async()=>true);const spawn=vi.fn();const service=new AlarmGuardService(store,'node','cli.js',spawn,()=>new Date('2030-01-01T00:00:00Z'),directory,async()=>false,stop); + const alarm:Alarm={id:'a',label:'Wake',enabled:true,station:{id:'x',provider:'radio-browser',name:'X',tags:[]},schedule:{type:'once',at:'2030-01-01T00:02:00.000Z'},playback:{volume:30,fadeSeconds:0,stopAfterMinutes:30},reliability:{missedRunGraceMinutes:10,wakeIfSupported:false},createdAt:'x',updatedAt:'x'}; + const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`a\0${occurrenceAt}`).toString('base64url')}.json`);mkdirSync(directory,{recursive:true}); + writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt,pid:process.pid,port:43210,token:'a'.repeat(64)}),{mode:0o600}); + await expect(service.start(alarm)).rejects.toThrow(/ownership.*repair/i); + expect(spawn).not.toHaveBeenCalled();expect(stop).not.toHaveBeenCalled();expect(readFileSync(path,'utf8')).toContain(occurrenceAt); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it('removes a guard record only when its process is confirmed absent',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-dead-')); + const kill=vi.spyOn(process,'kill').mockImplementation(()=>{throw Object.assign(new Error('No process'),{code:'ESRCH'});}); + try{ + const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`a\0${occurrenceAt}`).toString('base64url')}.json`); + writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt,pid:42,port:43210,token:'a'.repeat(64)})); + const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory,async()=>false,vi.fn()); + expect(await service.status()).toMatchObject({active:false,guards:[],unresolvedGuards:[]});expect(()=>readFileSync(path)).toThrow();expect(kill).toHaveBeenCalledWith(42,0); + }finally{kill.mockRestore();rmSync(root,{recursive:true,force:true});} + }); + + it('retains permission-denied process state as unresolved',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-eperm-')); + const kill=vi.spyOn(process,'kill').mockImplementation(()=>{throw Object.assign(new Error('Permission denied'),{code:'EPERM'});}); + try{ + const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`a\0${occurrenceAt}`).toString('base64url')}.json`); + writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt,pid:42,port:43210,token:'a'.repeat(64)})); + const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory,async()=>false,vi.fn()); + expect(await service.stop('a')).toBe(false);expect(await service.status()).toMatchObject({active:false,unresolvedGuards:[{alarmId:'a',pid:42}]});expect(readFileSync(path,'utf8')).toContain('"pid":42');expect(kill.mock.calls.every(call=>call[1]===0)).toBe(true); + }finally{kill.mockRestore();rmSync(root,{recursive:true,force:true});} + }); + + it('does not contact a non-loopback host from an ownership artifact',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-host-')); + try{ + const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const path=join(directory,'untrusted.json');writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt:'2030-01-01T00:01:00.000Z',pid:process.pid,host:'example.com',port:43210,token:'a'.repeat(64)})); + const verify=vi.fn(async()=>true);const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory,verify); + expect(await service.status()).toMatchObject({active:false,guards:[],unresolvedGuards:[expect.any(Object)]});expect(verify).not.toHaveBeenCalled();expect(readFileSync(path,'utf8')).toContain('example.com'); }finally{rmSync(root,{recursive:true,force:true});} }); @@ -61,7 +107,113 @@ describe('one-shot alarm guard',()=>{ it('serves an authenticated loopback challenge and stop control',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-guard-control-'));try{const path=join(root,'guard.json');const token='b'.repeat(64);const store=new AlarmPowerGuardStore(join(root,'power.json'));const release=vi.fn(async()=>{});const running=runAlarmGuard('a','2030-01-01T00:01:00Z',{status:vi.fn(),acquire:vi.fn(async()=>({release}))},store,()=>new Date('2030-01-01T00:00:00Z'),()=>new Promise(()=>{}),path,token);for(let attempt=0;attempt<20&&!readFile(path);attempt+=1)await new Promise(resolve=>setTimeout(resolve,5));const artifact=JSON.parse(readFileSync(path,'utf8')) as {port:number};expect(await controlRequest(artifact.port,token,'GET','/challenge')).toMatchObject({status:200,body:expect.stringContaining('"alarmId":"a"')});expect(await controlRequest(artifact.port,'wrong','POST','/stop')).toMatchObject({status:401});expect(await controlRequest(artifact.port,token,'POST','/stop')).toMatchObject({status:200});await running;expect(release).toHaveBeenCalled();expect(()=>readFileSync(path)).toThrow();}finally{rmSync(root,{recursive:true,force:true});}}); it('does not delete a transiently malformed ownership artifact while reading status',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-guard-parse-'));try{const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const path=join(directory,'partial.json');writeFileSync(path,'{"alarmId":');const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory);expect((await service.status()).active).toBe(false);expect(readFileSync(path,'utf8')).toBe('{"alarmId":');}finally{rmSync(root,{recursive:true,force:true});}}); + + it('publishes and challenges its IPv6 loopback endpoint when IPv4 is unavailable',async context=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-ipv6-'));const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const path=join(directory,'guard.json');const token='c'.repeat(64);const store=new AlarmPowerGuardStore(join(root,'power.json'));const release=vi.fn(async()=>{}); + const listen=vi.spyOn(Server.prototype,'listen').mockImplementationOnce(function(this:Server){queueMicrotask(()=>this.emit('error',Object.assign(new Error('IPv4 unavailable'),{code:'EAFNOSUPPORT'})));return this;}); + let failed:unknown;const running=runAlarmGuard('a','2030-01-01T00:01:00Z',{status:vi.fn(),acquire:vi.fn(async()=>({release}))},store,()=>new Date('2030-01-01T00:00:00Z'),()=>new Promise(()=>{}),path,token);void running.catch(error=>{failed=error;}); + let artifact:{host:'::1';port:number}|undefined; + try{ + for(let attempt=0;attempt<40&&!readFile(path)&&!failed;attempt+=1)await new Promise(resolve=>setTimeout(resolve,5)); + if(failed&&['EAFNOSUPPORT','EPROTONOSUPPORT','EADDRNOTAVAIL'].includes((failed as NodeJS.ErrnoException).code??''))context.skip('The host does not provide IPv6 loopback.'); + if(failed)throw failed; + artifact=JSON.parse(readFileSync(path,'utf8')) as typeof artifact;expect(artifact?.host).toBe('::1'); + const service=new AlarmGuardService(store,'node','cli.js',vi.fn(),()=>new Date(),directory); + expect(await service.status()).toMatchObject({active:true,guards:[{alarmId:'a',pid:process.pid}],unresolvedGuards:[]}); + expect(await controlRequest(artifact!.port,token,'POST','/stop',artifact!.host)).toMatchObject({status:200});await running;expect(release).toHaveBeenCalled(); + }finally{ + if(artifact&&readFile(path))await controlRequest(artifact.port,token,'POST','/stop',artifact.host).catch(()=>undefined); + if(artifact||failed)await running.catch(()=>undefined); + listen.mockRestore();rmSync(root,{recursive:true,force:true}); + } + }); +}); + +describe('alarm-specific Guard ownership', () => { + const oldOccurrence = '2030-01-01T00:01:00.000Z'; + const nameFor = (alarmId: string) => `${Buffer.from(`${alarmId}\0${oldOccurrence}`).toString('base64url')}.json`; + const ownerFor = (alarmId: string) => JSON.stringify({alarmId, occurrenceAt: oldOccurrence, pid: process.pid, port: 43210, token: 'a'.repeat(64)}); + + async function withRecord(name: string, contents: string, check: (fixture: { + service: AlarmGuardService; + alarm: Alarm; + path: string; + directory: string; + spawn: ReturnType; + }) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), 'radiocli-guard-record-scope-')); + try { + const directory = join(root, 'guards'); + mkdirSync(directory); + const path = join(directory, name); + writeFileSync(path, contents); + const store = new AlarmPowerGuardStore(join(root, 'power.json')); + let spawnedToken: string | undefined; + const spawn = vi.fn((_command: string, args: string[]) => { + const occurrenceAt = args.at(-3)!; + const pidPath = args.at(-2)!; + const token = args.at(-1)!; + spawnedToken = token; + writeFileSync(pidPath, JSON.stringify({alarmId: 'a', occurrenceAt, pid: process.pid, port: 43210, token})); + store.markActive('a', new Date(), occurrenceAt); + return {pid: process.pid, unref: vi.fn()}; + }); + const alarm: Alarm = { + id: 'a', label: 'Wake', enabled: true, + station: {id: 'x', provider: 'radio-browser', name: 'X', tags: []}, + schedule: {type: 'once', at: '2030-01-01T00:02:00.000Z'}, + playback: {volume: 30, fadeSeconds: 0, stopAfterMinutes: 30}, + reliability: {missedRunGraceMinutes: 10, wakeIfSupported: false}, + createdAt: 'x', updatedAt: 'x' + }; + const service = new AlarmGuardService(store, 'node', 'cli.js', spawn, () => new Date('2030-01-01T00:00:00Z'), directory, async owner => owner.token === spawnedToken, vi.fn(async () => false)); + await check({service, alarm, path, directory, spawn}); + } finally { + rmSync(root, {recursive: true, force: true}); + } + } + + it.each([ + {name: 'partial.json', contents: '{"alarmId":', reason: 'an undecodable filename'}, + {name: nameFor('b'), contents: '{"alarmId":', reason: 'another alarm encoded in its filename'}, + {name: nameFor('a'), contents: ownerFor('b'), reason: 'a parsed owner ID that differs from its filename'} + ])('starts an unrelated alarm despite $reason', async ({name, contents}) => { + await withRecord(name, contents, async ({service, alarm, path, spawn}) => { + await expect(service.start(alarm)).resolves.toMatchObject({pid: process.pid, occurrenceAt: alarm.schedule.type === 'once' ? alarm.schedule.at : undefined}); + expect(spawn).toHaveBeenCalledTimes(1); + expect(readFileSync(path, 'utf8')).toBe(contents); + }); + }); + + it.each([ + {name: nameFor('a'), contents: '{"alarmId":', reason: 'its own encoded filename'}, + {name: nameFor('b'), contents: ownerFor('a'), reason: 'its own parsed owner ID'} + ])('blocks a replacement identified by $reason', async ({name, contents}) => { + await withRecord(name, contents, async ({service, alarm, path, spawn}) => { + await expect(service.start(alarm)).rejects.toThrow(/ownership.*repair/i); + expect(spawn).not.toHaveBeenCalled(); + expect(readFileSync(path, 'utf8')).toBe(contents); + }); + }); + + it('reports one alarm stopped while retaining an unknown record for global repair', async () => { + await withRecord('partial.json', '{"alarmId":', async ({service, path, directory}) => { + const knownPath = join(directory, nameFor('a')); + writeFileSync(knownPath, ownerFor('a')); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => {throw Object.assign(new Error('No process'), {code: 'ESRCH'});}); + try { + expect(await service.stop('a')).toBe(true); + expect(() => readFileSync(knownPath)).toThrow(); + expect(await service.stop()).toBe(false); + expect(await service.status()).toMatchObject({active: false, guards: [], unresolvedGuards: [{message: expect.stringContaining('retained for repair')}]}); + expect(readFileSync(path, 'utf8')).toBe('{"alarmId":'); + expect(kill.mock.calls.every(call => call[1] === 0)).toBe(true); + } finally { + kill.mockRestore(); + } + }); + }); }); function readFile(path:string){try{readFileSync(path);return true;}catch{return false;}} -function controlRequest(port:number,token:string,method:string,path:string):Promise<{status:number;body:string}>{return new Promise((resolve,reject)=>{const req=request({host:'127.0.0.1',port,path,method,headers:{authorization:`Bearer ${token}`}},res=>{let body='';res.on('data',chunk=>body+=String(chunk));res.on('end',()=>resolve({status:res.statusCode??0,body}));});req.once('error',reject);req.end();});} +function controlRequest(port:number,token:string,method:string,path:string,host='127.0.0.1'):Promise<{status:number;body:string}>{return new Promise((resolve,reject)=>{const req=request({host,port,path,method,agent:false,headers:{authorization:`Bearer ${token}`}},res=>{let body='';res.on('data',chunk=>body+=String(chunk));res.on('end',()=>resolve({status:res.statusCode??0,body}));});req.once('error',reject);req.end();});} diff --git a/src/alarms/guard.ts b/src/alarms/guard.ts index 0e0d63b..864f460 100644 --- a/src/alarms/guard.ts +++ b/src/alarms/guard.ts @@ -2,15 +2,17 @@ import {spawn as nodeSpawn} from 'node:child_process'; import {randomBytes} from 'node:crypto'; import {chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {createServer,request} from 'node:http'; -import {dirname, join} from 'node:path'; +import {basename, dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {Alarm} from '../types.js'; import {AlarmPowerGuardStore} from './power-guard-store.js'; import type {PowerInhibitor} from './inhibitor.js'; import {nextOccurrenceForAlarm} from './schedule.js'; import {defaultAlarmRuntimeDirectory} from './runner.js'; +import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; -type GuardPid = {alarmId: string; occurrenceAt: string; pid: number; port: number; token: string}; +type GuardPid = {alarmId: string; occurrenceAt: string; pid: number; host?: LoopbackHost; port: number; token: string}; export type GuardSpawn = (command: string, args: string[], env: NodeJS.ProcessEnv) => {pid?: number; unref(): void}; type GuardVerifier=(guard:GuardPid)=>Promise; type GuardStopper=(guard:GuardPid)=>Promise; @@ -41,6 +43,7 @@ export class AlarmGuardService { // A recurring alarm may have been edited/rescheduled while its old guard is // alive. Stop that exact process tree before replacing its occurrence state. await this.stopOwned(alarm.id); + if (this.hasOwnershipRecords(alarm.id)) throw new Error('Previous Alarm Guard ownership is unresolved; retain the alarm for repair before starting another guard.'); this.store.request(alarm.id, occurrenceAt); const token=randomBytes(32).toString('hex'); const child = this.spawn( @@ -75,17 +78,20 @@ export class AlarmGuardService { async stop(alarmId?: string): Promise { if(alarmId)return this.withOwnership(alarmId,()=>this.stopOwned(alarmId)); const ids=[...new Set(this.pidFiles().map(readPid).filter((item):item is GuardPid=>Boolean(item)).map(item=>item.alarmId))]; - const results=await Promise.all(ids.map(id=>this.withOwnership(id,()=>this.stopOwned(id))));return results.every(Boolean); + const results=await Promise.all(ids.map(id=>this.withOwnership(id,()=>this.stopOwned(id))));return results.every(Boolean)&&!this.hasOwnershipRecords(); } private async stopOwned(alarmId?:string):Promise{ const targets = this.pidFiles() - .map(readPid) - .filter((item): item is GuardPid => Boolean(item)) - .filter(item => !alarmId || item.alarmId === alarmId); + .map(path => ({path, owner: readPid(path)})) + .filter((item): item is {path: string; owner: GuardPid} => Boolean(item.owner)) + .filter(item => !alarmId || item.owner.alarmId === alarmId); if (!targets.length) return false; - for (const current of targets) { - const path = this.path(current.alarmId, current.occurrenceAt); - if(!(await this.verify(current))){rmSync(path,{force:true});markIfCurrent(this.store,current,'Guard ownership challenge failed; the PID was not signaled.');continue;} + for (const {path, owner: current} of targets) { + if (!(await this.verify(current))) { + if (!isAlive(current.pid)) rmSync(path, {force: true}); + markIfCurrent(this.store, current, 'Guard ownership challenge failed; the PID was not signaled.'); + continue; + } if(!(await this.requestStop(current))){markIfCurrent(this.store,current,'Guard rejected its authenticated stop request; the PID was not signaled.');continue;} for(let attempt=0;attempt<20&&isAlive(current.pid);attempt+=1)await delay(50); if(isAlive(current.pid)&&await this.verify(current))await terminateGuardTree(current.pid); @@ -100,17 +106,28 @@ export class AlarmGuardService { this.store.markReleased(current.alarmId, new Date(), current.occurrenceAt); } } - return targets.every(item => !isAlive(item.pid)); + return !this.hasOwnershipRecords(alarmId); } async status() { const guards=[] as Array<{active:true;alarmId:string;occurrenceAt:string;pid:number}>; + const unresolvedGuards=[] as Array<{alarmId?:string;occurrenceAt?:string;pid?:number;message:string}>; for(const path of this.pidFiles()){ const pid = readPid(path); - if (!pid) continue; + if (!pid) { + const alarmId=alarmIdFromPath(path); + unresolvedGuards.push({...(alarmId?{alarmId}:{}),message:'Guard ownership metadata cannot be read; its record was retained for repair.'}); + continue; + } if (!(await this.verify(pid))) { - rmSync(path, {force: true}); - markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.'); + if (isAlive(pid.pid)) { + const message='Guard ownership cannot be verified; its record was retained and the PID was not signaled.'; + unresolvedGuards.push({alarmId:pid.alarmId,occurrenceAt:pid.occurrenceAt,pid:pid.pid,message}); + markIfCurrent(this.store,pid,message); + } else { + rmSync(path, {force: true}); + markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.'); + } continue; } guards.push({active:true,alarmId:pid.alarmId,occurrenceAt:pid.occurrenceAt,pid:pid.pid}); @@ -118,7 +135,10 @@ export class AlarmGuardService { return { active: guards.length > 0, guards, - message: guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.' + unresolvedGuards, + message: unresolvedGuards.length + ? `${guards.length} alarm guards verified active; ${unresolvedGuards.length} ownership records require repair.` + : guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.' }; } @@ -130,6 +150,13 @@ export class AlarmGuardService { if (!existsSync(this.directory)) return []; return readdirSync(this.directory).filter(name => name.endsWith('.json')).map(name => join(this.directory, name)); } + private hasOwnershipRecords(alarmId?:string):boolean { + return this.pidFiles().some(path=>{ + const ownerId=readPid(path)?.alarmId??alarmIdFromPath(path); + // Unknown records require global repair, but cannot identify this alarm. + return !alarmId||ownerId===alarmId; + }); + } private async withOwnership(alarmId:string,work:()=>Promise):Promise{const lock=join(this.directory,`.lock-${Buffer.from(alarmId).toString('base64url')}`);mkdirSync(this.directory,{recursive:true,mode:0o700});const deadline=Date.now()+3000;while(true){try{mkdirSync(lock,{mode:0o700});writeFileSync(join(lock,'created'),String(Date.now()),{mode:0o600});break;}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;let stale=false;try{const created=Number(readFileSync(join(lock,'created'),'utf8'));stale=Number.isFinite(created)?Date.now()-created>10_000:Date.now()-statSync(lock).mtimeMs>10_000;}catch{try{stale=Date.now()-statSync(lock).mtimeMs>10_000;}catch{}}if(stale){rmSync(lock,{recursive:true,force:true});continue;}if(Date.now()>deadline)throw new Error('Alarm Guard ownership is busy.');await delay(20);}}try{return await work();}finally{rmSync(lock,{recursive:true,force:true});}} } @@ -200,41 +227,56 @@ function readPid(path: string): GuardPid | undefined { if (!existsSync(path)) return undefined; try { const value = JSON.parse(readFileSync(path, 'utf8')) as GuardPid; - if (!Number.isInteger(value.pid) || !Number.isInteger(value.port)||value.port<1||value.port>65535||!value.alarmId || !/^[a-f0-9]{64}$/.test(value.token)||!/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt))) throw new Error(); + if (!Number.isInteger(value.pid) || value.pid<1 || !Number.isInteger(value.port)||value.port<1||value.port>65535||typeof value.alarmId!=='string'||!value.alarmId || (value.host!==undefined&&!isLoopbackHost(value.host)) || !/^[a-f0-9]{64}$/.test(value.token)||!/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt))) throw new Error(); return value; } catch { return undefined; } } +function alarmIdFromPath(path:string):string|undefined { + const name=basename(path,'.json');const decoded=Buffer.from(name,'base64url').toString(); + if(Buffer.from(decoded).toString('base64url')!==name)return undefined; + const separator=decoded.indexOf('\0'); + return separator>0?decoded.slice(0,separator):undefined; +} function writePrivate(path: string, value: GuardPid): void { mkdirSync(dirname(path), {recursive: true, mode: 0o700}); const temp=`${path}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`; writeFileSync(temp, JSON.stringify(value), {mode: 0o600}); renameSync(temp,path); - if (process.platform !== 'win32') chmodSync(path, 0o600); + if (nativeAdapters().posixPermissions) chmodSync(path, 0o600); } function isAlive(pid: number): boolean { - try { process.kill(pid, 0); return true; } catch { return false; } + try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; } } function ownsArtifact(path: string, token: string): boolean { return readPid(path)?.token === token; } -async function startGuardControl(path:string,token:string,alarmId:string,occurrenceAt:string,onStop:()=>void){const server=createServer((req,res)=>{if(req.headers.authorization!==`Bearer ${token}`){res.statusCode=401;res.end('{}');return;}if(req.method==='POST'&&req.url==='/stop'){onStop();res.end('{}');return;}if(req.method!=='GET'||req.url!=='/challenge'){res.statusCode=404;res.end('{}');return;}res.setHeader('content-type','application/json');res.end(JSON.stringify({alarmId,occurrenceAt,pid:process.pid}));});await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});const address=server.address();if(!address||typeof address==='string'){server.close();throw new Error('Unable to create Guard ownership endpoint.');}writePrivate(path,{alarmId,occurrenceAt,pid:process.pid,port:address.port,token});return{close:()=>new Promise(resolve=>server.close(()=>resolve()))};} +async function startGuardControl(path:string,token:string,alarmId:string,occurrenceAt:string,onStop:()=>void){ + const server=createServer((req,res)=>{if(req.headers.authorization!==`Bearer ${token}`){res.statusCode=401;res.end('{}');return;}if(req.method==='POST'&&req.url==='/stop'){onStop();res.end('{}');return;}if(req.method!=='GET'||req.url!=='/challenge'){res.statusCode=404;res.end('{}');return;}res.setHeader('content-type','application/json');res.end(JSON.stringify({alarmId,occurrenceAt,pid:process.pid}));}); + const close=()=>new Promise(resolve=>server.close(()=>resolve())); + try { + const {host,port}=await listenLoopback(server); + writePrivate(path,{alarmId,occurrenceAt,pid:process.pid,host,port,token}); + return {close}; + } catch(error) { await close();throw error; } +} function challengeGuard(guard:GuardPid):Promise{return callGuard(guard,'GET','/challenge',true);} function requestGuardStop(guard:GuardPid):Promise{return callGuard(guard,'POST','/stop',false);} -function callGuard(guard:GuardPid,method:string,path:string,validateIdentity:boolean):Promise{return new Promise(resolve=>{const req=request({host:'127.0.0.1',port:guard.port,path,method,headers:{authorization:`Bearer ${guard.token}`}},res=>{let body='';res.on('data',value=>body+=String(value));res.on('end',()=>{if(!validateIdentity){resolve(res.statusCode===200);return;}try{const reply=JSON.parse(body) as {alarmId?:string;occurrenceAt?:string;pid?:number};resolve(res.statusCode===200&&reply.alarmId===guard.alarmId&&reply.occurrenceAt===guard.occurrenceAt&&reply.pid===guard.pid);}catch{resolve(false);}});});req.once('error',()=>resolve(false));req.setTimeout(500,()=>req.destroy());req.end();});} +function callGuard(guard:GuardPid,method:string,path:string,validateIdentity:boolean):Promise{return new Promise(resolve=>{const req=request({host:guard.host??'127.0.0.1',port:guard.port,path,method,agent:false,headers:{authorization:`Bearer ${guard.token}`}},res=>{let body='';res.on('data',value=>body+=String(value));res.on('end',()=>{if(!validateIdentity){resolve(res.statusCode===200);return;}try{const reply=JSON.parse(body) as {alarmId?:string;occurrenceAt?:string;pid?:number};resolve(res.statusCode===200&&reply.alarmId===guard.alarmId&&reply.occurrenceAt===guard.occurrenceAt&&reply.pid===guard.pid);}catch{resolve(false);}});});req.once('error',()=>resolve(false));req.setTimeout(500,()=>req.destroy());req.end();});} export type GuardProcessOps={platform:NodeJS.Platform;isAlive(pid:number):boolean;kill(pid:number,signal:NodeJS.Signals):void;taskkill(pid:number,force:boolean):Promise;wait(milliseconds:number):Promise}; export async function terminateGuardTree(pid: number,ops:GuardProcessOps={platform:process.platform,isAlive,kill:(target,signal)=>process.kill(target,signal),taskkill:taskkillTree,wait:delay}): Promise { + const windows=nativeAdapters(identifyPlatform({platform:ops.platform})).ipc==='named-pipe'; // Kill the entire detached tree first on Windows so a dying parent cannot // orphan its PowerShell execution-state helper. - if (ops.platform === 'win32') { + if (windows) { await ops.taskkill(pid, false); } else { try { ops.kill(pid, 'SIGTERM'); } catch { return; } } for (let index = 0; index < 20 && ops.isAlive(pid); index += 1) await ops.wait(50); if (!ops.isAlive(pid)) return; - if (ops.platform === 'win32') await ops.taskkill(pid, true); + if (windows) await ops.taskkill(pid, true); else { // The guard is a detached process-group leader; force-killing the negative // PID guarantees its inhibitor helper cannot survive an unresponsive parent. diff --git a/src/alarms/inhibitor.test.ts b/src/alarms/inhibitor.test.ts index ab06f79..70a7308 100644 --- a/src/alarms/inhibitor.test.ts +++ b/src/alarms/inhibitor.test.ts @@ -1,4 +1,6 @@ import {describe, expect, it, vi} from 'vitest'; +import {mkdtempSync,rmSync,writeFileSync} from 'node:fs'; +import {basename,join,posix} from 'node:path'; import {createPowerInhibitor} from './inhibitor.js'; describe('power inhibitor', () => { @@ -16,5 +18,21 @@ describe('power inhibitor', () => { expect(kill).toHaveBeenCalled(); }); - it('reports a helper that exits unexpectedly after startup',async()=>{let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const inhibitor=createPowerInhibitor({platform:'darwin',spawn:()=>({pid:1,kill:()=>true,exited}),commandExists:()=>true});const lease=await inhibitor.acquire('Alarm');exit();await expect(lease.unexpectedExit).resolves.toMatchObject({message:expect.stringMatching(/unexpectedly/)});await lease.release();}); + it('reports lost protection when the helper exits unexpectedly after startup',async()=>{let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const inhibitor=createPowerInhibitor({platform:'darwin',spawn:()=>({pid:1,kill:()=>true,exited}),commandExists:()=>true});const lease=await inhibitor.acquire('Alarm');expect(inhibitor.status().active).toBe(true);exit();await expect(lease.unexpectedExit).resolves.toMatchObject({message:expect.stringMatching(/unexpectedly/)});expect(inhibitor.status().active).toBe(false);await lease.release();}); + + it('explains a missing helper without claiming that sleep is prevented',async()=>{const spawn=vi.fn();const inhibitor=createPowerInhibitor({platform:'darwin',spawn,commandExists:()=>false});expect(inhibitor.status()).toMatchObject({supported:false,active:false,message:expect.stringMatching(/caffeinate.*(?:missing|unavailable|not found)/i)});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/caffeinate.*(?:missing|unavailable|not found)/i);expect(spawn).not.toHaveBeenCalled();}); + + it('does not treat a Termux process reporting Linux as a logind host',async()=>{const spawn=vi.fn();const exists=vi.fn(()=>true);const inhibitor=createPowerInhibitor({platform:'linux',env:{TERMUX_VERSION:'0.118.3'},spawn,commandExists:exists});expect(inhibitor.status()).toMatchObject({supported:false,active:false,message:expect.stringContaining('termux')});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/unsupported.*termux/i);expect(exists).not.toHaveBeenCalled();expect(spawn).not.toHaveBeenCalled();}); + + it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('reports unavailable sleep protection on %s without spawning a surrogate',async platform=>{const spawn=vi.fn();const inhibitor=createPowerInhibitor({platform,spawn,commandExists:()=>true});expect(inhibitor.status()).toMatchObject({supported:false,active:false});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/unsupported/i);expect(spawn).not.toHaveBeenCalled();}); + + it('spawns the executable resolved from the supplied PATH',async()=>{ + // Keep the injected POSIX PATH free of Windows drive-letter delimiters. + const root=mkdtempSync(join(process.cwd(),'.radiocli-inhibitor-path-')); + try{ + writeFileSync(join(root,'systemd-inhibit'),'#!/bin/sh\nexit 0\n',{mode:0o700});let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const spawn=vi.fn(()=>({pid:42,kill:()=>{exit();return true;},exited})); + const pathEntry=basename(root);const inhibitor=createPowerInhibitor({platform:'linux',env:{PATH:pathEntry},spawn});const lease=await inhibitor.acquire('Alarm'); + expect(spawn).toHaveBeenCalledWith(posix.join(pathEntry,'systemd-inhibit'),expect.any(Array));await lease.release(); + }finally{rmSync(root,{recursive:true,force:true});} + }); }); diff --git a/src/alarms/inhibitor.ts b/src/alarms/inhibitor.ts index e857bf8..23578a9 100644 --- a/src/alarms/inhibitor.ts +++ b/src/alarms/inhibitor.ts @@ -1,6 +1,6 @@ import {spawn as nodeSpawn} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {delimiter, join} from 'node:path'; +import {resolveCommandDetails} from '../platform/executables.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; type InhibitorStatus = {supported: boolean; active: boolean; message: string}; export type InhibitorLease = {release(): Promise;unexpectedExit?:Promise}; @@ -11,6 +11,7 @@ export type PowerInhibitor = { }; export type InhibitorDeps = { platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; spawn?: (command:string,args:string[])=>Spawned; commandExists?: (command:string)=>boolean; }; @@ -21,34 +22,34 @@ const windowsInhibitorScript = (parentPid:number) => `$s='[DllImport("kernel32.d export function createPowerInhibitor(deps: InhibitorDeps = {}): PowerInhibitor { const platform = deps.platform ?? process.platform; - const spawn = deps.spawn ?? spawnDetached; - const exists = deps.commandExists ?? executableExists; - const spec = platform === 'darwin' + const env=deps.env??process.env; + const host=identifyPlatform({platform,env}); + const adapter=nativeAdapters(host).inhibitor; + const spawn = deps.spawn ?? ((command,args)=>spawnDetached(command,args,env)); + const spec = adapter === 'caffeinate' ? {command:'caffeinate',args:['-i','-w',String(process.pid)],message:'Prevents idle system sleep; the display may sleep.'} - : platform === 'linux' + : adapter === 'logind' ? {command:'systemd-inhibit',args:['--what=sleep','--who=RadioCLI','--why=Scheduled radio','--mode=block','sh','-c','while kill -0 "$1" 2>/dev/null; do sleep 2; done','radiocli-inhibitor',String(process.pid)],message:'Uses a logind sleep inhibitor; explicit sleep and lid policy may override it.'} - : platform === 'win32' + : adapter === 'windows' ? {command:'powershell.exe',args:['-NoLogo','-NoProfile','-NonInteractive','-Command',windowsInhibitorScript(process.pid)],message:'Prevents idle system sleep; explicit sleep and lid policy may override it.'} : null; let active = false; - const supported = Boolean(spec && exists(spec.command)); + const command=spec?(deps.commandExists?(deps.commandExists(spec.command)?spec.command:null):resolveCommandDetails(spec.command,{platform,env}).path):null; + const supported = Boolean(command); + const message=!spec?`Power inhibition is unsupported on ${host.id==='unknown'?platform:host.id}.`:!command?`${spec.command} is unavailable; sleep protection cannot be acquired.`:spec.message; return { - status:()=>({supported,active,message:spec?.message ?? `Power inhibition is unsupported on ${platform}.`}), + status:()=>({supported,active,message}), async acquire(_reason){ - if (!spec || !supported) throw new Error(spec?.message ?? `Power inhibition is unsupported on ${platform}.`); - const child=spawn(spec.command,spec.args);const exitedEarly=await Promise.race([child.exited.then(()=>true),new Promise(resolve=>setTimeout(()=>resolve(false),50))]);if(exitedEarly)throw new Error(`${spec.command} exited before sleep inhibition became active.`);active=true; let released=false; - const unexpectedExit=child.exited.then(()=>released?new Promise(()=>{}):new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`)); + if (!spec || !command) throw new Error(message); + const child=spawn(command,spec.args);const exitedEarly=await exitedWithin(child.exited,50);if(exitedEarly)throw new Error(`${spec.command} exited before sleep inhibition became active.`);active=true; let released=false; + const unexpectedExit=child.exited.then(()=>{active=false;return released?new Promise(()=>{}):new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`);}); return {unexpectedExit,async release(){if(released)return;released=true;active=false;child.kill('SIGTERM');if(await exitedWithin(child.exited,1000))return;child.kill('SIGKILL');if(!(await exitedWithin(child.exited,500)))throw new Error(`${spec.command} did not terminate; sleep protection state is unknown.`);}}; } }; } -function executableExists(command:string):boolean { - if (existsSync(command)) return true; - return Boolean(process.env.PATH?.split(delimiter).some(path=>existsSync(join(path,command)) || (process.platform==='win32'&&existsSync(join(path,`${command}.exe`))))); -} -function spawnDetached(command:string,args:string[]):Spawned { - const child=nodeSpawn(command,args,{stdio:'ignore',windowsHide:true}); +function spawnDetached(command:string,args:string[],env:NodeJS.ProcessEnv):Spawned { + const child=nodeSpawn(command,args,{env,stdio:'ignore',windowsHide:true}); return {pid:child.pid,kill:signal=>child.kill(signal),exited:new Promise(resolve=>{child.once('exit',resolve);child.once('error',resolve);})}; } -async function exitedWithin(exited:Promise,milliseconds:number){return Promise.race([exited.then(()=>true),new Promise(resolve=>setTimeout(()=>resolve(false),milliseconds))]);} +async function exitedWithin(exited:Promise,milliseconds:number){let timer:NodeJS.Timeout|undefined;try{return await Promise.race([exited.then(()=>true),new Promise(resolve=>{timer=setTimeout(()=>resolve(false),milliseconds);})]);}finally{if(timer)clearTimeout(timer);}} diff --git a/src/alarms/power-guard-store.ts b/src/alarms/power-guard-store.ts index a1bd2fd..bb69023 100644 --- a/src/alarms/power-guard-store.ts +++ b/src/alarms/power-guard-store.ts @@ -8,7 +8,7 @@ import { statSync, writeFileSync } from 'node:fs'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import {z} from 'zod'; import type {AlarmPowerGuardState} from '../types.js'; @@ -149,14 +149,7 @@ export class AlarmPowerGuardStore { } function defaultAlarmPowerGuardPath(): string { - if (process.env.RADIOCLI_HOME) return join(process.env.RADIOCLI_HOME, 'runtime', 'alarm-power-guards.json'); - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radiocli', 'runtime', 'alarm-power-guards.json'); - } - if (process.platform === 'win32') { - return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime', 'alarm-power-guards.json'); - } - return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli', 'alarm-power-guards.json'); + return join(platformPaths().alarmRuntime, 'alarm-power-guards.json'); } function validDate(date: Date, label: string): Date { diff --git a/src/alarms/runner.test.ts b/src/alarms/runner.test.ts index 51e5c08..53cc257 100644 --- a/src/alarms/runner.test.ts +++ b/src/alarms/runner.test.ts @@ -37,6 +37,59 @@ describe('alarm runner',()=>{ it('keeps playing when automatic terminal controls cannot open',async()=>{const record=vi.fn();const d=deps({openControls:vi.fn(async()=>{throw new Error('no terminal');}),health:{record} as never});expect((await runAlarm('a',scheduledAt,d)).status).toBe('dismissed');await Promise.resolve();expect(record).toHaveBeenCalledWith(expect.objectContaining({component:'runner',healthy:false,message:expect.stringMatching(/controls could not open.*no terminal/i)}));}); + it.each([ + 'Requested RadioCLI alarm controls; TUI startup is unverified.', + 'Requested RadioCLI alarm controls, but the TUI did not become ready before verification timed out.' + ])('preserves an unsuccessful controls result while playback continues: %s',async message=>{ + const record=vi.fn();const update=vi.fn(); + const d=deps({createSession:vi.fn(async()=>({update,close:vi.fn(async()=>{})})),openControls:vi.fn(async()=>({opened:false,requested:true,terminal:'darwin:apple-terminal',message})),health:{record} as never}); + expect((await runAlarm('a',scheduledAt,d)).status).toBe('played'); + expect(update).toHaveBeenCalledWith(expect.objectContaining({state:'playing'}));expect(d.wait).toHaveBeenCalledWith(60_000); + expect(record).toHaveBeenCalledWith(expect.objectContaining({alarmId:'a',occurrenceAt:'2030-01-01T08:00:00.000Z',component:'runner',healthy:false,message:expect.stringContaining(message)})); + }); + + it.each([ + {opened:false,terminal:'existing-tui',message:'An existing RadioCLI TUI will show the ringing controls.'}, + {opened:true,requested:true,terminal:'darwin:apple-terminal',message:'RadioCLI alarm controls are ready in the saved terminal.'} + ])('keeps confirmed or existing controls healthy: $terminal',async result=>{ + const record=vi.fn();const d=deps({openControls:vi.fn(async()=>result),health:{record} as never}); + expect((await runAlarm('a',scheduledAt,d)).status).toBe('dismissed'); + expect(record).not.toHaveBeenCalledWith(expect.objectContaining({component:'runner',healthy:false})); + }); + + it.each(['addRecent','startListeningSession','checkpointActiveListeningSession','finishActiveListeningSession'] as const)('continues published playback after optional %s history writes fail',async operation=>{ + const base=deps();const events:string[]=[];const record=vi.fn(); + const d=deps({ + store:{...base.store,[operation]:vi.fn(()=>{throw new Error(`${operation}: EACCES`);})}, + player:{...base.player,play:vi.fn(async()=>{events.push('play');}),stop:vi.fn(async()=>{events.push('stop');})}, + createSession:vi.fn(async()=>({update:vi.fn(status=>{if(status.state==='playing')events.push('published');}),close:vi.fn(async()=>{})})), + wait:vi.fn(async()=>{events.push('wait');}),health:{record} as never + }); + expect((await runAlarm('a',scheduledAt,d)).status).toBe('played'); + expect(events).toEqual(['play','published','wait','stop']); + expect(record).toHaveBeenCalledWith(expect.objectContaining({alarmId:'a',occurrenceAt:'2030-01-01T08:00:00.000Z',component:'runner',healthy:false,message:expect.stringContaining(`${operation}: EACCES`)})); + expect(d.store.recordAlarmOutcome).toHaveBeenCalledWith('a',expect.objectContaining({status:'played'}),{clearNextOverride:true}); + if(operation==='addRecent')expect(d.store.startListeningSession).toHaveBeenCalled(); + if(operation==='startListeningSession'){expect(d.store.checkpointActiveListeningSession).not.toHaveBeenCalled();expect(d.store.finishActiveListeningSession).not.toHaveBeenCalled();} + if(operation==='checkpointActiveListeningSession')expect(d.store.finishActiveListeningSession).toHaveBeenCalled(); + }); + + it.each(['recordAlarmOutcome','toggleAlarm'] as const)('reports required %s persistence failures separately from optional history',async operation=>{ + const base=deps();const record=vi.fn();const d=deps({store:{...base.store,[operation]:vi.fn(()=>{throw new Error(`${operation}: EACCES`);})},health:{record} as never}); + await runAlarm('a',scheduledAt,d); + expect(record).toHaveBeenCalledWith(expect.objectContaining({component:'runner',healthy:false,active:false,message:expect.stringMatching(new RegExp(`alarm.*sav.*${operation}: EACCES`,'i'))})); + if(operation==='recordAlarmOutcome')expect(d.store.toggleAlarm).not.toHaveBeenCalled(); + }); + + it('retains an unverified controls warning when optional history finalization also fails',async()=>{ + const base=deps();const record=vi.fn();const message='Requested controls; TUI startup is unverified.'; + const d=deps({store:{...base.store,finishActiveListeningSession:vi.fn(()=>{throw new Error('history EACCES');})},openControls:vi.fn(async()=>({opened:false,requested:true,terminal:'darwin:apple-terminal',message})),health:{record} as never}); + await runAlarm('a',scheduledAt,d); + const lastRunnerEntry=record.mock.calls.map(([entry])=>entry).filter(entry=>entry.component==='runner').at(-1); + expect(lastRunnerEntry).toMatchObject({healthy:false,message:expect.stringContaining(message)}); + expect(lastRunnerEntry.message).toContain('history EACCES'); + }); + it('retries primary once then uses only the explicit fallback',async()=>{ const play=vi.fn().mockRejectedValueOnce(new Error('bad')).mockRejectedValueOnce(new Error('bad')).mockResolvedValue(undefined); const d=deps({player:{play,stop:vi.fn(async()=>{}),setVolume:vi.fn(async()=>({ok:true})),getState:vi.fn(()=>({backend:'mpv'}))}}); @@ -76,6 +129,25 @@ describe('alarm runner',()=>{ it('does not disable a valid one-time alarm when launched early',async()=>{const d=deps({now:()=>new Date('2030-01-01T07:59:00Z')});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('failed');expect(d.store.toggleAlarm).not.toHaveBeenCalled();}); + it('does not complete a future native occurrence after an early invocation',async()=>{const completeOccurrence=vi.fn(async()=>{});const d=deps({now:()=>new Date('2030-01-01T07:59:00Z'),scheduler:{sync:vi.fn(async()=>null),completeOccurrence}});await runAlarm('a',scheduledAt,d);expect(completeOccurrence).not.toHaveBeenCalled();}); + + it.each([['early','2030-01-01T08:10:00Z'],['stale','2030-01-01T08:00:00Z']] as const)('preserves a future snooze override after an ignored %s invocation',async(_kind,invocation)=>{const snoozed={...alarm,nextOverride:{at:'2030-01-01T08:10:00Z',reason:'snooze' as const,createdAt:'2030-01-01T08:00:00Z'}};const d=deps({now:()=>new Date('2030-01-01T08:05:00Z'),store:{...deps().store,getAlarm:vi.fn(()=>snoozed)}});await runAlarm('a',invocation,d);expect(d.store.recordAlarmOutcome).toHaveBeenCalledWith('a',expect.any(Object),{clearNextOverride:false});expect(d.player.play).not.toHaveBeenCalled();}); + + it('allows the real occurrence to run after an early native dispatch', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-early-occurrence-')); + try { + let current = new Date('2030-01-01T07:59:00Z'); + const d = deps({now: () => current, acquireLock: (id, at) => acquireOccurrenceLock(id, at, root)}); + expect((await runAlarm('a', scheduledAt, d)).status).toBe('failed'); + expect(d.player.play).not.toHaveBeenCalled(); + current = new Date('2030-01-01T08:00:00Z'); + expect((await runAlarm('a', scheduledAt, d)).duplicate).not.toBe(true); + expect(d.player.play).toHaveBeenCalledOnce(); + expect((await runAlarm('a', scheduledAt, d)).duplicate).toBe(true); + expect(d.player.play).toHaveBeenCalledOnce(); + } finally { rmSync(root, {recursive: true, force: true}); } + }); + it('records a valid but overdue occurrence as missed and disables one-time alarms',async()=>{const d=deps({now:()=>new Date('2030-01-01T08:20:00Z')});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('missed');expect(d.store.toggleAlarm).toHaveBeenCalledWith('a',false);}); it('records normal stop-after completion as played',async()=>{const d=deps({createSession:vi.fn(async()=>({update:vi.fn(),close:vi.fn(async()=>{})}))});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('played');expect(d.store.toggleAlarm).toHaveBeenCalledWith('a',false);}); @@ -94,7 +166,31 @@ describe('alarm runner',()=>{ it('reconciles the next daily occurrence before keep-playing can run indefinitely',async()=>{const recurring:Alarm={...alarm,schedule:{type:'recurring',time:'08:00',weekdays:[1,2,3,4,5,6,7],timezone:'UTC'}};let signal:()=>void=()=>{};const sync=vi.fn(async()=>null);const d=deps({store:{...deps().store,getAlarm:vi.fn(()=>recurring)},scheduler:{sync},createSession:vi.fn(async(_status,handlers)=>{handlers.onKeepPlaying();return{update:vi.fn(),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>new Promise(()=>{})),subscribeSignals:handler=>{signal=handler;return vi.fn();}});const running=runAlarm('a',scheduledAt,d);for(let attempt=0;attempt<10&&sync.mock.calls.length===0;attempt+=1)await Promise.resolve();expect(sync).toHaveBeenCalledWith(recurring);signal();await running;}); - it('hands playback to the interactive player without restoring the alarm output level',async()=>{const release=vi.fn(async()=>{});const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(status,handlers)=>{expect(status.station).toEqual(station);return{update:vi.fn(change=>{if(change.state==='playing')queueMicrotask(()=>handlers.onHandoff?.());}),close:vi.fn(async()=>{})};})});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('played');expect(result.message).toContain('Handed off');expect(release).not.toHaveBeenCalled();expect(d.player.stop).toHaveBeenCalled();}); + it('hands playback to the interactive player and releases shared ownership while preserving output',async()=>{const release=vi.fn(async()=>{});const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(status,handlers)=>{expect(status.station).toEqual(station);return{update:vi.fn(change=>{if(change.state==='playing')queueMicrotask(()=>handlers.onHandoff?.());}),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>new Promise(()=>{}))});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('played');expect(result.message).toContain('Handed off');expect(release).toHaveBeenCalledExactlyOnceWith({preserve:true});expect(d.player.stop).toHaveBeenCalled();}); + + it('does not accept a handoff if preserving shared output ownership fails',async()=>{ + let handlers:Parameters[1]|undefined; + let playing:()=>void=()=>{};const started=new Promise(resolve=>{playing=resolve;}); + const release=vi.fn().mockRejectedValueOnce(new Error('ownership disk full')).mockResolvedValue(undefined); + const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(_status,value)=>{handlers=value;return{update:vi.fn(change=>{if(change.state==='playing')playing();}),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>new Promise(()=>{}))}); + const running=runAlarm('a',scheduledAt,d);await started; + await expect(Promise.resolve().then(()=>handlers!.onHandoff!())).rejects.toThrow('ownership disk full'); + await handlers!.onDismiss();expect((await running).status).toBe('dismissed'); + expect(release).toHaveBeenNthCalledWith(1,{preserve:true});expect(release).toHaveBeenNthCalledWith(2); + }); + + it('waits for an in-progress handoff before timeout cleanup can restore shared output',async()=>{ + let handlers:Parameters[1]|undefined; + let playing:()=>void=()=>{};const started=new Promise(resolve=>{playing=resolve;}); + let preserved:()=>void=()=>{};const preserving=new Promise(resolve=>{preserved=resolve;}); + let timedOut:()=>void=()=>{};const timeout=new Promise(resolve=>{timedOut=resolve;}); + const release=vi.fn(async(options?:{preserve?:boolean})=>{if(options?.preserve)await preserving;}); + const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(_status,value)=>{handlers=value;return{update:vi.fn(change=>{if(change.state==='playing')playing();}),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>timeout)}); + const running=runAlarm('a',scheduledAt,d);await started;const handoff=handlers!.onHandoff!();timedOut(); + try{for(let turn=0;turn<20;turn+=1)await Promise.resolve();expect(release).toHaveBeenCalledExactlyOnceWith({preserve:true});} + finally{preserved();await handoff;await running;} + expect(release).toHaveBeenCalledExactlyOnceWith({preserve:true}); + }); it('claims the alarm before resolving and preempts interactive playback before raising output or tuning',async()=>{const events:string[]=[];const base=deps();const d=deps({providers:{resolve:vi.fn(async()=>{events.push('resolve');return{url:'https://main'};})},preemptInteractivePlayback:vi.fn(async()=>{events.push('preempt');}),systemVolume:{acquireMinimum:vi.fn(async()=>{events.push('system-volume');return{message:'raised',release:vi.fn(async()=>{})};})},player:{...base.player,play:vi.fn(async()=>{events.push('play');})},createSession:vi.fn(async(status,handlers)=>{events.push(`claim-${status.state}`);return{update:vi.fn(change=>{events.push(`update-${change.state}`);queueMicrotask(()=>handlers.onDismiss());}),close:vi.fn(async()=>{})};})});await runAlarm('a',scheduledAt,d);expect(events).toEqual(['claim-starting','resolve','preempt','system-volume','play','update-playing']);}); diff --git a/src/alarms/runner.ts b/src/alarms/runner.ts index c268510..247f405 100644 --- a/src/alarms/runner.ts +++ b/src/alarms/runner.ts @@ -1,5 +1,5 @@ import {existsSync,mkdirSync,readFileSync,readdirSync,rmSync,statSync,writeFileSync} from 'node:fs'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import type {Alarm,AlarmRunRecord,PlaybackState,ResolvedStream,Station} from '../types.js'; import type {PlaybackControlResult} from '../player/player-controller.js'; @@ -9,6 +9,7 @@ import {assessScheduledOccurrence,NATIVE_DISPATCH_TOLERANCE_MS,nextOccurrenceFor import {startActiveAlarmSession,type ActiveAlarmHandlers,type ActiveAlarmServer,type ActiveAlarmStatus} from './active-session.js'; import type {AlarmRuntimeHealthStore} from './runtime-health.js'; import type {SystemVolumeController,SystemVolumeLease} from './system-volume.js'; +import type {AlarmTerminalLaunchResult} from './terminal-launcher.js'; type RunnerStore={ getAlarm(id:string):Alarm|undefined; @@ -25,9 +26,9 @@ type RunnerProvider={resolve(station:Station):Promise}; type RunnerScheduler=Pick & Partial>; export type AlarmRunnerDeps={ now():Date; store:RunnerStore; providers:RunnerProvider; player:RunnerPlayer; scheduler:RunnerScheduler; inhibitor:PowerInhibitor; - acquireLock(alarmId:string,scheduledAt:string):(()=>void)|null; + acquireLock(alarmId:string,scheduledAt:string):((completed?:boolean)=>void)|null; createSession(status:ActiveAlarmStatus,handlers:ActiveAlarmHandlers):Promise; - openControls?(status:ActiveAlarmStatus):Promise; + openControls?(status:ActiveAlarmStatus):Promise; preemptInteractivePlayback?():Promise; wait(milliseconds:number):Promise; subscribeSignals?(handler:()=>void):()=>void; @@ -40,10 +41,14 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(!/(?:Z|[+-]\d{2}:\d{2})$/.test(scheduledAtText))throw new Error('Scheduled alarm occurrence must be an absolute date.'); const scheduledAt=new Date(scheduledAtText);if(!Number.isFinite(scheduledAt.getTime()))throw new Error('Scheduled alarm occurrence must be an absolute date.'); const releaseLock=deps.acquireLock(alarmId,scheduledAt.toISOString());if(!releaseLock){const current=deps.store.getAlarm(alarmId);if(current)try{await deps.scheduler.sync(current);}catch{}return{duplicate:true,message:'This alarm occurrence is already running or completed.'};} - let alarm=deps.store.getAlarm(alarmId);let outcome:AlarmRunRecord|undefined;let session:ActiveAlarmServer|undefined;let lease:Awaited>|undefined;let systemVolumeLease:SystemVolumeLease|undefined;let listening=false;let firedAt:Date|undefined; + let alarm=deps.store.getAlarm(alarmId);let outcome:AlarmRunRecord|undefined;let session:ActiveAlarmServer|undefined;let lease:Awaited>|undefined;let systemVolumeLease:SystemVolumeLease|undefined;let listening=false;let historyStarted=false;let firedAt:Date|undefined; + const runnerWarnings=new Set(); + const recordRunnerWarning=(message:string)=>{runnerWarnings.add(message);try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:listening,message:[...runnerWarnings].join(' ')});}catch{}}; let preserveNextOverride=false; let preserveSystemVolume=false; + let handoffOutput:Promise|undefined; let validTerminalOccurrence=false; + let completeLock=true; let signalReceived=false;let resolveEarlySignal:()=>void=()=>{};let onPlaybackSignal:()=>void=()=>{};const earlySignal=new Promise(resolve=>{resolveEarlySignal=resolve;});const unsubscribeSignals=deps.subscribeSignals?.(()=>{signalReceived=true;resolveEarlySignal();onPlaybackSignal();void deps.player.stop().catch(()=>undefined);}); const finish=(status:AlarmRunRecord['status'],message?:string):AlarmRunRecord=>({status,scheduledAt:scheduledAt.toISOString(),...(firedAt?{firedAt:firedAt.toISOString()}:{}),finishedAt:deps.now().toISOString(),...(message?{message}:{})}); let action:'dismissed'|'snoozed'|'handoff'|'timeout'|'signal'|undefined;let keepPlaying=false;let resolveAction:(value:'dismissed'|'snoozed'|'handoff'|'signal')=>void=()=>{}; @@ -52,7 +57,7 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(!alarm||!alarm.enabled)return{message:'Alarm is missing or disabled.'}; const expected=nextExpectedOccurrence(alarm,scheduledAt);if(!expected||Math.abs(expected.getTime()-scheduledAt.getTime())>1000){outcome=finish('missed','Ignored a stale native task whose occurrence no longer matches this alarm.');return{status:'missed',message:outcome.message};} const assessment=assessScheduledOccurrence(scheduledAt,deps.now(),alarm.reliability.missedRunGraceMinutes); - if(assessment==='pending'){outcome=finish('failed','Scheduler launched the alarm before its occurrence.');return{status:outcome.status,message:outcome.message};} + if(assessment==='pending'){completeLock=false;outcome=finish('failed','Scheduler launched the alarm before its occurrence.');return{status:outcome.status,message:outcome.message};} validTerminalOccurrence=true; if(assessment==='missed'){outcome=finish('missed','Alarm was outside its missed-run grace window.');return{status:outcome.status,message:outcome.message};} const claimingStatus:ActiveAlarmStatus={alarmId,scheduledAt:scheduledAt.toISOString(),stationName:alarm.station.name,station:alarm.station,startedAt:deps.now().toISOString(),state:'starting'}; @@ -60,7 +65,7 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR onDismiss:()=>{action='dismissed';resolveAction('dismissed');resolveEarlySignal();}, onSnooze:minutes=>{deps.store.snoozeAlarm(alarmId,new Date(deps.now().getTime()+minutes*60_000));preserveNextOverride=true;action='snoozed';resolveAction('snoozed');resolveEarlySignal();}, onKeepPlaying:()=>{keepPlaying=true;session?.update({keepPlaying:true});}, - onHandoff:()=>{if(!listening)throw new Error('Alarm playback is still starting.');preserveSystemVolume=true;action='handoff';resolveAction('handoff');} + onHandoff:async()=>{if(!listening)throw new Error('Alarm playback is still starting.');handoffOutput=(systemVolumeLease?.release({preserve:true})??Promise.resolve()).then(()=>{preserveSystemVolume=true;});await handoffOutput;action='handoff';resolveAction('handoff');} });void creatingSession.then(created=>{if(signalReceived)void created.close().catch(()=>undefined);}).catch(()=>{});const created=await Promise.race([creatingSession.then(value=>({value})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in created){outcome=finish('dismissed','Alarm interrupted while local controls were starting.');return{status:'dismissed',message:outcome.message};}session=created.value; const claimed=deps.store.getAlarm(alarmId);if(claimed?.enabled&&claimed.schedule.type==='recurring'){if(deps.scheduler.syncClaimed)await deps.scheduler.syncClaimed(claimed,scheduledAt);else await deps.scheduler.sync(claimed);} let resolvedStation=alarm.station;let lastError:unknown; @@ -73,10 +78,12 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(lastError)throw lastError; firedAt=deps.now(); try{const acquiring=deps.inhibitor.acquire('RadioCLI alarm playback');void acquiring.then(acquired=>{if(signalReceived)void acquired.release().catch(()=>undefined);}).catch(()=>{});const acquired=await Promise.race([acquiring.then(value=>({value})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in acquired){outcome=finish('dismissed','Alarm interrupted while sleep protection was starting.');return{status:'dismissed',message:outcome.message};}lease=acquired.value;deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:true,active:true,message:'Sleep inhibition active while playing.'});void lease.unexpectedExit?.then(error=>{try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:false,message:`Playback continues after sleep protection exited: ${error.message}`});}catch{}}).catch(()=>{});}catch(error){deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:false,message:`Playback continues without sleep protection: ${errorMessage(error)}`});} - const startedAt=deps.now();deps.store.addRecent(resolvedStation);deps.store.startListeningSession(resolvedStation,startedAt);listening=true; + const startedAt=deps.now();listening=true; + try{deps.store.addRecent(resolvedStation);}catch(error){recordRunnerWarning(`Recent listening history could not be saved: ${errorMessage(error)}`);} + try{deps.store.startListeningSession(resolvedStation,startedAt);historyStarted=true;}catch(error){recordRunnerWarning(`Listening history could not be started: ${errorMessage(error)}`);} const activeStatus:ActiveAlarmStatus={alarmId,scheduledAt:scheduledAt.toISOString(),stationName:resolvedStation.name,station:resolvedStation,startedAt:startedAt.toISOString(),state:'playing'}; session.update(activeStatus); - void deps.openControls?.(activeStatus).catch(error=>{try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:true,message:`Alarm is playing, but RadioCLI controls could not open automatically: ${errorMessage(error)}`});}catch{}}); + void deps.openControls?.(activeStatus).then(result=>{if(result&&!result.opened&&result.terminal!=='existing-tui')recordRunnerWarning(`RadioCLI controls are unavailable or unverified: ${result.message}`);}).catch(error=>{recordRunnerWarning(`RadioCLI controls could not open automatically: ${errorMessage(error)}`);}); onPlaybackSignal=()=>{action='signal';resolveAction('signal');};if(signalReceived)onPlaybackSignal(); { const supportsFade=deps.player.getState().backend==='mpv'; @@ -88,27 +95,32 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR return{status:outcome.status,message:outcome.message}; }catch(error){outcome=finish('failed',errorMessage(error));return{status:'failed',message:outcome.message};} finally{ + listening=false; unsubscribeSignals?.(); try{await deps.player.stop();}catch{} + await handoffOutput?.catch(()=>undefined); if(!preserveSystemVolume)try{await systemVolumeLease?.release();}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Previous system output volume could not be restored: ${errorMessage(error)}`});}catch{}} - if(listening){try{deps.store.checkpointActiveListeningSession(deps.now());deps.store.finishActiveListeningSession(deps.now());}catch{}} + if(historyStarted){ + try{deps.store.checkpointActiveListeningSession(deps.now());}catch(error){recordRunnerWarning(`Listening history could not be checkpointed: ${errorMessage(error)}`);} + try{deps.store.finishActiveListeningSession(deps.now());}catch(error){recordRunnerWarning(`Listening history could not be finished: ${errorMessage(error)}`);} + } try{await lease?.release();if(lease)deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:true,active:false,message:'Playback sleep inhibition released.'});}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:true,message:`Unable to verify inhibitor release: ${errorMessage(error)}`});}catch{}} try{await session?.close();}catch{} - if(alarm&&outcome){try{deps.store.recordAlarmOutcome(alarmId,outcome,{clearNextOverride:!preserveNextOverride});const latest=deps.store.getAlarm(alarmId);if(alarm.schedule.type==='once'&&latest?.schedule.type==='once'&&latest.schedule.at===alarm.schedule.at&&validTerminalOccurrence&&!preserveNextOverride)deps.store.toggleAlarm(alarmId,false);}catch{}} + if(alarm&&outcome){try{deps.store.recordAlarmOutcome(alarmId,outcome,{clearNextOverride:validTerminalOccurrence&&!preserveNextOverride});const latest=deps.store.getAlarm(alarmId);if(alarm.schedule.type==='once'&&latest?.schedule.type==='once'&&latest.schedule.at===alarm.schedule.at&&validTerminalOccurrence&&!preserveNextOverride)deps.store.toggleAlarm(alarmId,false);}catch(error){recordRunnerWarning(`Alarm outcome or completion state could not be saved: ${errorMessage(error)}`);}} alarm=deps.store.getAlarm(alarmId);if(alarm)try{if(deps.scheduler.syncClaimed)await deps.scheduler.syncClaimed(alarm,scheduledAt);else await deps.scheduler.sync(alarm);}catch{} - try{releaseLock();}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Occurrence lock cleanup failed: ${errorMessage(error)}`});}catch{}} - try{await deps.scheduler.completeOccurrence?.(alarmId,scheduledAt);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'scheduler',healthy:false,message:`Completed launch job cleanup failed: ${errorMessage(error)}`});}catch{}} + try{releaseLock(completeLock);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Occurrence lock cleanup failed: ${errorMessage(error)}`});}catch{}} + if(completeLock)try{await deps.scheduler.completeOccurrence?.(alarmId,scheduledAt);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'scheduler',healthy:false,message:`Completed launch job cleanup failed: ${errorMessage(error)}`});}catch{}} } } -export function acquireOccurrenceLock(alarmId:string,scheduledAt:string,root=defaultAlarmRuntimeDirectory()):(()=>void)|null{ +export function acquireOccurrenceLock(alarmId:string,scheduledAt:string,root=defaultAlarmRuntimeDirectory()):((completed?:boolean)=>void)|null{ const safe=`${alarmId.replace(/[^a-zA-Z0-9_-]/g,'_').slice(0,80)}-${Buffer.from(scheduledAt).toString('base64url')}`;const path=join(root,'locks',safe);mkdirSync(dirname(path),{recursive:true,mode:0o700});pruneCompletedOccurrenceLocks(root); try{mkdirSync(path,{mode:0o700});}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;try{if(existsSync(join(path,'completed')))return null;const pid=Number(readFileSync(join(path,'running'),'utf8'));if(Number.isInteger(pid)&&processAlive(pid))return null;rmSync(path,{recursive:true,force:true});mkdirSync(path,{mode:0o700});}catch{return null;}} writeFileSync(join(path,'running'),String(process.pid),{mode:0o600}); - return()=>{rmSync(join(path,'running'),{force:true});writeFileSync(join(path,'completed'),new Date().toISOString(),{mode:0o600});}; + return(completed=true)=>{if(!completed){rmSync(path,{recursive:true,force:true});return;}rmSync(join(path,'running'),{force:true});writeFileSync(join(path,'completed'),new Date().toISOString(),{mode:0o600});}; } export function pruneCompletedOccurrenceLocks(root=defaultAlarmRuntimeDirectory(),olderThanMs=30*24*60*60_000,now=Date.now()){const directory=join(root,'locks');if(!existsSync(directory))return 0;let removed=0;for(const name of readdirSync(directory)){const completed=join(directory,name,'completed');try{if(existsSync(completed)&&now-statSync(completed).mtimeMs>olderThanMs){rmSync(join(directory,name),{recursive:true,force:true});removed+=1;}}catch{}}return removed;} -export function defaultAlarmRuntimeDirectory():string{if(process.env.RADIOCLI_HOME)return join(process.env.RADIOCLI_HOME,'runtime');if(process.platform==='win32')return join(process.env.LOCALAPPDATA??join(homedir(),'AppData','Local'),'RadioCLI','runtime');return join(process.env.XDG_RUNTIME_DIR??join(homedir(),'.local','state'),'radiocli');} +export function defaultAlarmRuntimeDirectory():string{return platformPaths().runtime;} export const defaultRunnerUtilities={acquireLock:acquireOccurrenceLock,createSession:startActiveAlarmSession,wait:(milliseconds:number)=>new Promise(resolve=>setTimeout(resolve,milliseconds)),subscribeSignals:(handler:()=>void)=>{process.once('SIGTERM',handler);process.once('SIGHUP',handler);process.once('SIGINT',handler);return()=>{process.off('SIGTERM',handler);process.off('SIGHUP',handler);process.off('SIGINT',handler);};}}; function errorMessage(error:unknown){return error instanceof Error?error.message:String(error);} function processAlive(pid:number){try{process.kill(pid,0);return true;}catch{return false;}} diff --git a/src/alarms/schedule.test.ts b/src/alarms/schedule.test.ts index 7bb14f7..8e703b6 100644 --- a/src/alarms/schedule.test.ts +++ b/src/alarms/schedule.test.ts @@ -1,8 +1,9 @@ -import {describe, expect, it} from 'vitest'; +import {describe, expect, it, vi} from 'vitest'; import { assessScheduledOccurrence, canonicalizeAlarmTime, canonicalizeIsoWeekdays, + canonicalizeTimeZone, isValidTimeZone, nextAlarmOccurrence, nextOccurrenceForAlarm @@ -120,4 +121,20 @@ describe('alarm scheduling', () => { expect(() => canonicalizeIsoWeekdays([])).toThrow(/weekday/i); expect(() => canonicalizeIsoWeekdays([0, 8])).toThrow(/weekday/i); }); + + it('canonicalizes each alarm timezone with one native formatter construction', () => { + const expected = new Intl.DateTimeFormat('en-US', {timeZone: 'US/Eastern'}).resolvedOptions().timeZone; + const NativeDateTimeFormat = Intl.DateTimeFormat; + const formatter = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function (...args: Parameters) { + return new NativeDateTimeFormat(...args); + } as typeof Intl.DateTimeFormat); + try { + expect(canonicalizeTimeZone(' US/Eastern ')).toBe(expected); + expect(formatter).toHaveBeenCalledTimes(1); + expect(() => canonicalizeTimeZone('Mars/Olympus_Mons')).toThrow('Invalid IANA timezone: Mars/Olympus_Mons'); + expect(() => canonicalizeTimeZone(' ')).toThrow(/Invalid IANA timezone/); + } finally { + formatter.mockRestore(); + } + }); }); diff --git a/src/alarms/schedule.ts b/src/alarms/schedule.ts index 430e302..7b8f04c 100644 --- a/src/alarms/schedule.ts +++ b/src/alarms/schedule.ts @@ -16,10 +16,15 @@ export function isValidTimeZone(timezone: string): boolean { export function canonicalizeTimeZone(timezone: string): string { const value = timezone.trim(); - if (!isValidTimeZone(value)) { + try { + if (!value) throw new Error('Empty timezone.'); + // The constructor validates the zone and resolves its canonical name. + // Constructing twice made large-library validation expensive on Node 22 + // Intel hosts; keep one native validation for every supplied value. + return new Intl.DateTimeFormat('en-US', {timeZone: value}).resolvedOptions().timeZone; + } catch { throw new Error(`Invalid IANA timezone: ${timezone}`); } - return new Intl.DateTimeFormat('en-US', {timeZone: value}).resolvedOptions().timeZone; } export function canonicalizeAlarmTime(time: string): string { diff --git a/src/alarms/scheduler.test.ts b/src/alarms/scheduler.test.ts index 01f90d4..fce30c2 100644 --- a/src/alarms/scheduler.test.ts +++ b/src/alarms/scheduler.test.ts @@ -1,9 +1,12 @@ import {describe, expect, it, vi} from 'vitest'; import {existsSync,mkdirSync,mkdtempSync,readdirSync,rmSync,writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; -import {dirname,join} from 'node:path'; +import {homedir,tmpdir} from 'node:os'; +import {basename,dirname,join,posix,win32} from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; import type {Alarm} from '../types.js'; import {createSchedulerAdapter, SchedulerService,shouldRunLaunchdOccurrence,type SchedulerAdapter} from './scheduler.js'; +import {pathEnvironmentKeys, platformPaths} from '../platform/paths.js'; const alarm: Alarm = { id: 'wake<&"', label: 'Morning <&>', enabled: true, @@ -14,6 +17,46 @@ const alarm: Alarm = { createdAt: '2029-01-01T00:00:00.000Z', updatedAt: '2029-01-01T00:00:00.000Z' }; +function windowsTaskInvocation(body: string): {command: string; args: string[]} { + expect(body).toContain('%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); + const argumentsText = /([^<]+)<\/Arguments>/.exec(body)?.[1]; + expect(argumentsText).toBeDefined(); + const args = argumentsText!.split(' '); + const script = Buffer.from(args[args.indexOf('-EncodedCommand') + 1]!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(encoded).toBeDefined(); + return JSON.parse(Buffer.from(encoded!, 'base64').toString('utf8')); +} + +const decodeXml = (value: string) => value.replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll(''', "'").replaceAll('&', '&'); +function schedulerInvocation(body: string, platform: 'darwin' | 'linux' | 'win32'): {command: string; args: string[]} { + if (platform === 'win32') return windowsTaskInvocation(body); + const values: string[] = platform === 'darwin' + ? [.../ProgramArguments<\/key>([\s\S]*?)<\/array>/.exec(body)![1]!.matchAll(/([\s\S]*?)<\/string>/g)].map(match => decodeXml(match[1]!)) + : [.../^ExecStart=:(.+)$/m.exec(body)![1]!.matchAll(/"(?:[^"\\]|\\.)*"/g)].map(match => (JSON.parse(match[0]) as string).replaceAll('%%', '%')); + return {command: values[0]!, args: values.slice(1)}; +} +function schedulerPayload(body: string, platform: 'darwin' | 'linux' | 'win32'): {args: string[]; environment: Record} { + return JSON.parse(Buffer.from(schedulerInvocation(body, platform).args.at(-1)!, 'base64url').toString('utf8')); +} + +function schedulerEnvironment(body: string, platform: 'darwin' | 'linux' | 'win32'): Record { + if (platform === 'win32') return {}; + if (platform === 'darwin') { + const environment = /EnvironmentVariables<\/key>([\s\S]*?)<\/dict>/.exec(body)![1]!; + return Object.fromEntries([...environment.matchAll(/([^<]+)<\/key>([^<]*)<\/string>/g)].map(match => [decodeXml(match[1]!), decodeXml(match[2]!)])); + } + return Object.fromEntries([...body.matchAll(/^Environment=(.+)$/gm)].map(match => { + const entry = (JSON.parse(match[1]!) as string).replaceAll('%%', '%'); + return [entry.slice(0, entry.indexOf('=')), entry.slice(entry.indexOf('=') + 1)]; + })); +} + +const windowsTaskPath = (id: string) => `\\RadioCLI\\io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`; +function windowsLookupResult(overrides: Record = {}) { + return {code: 0, stdout: JSON.stringify({taskPath: windowsTaskPath(alarm.id), stage: 'lookup', status: 'error', hresult: -2147024894, message: 'El sistema no puede encontrar el archivo especificado.', ...overrides}), stderr: ''}; +} + describe('native alarm schedulers', () => { it('generates safely escaped launchd artifacts and an argv-only command', async () => { const write = vi.fn(); const run = vi.fn(async () => ({code: 0, stdout: '', stderr: ''})); @@ -25,14 +68,14 @@ describe('native alarm schedulers', () => { expect(xml).toContain('/Node A/node'); expect(xml).not.toContain('Year'); expect(xml).not.toContain('Second'); - expect(xml).toContain('internal-launchd'); + expect(schedulerPayload(xml, 'darwin').args).toEqual(['/App A/dist/cli.js', 'alarm', 'internal-launchd', alarm.id, '2030-02-03T14:05:00.000Z']); expect(xml).toContain('RADIOCLI_HOME/Data & A'); expect(xml).toContain('RADIOCLI_ALARM_TERMINALdarwin:apple-terminal'); expect((run.mock.calls as unknown as Array<[string,string[]]>).every(call => Array.isArray(call[1]))).toBe(true); }); it('makes an early annual launchd calendar match a true no-op',()=>{expect(shouldRunLaunchdOccurrence('2035-02-03T14:05:00Z',new Date('2030-02-03T14:05:00Z'))).toBe(false);expect(shouldRunLaunchdOccurrence('2035-02-03T14:05:00Z',new Date('2035-02-03T14:05:00Z'))).toBe(true);}); - it('installs the next launchd occurrence without booting out the current runner',async()=>{const writes:Array<[string,string]>=[];const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='print'?1:0,stdout:'',stderr:''}));const adapter=createSchedulerAdapter({platform:'darwin',home:'/Users/a',nodePath:'/node',cliPath:'/cli.js',writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run,env:{}});const recurring:Alarm={...alarm,schedule:{type:'recurring',time:'14:05',weekdays:[1,2,3,4,5,6,7],timezone:'UTC'}};const service=new SchedulerService(adapter,()=>new Date('2030-02-03T14:06:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[])} as never);await expect(service.syncClaimed(recurring,new Date('2030-02-03T14:05:00Z'))).resolves.toEqual(new Date('2030-02-04T14:05:00Z'));expect(run.mock.calls.map(call=>call[1]?.[0])).not.toContain('bootout');expect(run).toHaveBeenCalledWith('launchctl',expect.arrayContaining(['bootstrap']));expect(writes[0]?.[0]).toMatch(/io\.radiocli\.alarm\.[a-f0-9]+\.[a-f0-9]+\.plist$/);expect(writes[0]?.[1]).toContain('2030-02-04T14:05:00.000Z');}); + it('installs the next launchd occurrence without booting out the current runner',async()=>{const writes:Array<[string,string]>=[];const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='print'?1:0,stdout:'',stderr:''}));const adapter=createSchedulerAdapter({platform:'darwin',home:'/Users/a',nodePath:'/node',cliPath:'/cli.js',writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run,env:{}});const recurring:Alarm={...alarm,schedule:{type:'recurring',time:'14:05',weekdays:[1,2,3,4,5,6,7],timezone:'UTC'}};const service=new SchedulerService(adapter,()=>new Date('2030-02-03T14:06:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[])} as never);await expect(service.syncClaimed(recurring,new Date('2030-02-03T14:05:00Z'))).resolves.toEqual(new Date('2030-02-04T14:05:00Z'));expect(run.mock.calls.map(call=>call[1]?.[0])).not.toContain('bootout');expect(run).toHaveBeenCalledWith('launchctl',expect.arrayContaining(['bootstrap']));expect(writes[0]?.[0]).toMatch(/io\.radiocli\.alarm\.[a-f0-9]+\.[a-f0-9]+\.plist$/);expect(schedulerPayload(writes[0]![1],'darwin').args).toContain('2030-02-04T14:05:00.000Z');}); it.each([['nonzero',false],['rejected',true]] as const)('retains a stable launchd artifact when bootout is %s and the job is still loaded',async(_label,rejected)=>{const remove=vi.fn();const run=vi.fn(async(_command:string,args:string[])=>{if(args[0]==='bootout'){if(rejected)throw new Error('transport');return{code:5,stdout:'',stderr:'busy'};}return{code:0,stdout:'loaded',stderr:''};});const adapter=createSchedulerAdapter({platform:'darwin',home:'/Users/a',nodePath:'/node',cliPath:'/cli.js',writeFile:vi.fn(),removeFile:remove,run,env:{}});await expect(adapter.completeOccurrence!(alarm.id,new Date(alarm.schedule.type==='once'?alarm.schedule.at:''))).rejects.toThrow(/still loaded/i);expect(remove).not.toHaveBeenCalled();}); @@ -52,7 +95,7 @@ describe('native alarm schedulers', () => { await adapter.install(alarm, new Date('2030-02-03T14:05:00.000Z')); expect(writes.map(item => item[1]).join('\n')).toContain('Persistent=true'); expect(writes.map(item => item[1]).join('\n')).toContain('AccuracySec=1s'); - expect(writes.map(item => item[1]).join('\n')).toContain('"/opt/radio cli/dist/cli.js"'); + expect(schedulerPayload(writes.find(([path]) => path.endsWith('.service'))![1], 'linux').args).toEqual(['/opt/radio cli/dist/cli.js', 'alarm', 'internal-run', alarm.id, '2030-02-03T14:05:00.000Z']); expect(writes.map(item => item[1]).join('\n')).toContain('RADIOCLI_ALARM_TERMINAL=linux:unsupported'); }); @@ -62,6 +105,22 @@ describe('native alarm schedulers', () => { expect(adapter.capabilities().message).toMatch(/systemd/i); }); + it('retains scheduler health and repair artifacts when systemctl is unavailable',async()=>{ + const run=vi.fn();const removeFile=vi.fn();const health={record:vi.fn(),list:()=>[],get:()=>[],remove:vi.fn()}; + const adapter=createSchedulerAdapter({platform:'linux',home:'/missing-radiocli-home',env:{},commandExists:()=>false,run,removeFile,writeFile:vi.fn()}); + const service=new SchedulerService(adapter,()=>new Date(),health as never); + await expect(service.remove(alarm.id)).rejects.toThrow(/systemctl.*unavailable|verify.*remov|verify.*absence/i); + expect(health.remove).not.toHaveBeenCalled();expect(removeFile).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled(); + expect(health.record).toHaveBeenCalledWith(expect.objectContaining({alarmId:alarm.id,component:'scheduler',healthy:false,message:expect.stringContaining('retained')})); + }); + + it('allows removal of a definition on a runtime with no native scheduler adapter',async()=>{ + const run=vi.fn();const adapter=createSchedulerAdapter({platform:'freebsd',env:{},run}); + const health={record:vi.fn(),list:()=>[],get:()=>[],remove:vi.fn()}; + await expect(new SchedulerService(adapter,()=>new Date(),health as never).remove(alarm.id)).resolves.toBeUndefined(); + expect(health.remove).toHaveBeenCalledWith(alarm.id);expect(run).not.toHaveBeenCalled(); + }); + it('creates a current-user Windows task with wake and local interactive audio semantics', async () => { const write=vi.fn();const run=vi.fn(async()=>({code:0,stdout:'',stderr:''})); const adapter=createSchedulerAdapter({platform:'win32',home:'C:\\Users\\A B',nodePath:'C:\\Node A\\node.exe',cliPath:'C:\\Radio A\\dist\\cli.js',writeFile:write,removeFile:vi.fn(),run,env:{LOCALAPPDATA:'C:\\Data A',RADIOCLI_HOME:'C:\\Profile & A'}}); @@ -74,18 +133,90 @@ describe('native alarm schedulers', () => { expect(artifact).toContain('false'); expect(artifact).toContain('PT0S'); expect(artifact).toContain('encoding="UTF-8"'); - expect(artifact).toContain('RADIOCLI_HOME=C:\\Profile ^& A" &&'); - expect(artifact).toContain('RADIOCLI_ALARM_TERMINAL=win32:console'); + const invocation=windowsTaskInvocation(artifact); + expect(invocation.command).toBe('C:\\Node A\\node.exe'); + expect(JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')).environment).toEqual({LOCALAPPDATA:'C:\\Data A',RADIOCLI_HOME:'C:\\Profile & A',RADIOCLI_ALARM_TERMINAL:'win32:console',...(process.platform==='win32'?{USERPROFILE:homedir()}:{})}); expect(artifact).not.toContain('&amp;'); expect(adapter.capabilities().exactWake).toBe(false); expect((run.mock.calls as unknown as Array<[string,string[]]>)[0]?.[1]).toContain('/XML'); }); + it('preserves literal Windows job arguments and approved environment values',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-windows-job-')); + try{ + const cliPath=join(root,"Radio $ %PATH%! & ' 单播.cjs");const writes:Array<[string,string]>=[]; + const configured={RADIOCLI_HOME:"C:\\Data %PATH%! & ' 单播",RADIOCLI_MPV_PATH:"C:\\Players %PATH%! & ' 单播\\mpv.exe",RADIOCLI_FFPLAY_PATH:'C:\\Players A\\ffplay.exe',RADIOCLI_VLC_PATH:'C:\\Players A\\vlc.exe',RADIOCLI_FFMPEG_PATH:'C:\\Players A\\ffmpeg.exe',RADIOCLI_OFFLINE:'0',RADIOCLI_LOW_BANDWIDTH:'1'}; + writeFileSync(cliPath,`process.stdout.write(JSON.stringify({args:process.argv.slice(2),environment:Object.fromEntries(${JSON.stringify(Object.keys(configured))}.map(key=>[key,process.env[key]]))}))`); + const adapter=createSchedulerAdapter({platform:'win32',home:root,nodePath:process.execPath,cliPath,env:{...configured,HTTPS_PROXY:'https://private-password@proxy.invalid'},writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); + const {command,args}=windowsTaskInvocation(writes[0]![1]);expect(command).toBe(process.execPath);expect(args).toHaveLength(3);expect(args[0]).toBe('-e'); + const payload=JSON.parse(Buffer.from(args.at(-1)!,'base64url').toString('utf8'));expect(payload.environment).toEqual({...configured,RADIOCLI_ALARM_TERMINAL:'win32:console',...(process.platform==='win32'?{USERPROFILE:homedir()}:{})});expect(JSON.stringify(payload)).not.toContain('private-password'); + const output=execFileSync(command,args,{encoding:'utf8',windowsHide:true}); + expect(JSON.parse(output)).toEqual({args:['alarm','internal-run',alarm.id,'2030-02-03T14:05:00.000Z'],environment:configured}); + }finally{rmSync(root,{recursive:true,force:true});} + }); + it('pins systemd timers in UTC and escapes specifiers',async()=>{ const writes:Array<[string,string]>=[];const adapter=createSchedulerAdapter({platform:'linux',home:'/home/a',nodePath:'/node%p',cliPath:'/app%h/cli.js',writeFile:(p,c)=>writes.push([p,c]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''})),commandExists:()=>true,env:{RADIOCLI_HOME:'/data%u'}}); await adapter.install(alarm,new Date('2030-02-03T14:05:00Z'));const units=writes.map(item=>item[1]).join('\n');expect(units).toContain('2030-02-03 14:05:00 UTC');expect(units).toContain('/node%%p');expect(units).toContain('RADIOCLI_HOME=/data%%u'); }); + it('disables systemd command expansion while preserving literal environment values',async()=>{ + const writes:Array<[string,string]>=[]; + const adapter=createSchedulerAdapter({platform:'linux',home:'/home/a',nodePath:'/Node $ ${CUSTOM} % 单播/node',cliPath:'/app-${CUSTOM}/% spaced 单播/cli.js',env:{RADIOCLI_HOME:'/data $ ${CUSTOM} % 单播'},commandExists:()=>true,writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); + const service=writes.find(([path])=>path.endsWith('.service'))![1]; + expect(service).toContain('ExecStart=:"/Node $ ${CUSTOM} %% 单播/node" "-e"'); + expect(schedulerPayload(service, 'linux').args[0]).toBe('/app-${CUSTOM}/% spaced 单播/cli.js'); + expect(service).toContain('Environment="RADIOCLI_HOME=/data $ ${CUSTOM} %% 单播"'); + expect(service).not.toContain('$$'); + }); + + it.each(['darwin','linux'] as const)('preserves explicit player locations and network policy without proxy secrets in %s jobs',async platform=>{ + const writes:Array<[string,string]>=[]; + const configured={RADIOCLI_MPV_PATH:'/Players A/mpv',RADIOCLI_FFPLAY_PATH:'/Players A/ffplay',RADIOCLI_VLC_PATH:'/Players A/vlc',RADIOCLI_FFMPEG_PATH:'/Players A/ffmpeg',RADIOCLI_OFFLINE:'0',RADIOCLI_LOW_BANDWIDTH:'1'}; + const adapter=createSchedulerAdapter({platform,home:'/home/a',nodePath:'/node',cliPath:'/cli.js',env:{...configured,HTTPS_PROXY:'https://proxy-user:private-password@proxy.invalid',ARBITRARY_SECRET:'private-secret'},commandExists:()=>true,writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); + const artifact=writes.map(([,body])=>body).join('\n'); + for(const [key,value] of Object.entries(configured))expect(artifact).toContain(platform==='darwin'?`${key}${value}`:`Environment="${key}=${value}"`); + expect(artifact).not.toMatch(/proxy-user|private-password|private-secret|HTTPS_PROXY|ARBITRARY_SECRET/); + }); + + it.each(['darwin', 'linux', 'win32'] as const)('runs the generated %s scheduler command with its saved identity in clean and contaminated environments', async platform => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-scheduler-identity-')); + try { + const cliPath = join(root, "inspect $ % & ' 単播.cjs"); + writeFileSync(cliPath, `process.stdout.write(JSON.stringify({args:process.argv.slice(2),env:Object.fromEntries(${JSON.stringify(pathEnvironmentKeys)}.filter(key=>process.env[key]!==undefined).map(key=>[key,process.env[key]]))}))`); + const path = platform === 'win32' ? win32 : posix; + // A Windows filesystem path is not an absolute POSIX path. Use a virtual + // POSIX identity when exercising Darwin/Linux artifacts on Windows. + const dataRoot = platform === 'win32' ? 'C:\\Users\\scheduler-fixture' : process.platform === 'win32' ? '/scheduler-fixture' : root; + const configured = {HOME: dataRoot, USERPROFILE: path.join(dataRoot, 'profile'), APPDATA: '', LOCALAPPDATA: path.join(dataRoot, 'local'), RADIO_ATLAS_HOME: path.join(dataRoot, "alias $ % & ' 単播"), XDG_DATA_HOME: path.join(dataRoot, 'data'), XDG_CACHE_HOME: path.join(dataRoot, 'cache'), XDG_RUNTIME_DIR: path.join(dataRoot, 'runtime')}; + const writes: Array<[string, string]> = []; + const adapter = createSchedulerAdapter({platform, home: root, nodePath: process.execPath, cliPath, env: {...configured, HTTPS_PROXY: 'private-proxy-secret'}, commandExists: () => true, writeFile: (path, body) => writes.push([path, body]), removeFile: vi.fn(), run: vi.fn(async () => ({code: 0, stdout: '', stderr: ''}))}); + await adapter.install(alarm, new Date('2030-02-03T14:05:00Z')); + const artifact = writes.find(([path]) => platform !== 'linux' || path.endsWith('.service'))![1]; + const invocation = schedulerInvocation(artifact, platform); + const clean = {...process.env}; + for (const key of pathEnvironmentKeys) delete clean[key]; + const contaminated = {...clean, ...Object.fromEntries(pathEnvironmentKeys.map(key => [key, `${root}/stale-${key}`]))}; + for (const env of [clean, contaminated]) { + const observed = JSON.parse(execFileSync(invocation.command, invocation.args, {env: {...env, ...schedulerEnvironment(artifact, platform)}, encoding: 'utf8', windowsHide: true})); + expect(observed.args).toEqual(['alarm', platform === 'darwin' ? 'internal-launchd' : 'internal-run', alarm.id, '2030-02-03T14:05:00.000Z']); + // An empty APPDATA root denotes the invoking directory. The snapshot + // retains that location even if the native manager has another cwd. + expect(observed.env).toEqual({...configured, APPDATA: path.resolve(process.cwd())}); + expect(platformPaths({platform, home: root, env: observed.env})).toEqual(platformPaths({platform, home: root, env: configured})); + } + expect(artifact).not.toContain('private-proxy-secret'); + } finally {rmSync(root, {recursive: true, force: true});} + }); + + it.each(['darwin','linux','win32'] as const)('rejects control characters in an approved %s job environment before writing artifacts',async platform=>{ + const writeFile=vi.fn();const adapter=createSchedulerAdapter({platform,home:'/home/a',nodePath:'/node',cliPath:'/cli.js',env:{RADIOCLI_MPV_PATH:'/tools/mpv\nInjected=value'},commandExists:()=>true,writeFile,removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await expect(adapter.install(alarm,new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/RADIOCLI_MPV_PATH.*control/i);expect(writeFile).not.toHaveBeenCalled(); + }); + it('removes disabled alarms and reschedules enabled alarms', async () => { const adapter = {capabilities: vi.fn(() => ({supported: true, exactWake: false, catchUpAfterWake: true, message: 'ok'})), install: vi.fn(), remove: vi.fn(), status: vi.fn()}; const service = new SchedulerService(adapter, () => new Date('2029-01-01T00:00:00Z'), {record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[])} as never); @@ -113,4 +244,219 @@ describe('native alarm schedulers', () => { it('fails disable/removal when an active Guard cannot be verified stopped',async()=>{const adapter:SchedulerAdapter={capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'ok'}),install:vi.fn(),remove:vi.fn(),status:vi.fn()};const record=vi.fn();const guard={start:vi.fn(),stop:vi.fn(async()=>false),status:vi.fn(async()=>({guards:[{alarmId:alarm.id}]}))};const service=new SchedulerService(adapter,()=>new Date('2029-01-01T00:00:00Z'),{record,list:vi.fn(()=>[]),get:vi.fn(()=>[]),remove:vi.fn()} as never,guard);await expect(service.sync({...alarm,enabled:false})).rejects.toThrow(/teardown|verified/i);expect(record).toHaveBeenCalledWith(expect.objectContaining({component:'power',healthy:false}));await expect(service.remove(alarm.id)).rejects.toThrow(/retained/i);expect(adapter.remove).toHaveBeenCalledTimes(1);}); it('accepts false Guard stop only when status verifies no matching Guard exists',async()=>{const adapter:SchedulerAdapter={capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'ok'}),install:vi.fn(),remove:vi.fn(),status:vi.fn()};const guard={start:vi.fn(),stop:vi.fn(async()=>false),status:vi.fn(async()=>({guards:[]}))};const service=new SchedulerService(adapter,()=>new Date('2029-01-01T00:00:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[]),remove:vi.fn()} as never,guard);await expect(service.sync({...alarm,enabled:false})).resolves.toBeNull();await expect(service.remove(alarm.id)).resolves.toBeUndefined();}); it('accepts a successful authenticated Guard teardown',async()=>{const adapter:SchedulerAdapter={capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'ok'}),install:vi.fn(),remove:vi.fn(),status:vi.fn()};const guard={start:vi.fn(),stop:vi.fn(async()=>true),status:vi.fn()};const service=new SchedulerService(adapter,()=>new Date('2029-01-01T00:00:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[]),remove:vi.fn()} as never,guard);await expect(service.sync({...alarm,enabled:false})).resolves.toBeNull();expect(guard.status).not.toHaveBeenCalled();}); + + it.each(['darwin', 'linux', 'win32'] as const)('retains %s repair artifacts when native removal fails and absence is uncertain', async platform => { + const removeFile = vi.fn(); + const run = vi.fn(async () => ({code: 1, stdout: '', stderr: 'Permission denied'})); + const adapter = createSchedulerAdapter({platform, home: '/missing-radiocli-test-home', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(/remov|absen|disable|bootout|delete/i); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'linux', 'win32'] as const)('retains %s repair artifacts when the targeted native job is still registered', async platform => { + const removeFile = vi.fn(); + const run = vi.fn(async (_command: string, args: string[]) => { + if (args[0] === 'print' || args[0] === '/Query') return {code: 0, stdout: 'registered', stderr: ''}; + if (args.includes('show')) return {code: 0, stdout: 'LoadState=loaded\nActiveState=active\n', stderr: ''}; + return {code: 1, stdout: '', stderr: 'busy'}; + }); + const adapter = createSchedulerAdapter({platform, home: '/missing-radiocli-test-home', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'linux', 'win32'] as const)('accepts failed %s removal only after a targeted absence response', async platform => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string, args: string[]) => { + if (args[0] === 'print') return {code: 113, stdout: '', stderr: `Could not find service "${args[1]!.split('/').at(-1)}" in domain for user gui: 501`}; + if (args.includes('show')) return {code: 1, stdout: 'LoadState=not-found\nActiveState=inactive\n', stderr: ''}; + if (command === 'powershell.exe') return windowsLookupResult(); + if (args.includes('daemon-reload')) return {code: 0, stdout: '', stderr: ''}; + return {code: 1, stdout: '', stderr: 'already gone'}; + }); + const adapter = createSchedulerAdapter({platform, home: '/missing-radiocli-test-home', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).resolves.toBeUndefined(); + expect(removeFile).toHaveBeenCalled(); + }); + + it('reports an unreachable systemd user manager without writing native jobs', async () => { + const writeFile = vi.fn(); + const run = vi.fn(async (_command: string, _args: string[]) => ({code: 1, stdout: '', stderr: 'Failed to connect to user bus'})); + const adapter = createSchedulerAdapter({platform: 'linux', env: {}, commandExists: () => true, writeFile, removeFile: vi.fn(), run}); + const service = new SchedulerService(adapter, () => new Date(), {record: vi.fn(), list: () => [], get: () => []} as never); + expect(adapter.capabilities().message).toMatch(/detect|verif|check/i); + const report = await service.runtimeStatus(); + expect(report.capabilities).toMatchObject({supported: false, catchUpAfterWake: false}); + expect(report.capabilities.message).toMatch(/user.*manager|user bus/i); + await expect(adapter.install(alarm, new Date(alarm.schedule.type === 'once' ? alarm.schedule.at : ''))).rejects.toThrow(/user.*manager|user bus/i); + expect(writeFile).not.toHaveBeenCalled(); + expect(run.mock.calls.every(call => call[1].includes('show'))).toBe(true); + }); + + it('bounds a stalled systemd user-manager probe', async () => { + vi.useFakeTimers(); + try { + const adapter = createSchedulerAdapter({platform: 'linux', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile: vi.fn(), run: () => new Promise(() => {})}); + const service = new SchedulerService(adapter, () => new Date(), {record: vi.fn(), list: () => [], get: () => []} as never); + const pending = service.runtimeStatus(); + await vi.advanceTimersByTimeAsync(3_000); + expect((await pending).capabilities).toMatchObject({supported: false}); + } finally { vi.useRealTimers(); } + }); + + it('uses the injected executable environment for scheduler discovery', () => { + // A relative PATH entry also represents a POSIX search path when this + // injected Linux case runs on Windows, whose drive prefix contains ':'. + const root = mkdtempSync(join(process.cwd(), '.radiocli-scheduler-path-')); + vi.stubEnv('PATH', '/missing-radiocli-executables'); + try { + writeFileSync(join(root, 'systemctl'), '#!/bin/sh\nexit 0\n', {mode: 0o755}); + const adapter = createSchedulerAdapter({platform: 'linux', env: {PATH: basename(root)}, home: root, writeFile: vi.fn(), removeFile: vi.fn(), run: vi.fn()}); + expect(adapter.capabilities().supported).toBe(true); + } finally { vi.unstubAllEnvs(); rmSync(root, {recursive: true, force: true}); } + }); + + it('does not select Linux scheduling for a Termux environment', async () => { + const writeFile = vi.fn(); const run = vi.fn(); + const adapter = createSchedulerAdapter({platform: 'linux', env: {TERMUX_VERSION: 'test'}, commandExists: () => true, writeFile, removeFile: vi.fn(), run}); + expect(adapter.capabilities()).toMatchObject({supported: false, catchUpAfterWake: false}); + await expect(adapter.install(alarm, new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/termux/i); + expect(writeFile).not.toHaveBeenCalled(); expect(run).not.toHaveBeenCalled(); + }); + + it('retains an alarm when Guard ownership remains unresolved', async () => { + const adapter: SchedulerAdapter = {capabilities: () => ({supported: true, exactWake: false, catchUpAfterWake: true, message: 'ok'}), install: vi.fn(), remove: vi.fn(), status: vi.fn()}; + const guard = {start: vi.fn(), stop: vi.fn(async () => false), status: vi.fn(async () => ({guards: [], unresolvedGuards: [{alarmId: alarm.id}]}))}; + const service = new SchedulerService(adapter, () => new Date(), {record: vi.fn(), list: () => [], get: () => [], remove: vi.fn()} as never, guard); + await expect(service.remove(alarm.id)).rejects.toThrow(/verified|retained/i); + expect(adapter.remove).not.toHaveBeenCalled(); + }); + + it('does not accept an absence response for another launchd service sharing the target prefix',async()=>{const removeFile=vi.fn();const run=vi.fn(async(_command:string,args:string[])=>({code:113,stdout:'',stderr:args[0]==='print'?`Could not find service "${args[1]!.split('/').at(-1)}.other" in domain for user gui: 501`:'busy'}));const adapter=createSchedulerAdapter({platform:'darwin',home:'/missing-radiocli-test-home',env:{},writeFile:vi.fn(),removeFile,run});await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*absence/i);expect(removeFile).not.toHaveBeenCalled();}); + + it('keeps a registered but inactive systemd timer unhealthy without mutating its registration',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-systemd-inactive-'));try{ + const cliPath=join(root,'cli.js');writeFileSync(cliPath,'');const run=vi.fn(async(_command:string,args:string[])=>({code:args.includes('is-active')?3:0,stdout:args.includes('is-active')?'inactive':'',stderr:''})); + const writeFile=vi.fn((path:string,body:string)=>{mkdirSync(dirname(path),{recursive:true});writeFileSync(path,body);}); + const adapter=createSchedulerAdapter({platform:'linux',home:root,nodePath:process.execPath,cliPath,env:{},commandExists:()=>true,writeFile,run}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z'));writeFile.mockClear();run.mockClear(); + expect(await adapter.status(alarm.id)).toMatchObject({installed:true,healthy:false,message:expect.stringMatching(/not active/i)});expect(writeFile).not.toHaveBeenCalled();expect(run.mock.calls.map(call=>call[1][1])).toEqual(['show','is-enabled','is-active']); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it.each(['darwin','linux','win32'] as const)('retains %s repair artifacts when native commands reject instead of returning status',async platform=>{const removeFile=vi.fn();const adapter=createSchedulerAdapter({platform,env:{},home:'/missing-radiocli-test-home',writeFile:vi.fn(),removeFile,commandExists:()=>true,run:async()=>{throw new Error('native transport failed');}});await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*removal/i);expect(removeFile).not.toHaveBeenCalled();}); + + it.each([ + {hresult: -2147024894, message: 'El sistema no puede encontrar el archivo especificado.'}, + {hresult: -2147024894, message: 'Das System kann die angegebene Datei nicht finden.'}, + {hresult: -2147024893, message: '指定されたパスが見つかりません。'} + ])('removes Windows repair metadata after localized absence HRESULT $hresult: $message', async result => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult(result) : {code: 1, stdout: '', stderr: result.message}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).resolves.toBeUndefined(); + expect(removeFile).toHaveBeenCalledOnce(); + expect(run).toHaveBeenCalledTimes(2); + }); + + it.each([ + {label: 'access denied', hresult: -2147024891, message: 'Zugriff verweigert.'}, + {label: 'unknown failure', hresult: -2147467259, message: 'Erreur non spécifiée.'}, + {label: 'missing COM service', hresult: -2147024894, message: 'Servicio no encontrado.', stage: 'connect'}, + {label: 'inaccessible root folder', hresult: -2147024893, message: 'Chemin introuvable.', stage: 'root'} + ])('retains Windows repair metadata and diagnostics for $label', async ({label: _label, ...result}) => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult(result) : {code: 1, stdout: '', stderr: 'delete failed'}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + const failure = await adapter.remove(alarm.id).catch(error => error as Error); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(result.message); + expect((failure as Error).message).toContain('delete failed'); + expect((failure as Error).message).toContain(`0x${(result.hresult >>> 0).toString(16)}`); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it.each([ + {label: 'empty response', result: {code: 0, stdout: '', stderr: ''}}, + {label: 'plain localized text', result: {code: 1, stdout: '', stderr: 'El sistema no puede encontrar el archivo especificado.'}}, + {label: 'malformed JSON', result: {code: 0, stdout: '{broken', stderr: ''}}, + {label: 'null response', result: {code: 0, stdout: 'null', stderr: ''}}, + {label: 'array response', result: {code: 0, stdout: '[]', stderr: ''}}, + {label: 'string HRESULT', result: windowsLookupResult({hresult: '-2147024894'})}, + {label: 'unsupported status', result: windowsLookupResult({status: 'absent'})}, + {label: 'missing diagnostic', result: windowsLookupResult({message: null})}, + {label: 'unrelated task', result: windowsLookupResult({taskPath: `${windowsTaskPath(alarm.id)}.other`})}, + {label: 'failed process with valid JSON', result: {...windowsLookupResult(), code: 1}}, + {label: 'extra output', result: {...windowsLookupResult(), stdout: `notice\n${windowsLookupResult().stdout}`}} + ])('does not infer Windows task absence from $label', async ({result}) => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? result : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*absence|verify.*removal/i); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it('retains Windows repair metadata when successful deletion is followed by a still-present task', async () => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult({status: 'present', hresult: 0, message: ''}) : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(/still registered/i); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it('waits for native Windows absence verification before removing the artifact', async () => { + const removeFile = vi.fn(); + let finishLookup!: (result: ReturnType) => void; + const lookup = new Promise>(resolve => {finishLookup = resolve;}); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? lookup : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + const pending = adapter.remove(alarm.id); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2)); + expect(removeFile).not.toHaveBeenCalled(); + finishLookup(windowsLookupResult()); + await expect(pending).resolves.toBeUndefined(); + expect(removeFile).toHaveBeenCalledOnce(); + }); + + it('limits Windows deletion and lookup to the exact named task and retains unrelated artifacts', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-windows-remove-')); + try { + const id = "../other; $env:HOME & ' 単播"; + const target = windowsTaskPath(id); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult({taskPath: target}) : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', home: root, env: {LOCALAPPDATA: root}, run}); + await adapter.install({...alarm, id}, new Date('2030-02-03T14:05:00Z')); + await adapter.install(alarm, new Date('2030-02-03T14:05:00Z')); + run.mockClear(); + await adapter.remove(id); + const calls = run.mock.calls as unknown as Array<[string, string[]]>; + expect(calls[0]).toEqual(['schtasks.exe', ['/Delete', '/TN', target, '/F']]); + expect(calls[1]![0]).toBe('powershell.exe'); + const args = calls[1]![1]; + expect(args.slice(0, -1)).toEqual(['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand']); + const script = Buffer.from(args.at(-1)!, 'base64').toString('utf16le'); + expect(script).not.toContain(id); + expect(script).not.toMatch(/DeleteFolder|DeleteTask|GetTasks|Unregister-ScheduledTask/i); + const encodedPath = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(Buffer.from(encodedPath!, 'base64').toString('utf8')).toBe(target); + expect(readdirSync(join(root, 'RadioCLI', 'scheduler'))).toEqual([`${windowsTaskPath(alarm.id).split('\\').at(-1)}.xml`]); + } finally {rmSync(root, {recursive: true, force: true});} + }); + + it('bounds a stalled Windows deletion and absence probe without removing repair artifacts', async () => { + vi.useFakeTimers(); + try { + const removeFile = vi.fn(); + const run = vi.fn(() => new Promise>(() => {})); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + const pending = adapter.remove(alarm.id); + const assertion = expect(pending).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(20_000); + await assertion; + expect(run).toHaveBeenCalledTimes(2); + expect(removeFile).not.toHaveBeenCalled(); + } finally {vi.useRealTimers();} + }); + + it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('does not create or claim native alarm registration on %s',async platform=>{const writeFile=vi.fn();const run=vi.fn();const commandExists=vi.fn(()=>true);const adapter=createSchedulerAdapter({platform,env:{},writeFile,run,commandExists});expect(adapter.capabilities()).toMatchObject({supported:false,exactWake:false,catchUpAfterWake:false});await expect(adapter.install(alarm,new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/not supported/i);expect(writeFile).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();expect(commandExists).not.toHaveBeenCalled();}); }); diff --git a/src/alarms/scheduler.ts b/src/alarms/scheduler.ts index f543cdb..8d75fd7 100644 --- a/src/alarms/scheduler.ts +++ b/src/alarms/scheduler.ts @@ -9,9 +9,14 @@ import {nextOccurrenceForAlarm} from './schedule.js'; import {AlarmRuntimeHealthStore} from './runtime-health.js'; import {AlarmGuardService} from './guard.js'; import {AlarmPowerGuardStore} from './power-guard-store.js'; -import {detectAlarmTerminal} from './terminal-launcher.js'; +import {detectGraphicalTerminal} from '../platform/terminals.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; +import {resolveCommandDetails} from '../platform/executables.js'; +import {launchEnvironment, nodeLaunchCommand} from '../platform/launch-command.js'; +import {powershellCommand} from '../platform/shell.js'; export type SchedulerCapabilities = { + name?: string; supported: boolean; exactWake: boolean; catchUpAfterWake: boolean; @@ -21,6 +26,7 @@ type CommandResult = {code: number; stdout: string; stderr: string}; type SchedulerStatus = {installed: boolean; healthy: boolean; message: string}; export type SchedulerAdapter = { capabilities(): SchedulerCapabilities; + probeCapabilities?(): Promise; install(alarm: Alarm, occurrence: Date): Promise; remove(alarmId: string): Promise; status(alarmId: string): Promise; @@ -41,7 +47,7 @@ export type SchedulerDeps = { }; export class SchedulerService { - constructor(readonly adapter: SchedulerAdapter, private readonly now: () => Date = () => new Date(),readonly health=new AlarmRuntimeHealthStore(),private readonly guard?:{start(alarm:Alarm):Promise;stop(alarmId?:string):Promise;status?():Promise<{guards:Array<{alarmId:string}>}>|{guards:Array<{alarmId:string}>}}) {} + constructor(readonly adapter: SchedulerAdapter, private readonly now: () => Date = () => new Date(),readonly health=new AlarmRuntimeHealthStore(),private readonly guard?:{start(alarm:Alarm):Promise;stop(alarmId?:string):Promise;status?():Promise|GuardStatus}) {} async sync(alarm: Alarm): Promise { const occurrence = nextOccurrenceForAlarm(alarm, this.now()); @@ -76,10 +82,11 @@ export class SchedulerService { async completeOccurrence(alarmId:string,occurrence:Date):Promise{await this.adapter.completeOccurrence?.(alarmId,occurrence);} async remove(alarmId:string):Promise{await this.ensureGuardAbsent(alarmId);try{await this.adapter.remove(alarmId);}catch(error){this.health.record({alarmId,component:'scheduler',healthy:false,message:`Native job removal failed; the alarm definition was retained: ${errorMessage(error)}`});throw error;}this.health.remove(alarmId);} async statusAll(alarms:readonly Alarm[]){return mapConcurrent(alarms,4,async alarm=>{const occurrence=nextOccurrenceForAlarm(alarm,this.now());return{alarmId:alarm.id,nextOccurrence:occurrence?.toISOString()??null,native:occurrence?await this.adapter.status(alarm.id):{installed:false,healthy:true,message:'No enabled future occurrence; no native job is expected.'},health:this.health.get(alarm.id)};});} - async runtimeStatus(alarms:readonly Alarm[]=[]){return{capabilities:this.doctor(),entries:this.health.list(),alarms:await this.statusAll(alarms)};} + async runtimeStatus(alarms:readonly Alarm[]=[]){const [capabilities,status]=await Promise.all([this.adapter.probeCapabilities?.()??this.doctor(),this.statusAll(alarms)]);return{capabilities,entries:this.health.list(),alarms:status};} private async reconcileGuard(alarm:Alarm){if(!this.guard)return;try{const requested=alarm.enabled&&alarm.reliability.keepAwakeUntilAlarm;if(requested)await this.guard.start(alarm);else await this.ensureGuardAbsent(alarm.id);this.health.record({alarmId:alarm.id,component:'power',healthy:true,active:requested,message:requested?'Alarm Guard reconciled.':'Alarm Guard verified absent.'});}catch(error){const message=`Alarm Guard reconciliation failed: ${errorMessage(error)}`;this.health.record({alarmId:alarm.id,component:'power',healthy:false,active:true,message});throw new GuardReconcileError(message);}} - private async ensureGuardAbsent(alarmId:string){if(!this.guard)return;const stopped=await this.guard.stop(alarmId);if(stopped)return;const status=await this.guard.status?.();if(status&&!status.guards.some(item=>item.alarmId===alarmId))return;const message='Alarm Guard teardown could not be verified; the alarm definition was retained for repair.';this.health.record({alarmId,component:'power',healthy:false,active:true,message});throw new GuardReconcileError(message);} + private async ensureGuardAbsent(alarmId:string){if(!this.guard)return;const stopped=await this.guard.stop(alarmId);if(stopped)return;const status=await this.guard.status?.();if(status&&!status.guards.some(item=>item.alarmId===alarmId)&&!status.unresolvedGuards?.some(item=>!item.alarmId||item.alarmId===alarmId))return;const message='Alarm Guard teardown could not be verified; the alarm definition was retained for repair.';this.health.record({alarmId,component:'power',healthy:false,active:true,message});throw new GuardReconcileError(message);} } +type GuardStatus={guards:Array<{alarmId:string}>;unresolvedGuards?:Array<{alarmId?:string}>}; class GuardReconcileError extends Error{} async function settledOperation(work:()=>Promise):Promise<{error?:string}>{try{await work();return{};}catch(error){return{error:errorMessage(error)};}} @@ -93,24 +100,26 @@ export function createSchedulerAdapter(deps: SchedulerDeps = {}): SchedulerAdapt const nodePath = deps.nodePath ?? process.execPath; const cliPath = deps.cliPath ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js'); const env = deps.env ?? process.env; + const host = identifyPlatform({platform, env}); + const policy = nativeAdapters(host); const write = deps.writeFile ?? ((path, contents) => { mkdirSync(dirname(path), {recursive: true}); writeFileSync(path, contents, {encoding: 'utf8', mode: 0o600}); }); const removeFile = deps.removeFile ?? (path => rmSync(path, {force: true})); - const run = deps.run ?? runCommand; - const commandExists = deps.commandExists ?? (command => existsSync(command) || Boolean(process.env.PATH?.split(platform === 'win32' ? ';' : ':').some(path => existsSync(join(path, command))))); - const common = {home, nodePath, cliPath, env, terminal: detectAlarmTerminal(platform, env), write, removeFile, run}; - if (platform === 'darwin') return launchdAdapter(common); - if (platform === 'win32') return windowsAdapter(common); - if (platform === 'linux' && commandExists('systemctl')) return systemdAdapter(common); - return unsupportedAdapter(platform === 'linux' - ? 'User scheduling requires systemd. This Linux session is unsupported; RadioCLI will not claim the alarm is registered.' - : `Alarm scheduling is not supported on ${platform}.`); + const resolve = (command:string) => resolveCommandDetails(command,{platform,env,home}).path; + const run = deps.run ?? ((command,args) => runCommand(resolve(command)??command,args)); + const commandExists = deps.commandExists ?? (command => resolve(command)!==null); + const common = {platform, home, nodePath, cliPath, env, terminal: detectGraphicalTerminal(platform, env), write, removeFile, run}; + if (policy.scheduler === 'launchd') return launchdAdapter(common); + if (policy.scheduler === 'task-scheduler') return windowsAdapter(common); + if (policy.scheduler === 'systemd') { + if (commandExists('systemctl')) return systemdAdapter(common); + return unavailableAdapter('systemctl is unavailable; systemd job registration and removal cannot be verified. Repair artifacts were retained.'); + } + return unsupportedAdapter(`Alarm scheduling is not supported on ${host.id==='unknown'?host.platform:host.id}.`); } -type Common = {home: string; nodePath: string; cliPath: string; env: NodeJS.ProcessEnv; terminal:string; write: (p:string,c:string)=>void; removeFile:(p:string)=>void; run:(c:string,a:string[])=>Promise}; +type Common = {platform: NodeJS.Platform; home: string; nodePath: string; cliPath: string; env: NodeJS.ProcessEnv; terminal:string; write: (p:string,c:string)=>void; removeFile:(p:string)=>void; run:(c:string,a:string[])=>Promise}; function runtimeEnvironment(common:Common):Record{ - const result:Record={RADIOCLI_ALARM_TERMINAL:common.terminal}; - for(const key of ['RADIOCLI_HOME','DISPLAY','WAYLAND_DISPLAY','DBUS_SESSION_BUS_ADDRESS','XDG_RUNTIME_DIR']){const value=common.env[key];if(value)result[key]=value;} - return result; + return launchEnvironment(common.env, {platform: common.platform, includeDesktop: true, terminal: common.terminal}); } const jobName = (id: string) => `io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`; const invocationArgs = (common: Common, id: string, occurrence: Date,internalCommand='internal-run') => [common.cliPath, 'alarm', internalCommand, id, occurrence.toISOString()]; @@ -122,50 +131,159 @@ function launchdAdapter(common: Common): SchedulerAdapter { const occurrenceLabel=(id:string,occurrence:Date)=>`${jobName(id)}.${createHash('sha256').update(occurrence.toISOString()).digest('hex').slice(0,12)}`; const occurrencePath=(id:string,occurrence:Date)=>join(directory,`${occurrenceLabel(id,occurrence)}.plist`); const pathsFor=(id:string)=>{const prefix=`${jobName(id)}.`;let extras:string[]=[];try{extras=readdirSync(directory).filter(name=>name.startsWith(prefix)&&name.endsWith('.plist')).map(name=>join(directory,name));}catch{}return[pathFor(id),...extras];}; - const installJob=async(alarm:Alarm,occurrence:Date,label:string,path:string,replace:boolean)=>{const local=localParts(occurrence);const entries=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`${xml(key)}${xml(value)}`).join('');const envArgs=`EnvironmentVariables${entries}`;const args=[common.nodePath,...invocationArgs(common,alarm.id,occurrence,'internal-launchd')].map(value=>`${xml(value)}`).join('');common.write(path,`\n\nLabel${label}ProgramArguments${args}${envArgs}StartCalendarIntervalMonth${local.month}Day${local.day}Hour${local.hour}Minute${local.minute}ProcessTypeInteractiveStandardOutPath/dev/nullStandardErrorPath/dev/null\n`);if(replace)await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);ensureSuccess(await common.run('launchctl',['bootstrap',`gui/${process.getuid?.()??''}`,path]),'launchctl bootstrap');}; + const installJob=async(alarm:Alarm,occurrence:Date,label:string,path:string,replace:boolean)=>{const local=localParts(occurrence);const entries=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`${xml(key)}${xml(value)}`).join('');const envArgs=`EnvironmentVariables${entries}`;const args=nodeLaunchCommand(common.nodePath,invocationArgs(common,alarm.id,occurrence,'internal-launchd'),runtimeEnvironment(common)).map(value=>`${xml(value)}`).join('');common.write(path,`\n\nLabel${label}ProgramArguments${args}${envArgs}StartCalendarIntervalMonth${local.month}Day${local.day}Hour${local.hour}Minute${local.minute}ProcessTypeInteractiveStandardOutPath/dev/nullStandardErrorPath/dev/null\n`);if(replace)await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);ensureSuccess(await common.run('launchctl',['bootstrap',`gui/${process.getuid?.()??''}`,path]),'launchctl bootstrap');}; + const removeJob=async(path:string)=>{ + const label=path.split('/').at(-1)!.slice(0,-6); + let failure:unknown; + try{ensureSuccess(await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]),'launchctl bootout');}catch(error){failure=error;} + if(failure){ + let status:CommandResult; + try{status=await common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${label}`]);}catch(error){throw new Error(`Unable to verify launchd job removal: ${errorMessage(error)}`);} + if(status.code===0)throw new Error(`launchd job is still loaded: ${errorMessage(failure)}`); + if(!isLaunchdServiceNotFound(status,label))throw new Error(`Unable to verify launchd job absence: ${commandMessage(status)}`); + } + common.removeFile(path); + }; return { - capabilities: () => ({supported: true, exactWake: false, catchUpAfterWake: true, message: 'launchd runs after wake when possible; exact wake is unavailable. Resync after changing the host timezone.'}), + capabilities: () => ({name:'launchd', supported: true, exactWake: false, catchUpAfterWake: true, message: 'launchd runs after wake when possible; exact wake is unavailable. Resync after changing the host timezone.'}), async install(alarm,occurrence){await installJob(alarm,occurrence,jobName(alarm.id),pathFor(alarm.id),true);}, async installFromRunner(alarm,occurrence){const label=occurrenceLabel(alarm.id,occurrence);const existing=await common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${label}`]);if(existing.code===0)return;await installJob(alarm,occurrence,label,occurrencePath(alarm.id,occurrence),false);}, async removeFromRunner(){}, - async completeOccurrence(id,occurrence){const specific=occurrencePath(id,occurrence);const path=existsSync(specific)?specific:pathFor(id);const label=path.split('/').at(-1)!.slice(0,-6);let failure:unknown;try{const result=await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]);if(result.code!==0)failure=new Error(`launchctl bootout failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);}catch(error){failure=error;}if(failure){let status:CommandResult;try{status=await common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${label}`]);}catch(error){throw new Error(`Unable to verify completed launchd job removal: ${errorMessage(error)}`);}if(status.code===0)throw new Error(`Completed launchd job is still loaded: ${errorMessage(failure)}`);if(!isLaunchdServiceNotFound(status,label))throw new Error(`Unable to verify completed launchd job absence: ${(status.stderr||status.stdout||`exit ${status.code}`).trim()}`);}common.removeFile(path);}, - async remove(id){for(const path of pathsFor(id)){await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);common.removeFile(path);}}, + async completeOccurrence(id,occurrence){const specific=occurrencePath(id,occurrence);await removeJob(existsSync(specific)?specific:pathFor(id));}, + async remove(id){for(const path of pathsFor(id))await removeJob(path);}, async status(id){const paths=pathsFor(id).filter(existsSync);const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);if(!paths.length)return{installed:false,healthy:false,message:'LaunchAgent artifact missing'};const results=await Promise.all(paths.map(path=>common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${path.split('/').at(-1)!.slice(0,-6)}`])));const registered=results.some(result=>result.code===0);return{installed:registered,healthy:registered&&executable,message:!registered?'launchd registration missing':!executable?'RadioCLI executable missing':'registered and executable'};} }; } function systemdAdapter(common: Common): SchedulerAdapter { const base = join(common.home,'.config','systemd','user'); const names=(id:string)=>({service:`${jobName(id)}.service`,timer:`${jobName(id)}.timer`}); + const capabilities=():SchedulerCapabilities=>({name:'systemd',supported:true,exactWake:false,catchUpAfterWake:true,message:'systemd tooling detected; user-manager readiness is checked during registration and diagnostics. Persistent user timers can catch up after login/wake; WakeSystem is not claimed without system privileges.'}); + const probeCapabilities=async():Promise=>{ + try{ + const result=await boundedProbe(common.run('systemctl',['--user','show','--property=Version','--value'])); + ensureSuccess(result,'systemd user manager probe'); + return{...capabilities(),message:'The systemd user manager is reachable. Persistent timers can catch up after login/wake; WakeSystem is not claimed without system privileges.'}; + }catch(error){return{...capabilities(),supported:false,catchUpAfterWake:false,message:`The systemd user manager is unavailable: ${errorMessage(error)}`};} + }; return { - capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'systemd user timers catch up after login/wake; WakeSystem is not claimed without system privileges.'}), - async install(alarm, occurrence) { const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=[common.nodePath,...invocationArgs(common,alarm.id,occurrence)].map(systemdQuote).join(' '); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, - async remove(id){const n=names(id); await common.run('systemctl',['--user','disable','--now',n.timer]).catch(()=>undefined); common.removeFile(join(base,n.timer));common.removeFile(join(base,n.service));await common.run('systemctl',['--user','daemon-reload']).catch(()=>undefined);}, - async status(id){const n=names(id);const result=await common.run('systemctl',['--user','is-enabled',n.timer]);const artifact=existsSync(join(base,n.timer))&&existsSync(join(base,n.service));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:result.code===0&&artifact,healthy:result.code===0&&artifact&&executable,message:result.code!==0?'user timer not enabled':!artifact?'systemd unit artifact missing':!executable?'RadioCLI executable missing':'enabled and executable'};} + capabilities, + probeCapabilities, + async install(alarm, occurrence) { const readiness=await probeCapabilities();if(!readiness.supported)throw new Error(readiness.message);const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=systemdExecCommand(nodeLaunchCommand(common.nodePath,invocationArgs(common,alarm.id,occurrence),runtimeEnvironment(common))); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, + async remove(id){ + const n=names(id); + await removeWithVerification(()=>common.run('systemctl',['--user','disable','--now',n.timer]),()=>common.run('systemctl',['--user','show',n.timer,'--property=LoadState','--property=ActiveState']),result=>/^LoadState=not-found\r?$/m.test(result.stdout)&&/^ActiveState=inactive\r?$/m.test(result.stdout),'systemd timer'); + common.removeFile(join(base,n.timer));common.removeFile(join(base,n.service)); + ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload after removal'); + }, + async status(id){const readiness=await probeCapabilities();if(!readiness.supported)return{installed:false,healthy:false,message:readiness.message};const n=names(id);const [enabled,active]=await Promise.all([common.run('systemctl',['--user','is-enabled',n.timer]),common.run('systemctl',['--user','is-active',n.timer])]);const artifact=existsSync(join(base,n.timer))&&existsSync(join(base,n.service));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:enabled.code===0&&artifact,healthy:enabled.code===0&&active.code===0&&artifact&&executable,message:enabled.code!==0?'user timer not enabled':active.code!==0?'user timer is not active':!artifact?'systemd unit artifact missing':!executable?'RadioCLI executable missing':'enabled, active, and executable'};} }; } function windowsAdapter(common: Common): SchedulerAdapter { const xmlPath=(id:string)=>join(common.env.LOCALAPPDATA??join(common.home,'AppData','Local'),'RadioCLI','scheduler',`${jobName(id)}.xml`); return { - capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.'}), - async install(alarm,occurrence){const name=`\\RadioCLI\\${jobName(alarm.id)}`;const command=windowsCommandLine(common.nodePath,invocationArgs(common,alarm.id,occurrence));const environment=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`set "${key}=${cmdEscape(value)}"`).join(' && ');const raw=`${environment} && ${command}`;const body=`${xml(alarm.label)}${windowsLocalBoundary(occurrence)}trueInteractiveTokenLeastPrivilegetrue${alarm.reliability.wakeIfSupported}falsefalsePT0SIgnoreNewcmd.exe/d /s /c "${xml(raw)}"`;const path=xmlPath(alarm.id);common.write(path,body);ensureSuccess(await common.run('schtasks.exe',['/Create','/TN',name,'/XML',path,'/F']),'schtasks create');}, - async remove(id){await common.run('schtasks.exe',['/Delete','/TN',`\\RadioCLI\\${jobName(id)}`,'/F']).catch(()=>undefined);common.removeFile(xmlPath(id));}, + capabilities:()=>({name:'Task Scheduler',supported:true,exactWake:false,catchUpAfterWake:true,message:'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.'}), + async install(alarm, occurrence) { + const name = `\\RadioCLI\\${jobName(alarm.id)}`; + const direct = nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence), runtimeEnvironment(common)); + // Task Scheduler expands %NAME% in Path and Arguments itself. Only its + // standard PowerShell path and fixed flags/encoded data cross that boundary. + const command = '%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + const args = powershellCommand(direct).join(' '); + const body = `${xml(alarm.label)}${windowsLocalBoundary(occurrence)}trueInteractiveTokenLeastPrivilegetrue${alarm.reliability.wakeIfSupported}falsefalsePT0SIgnoreNew${xml(command)}${xml(args)}`; + const path = xmlPath(alarm.id); + common.write(path, body); + ensureSuccess(await common.run('schtasks.exe', ['/Create', '/TN', name, '/XML', path, '/F']), 'schtasks create'); + }, + async remove(id){ + const name=`\\RadioCLI\\${jobName(id)}`; + const removal=await settledOperation(async()=>{ + const result=await boundedWindowsCommand(()=>common.run('schtasks.exe',['/Delete','/TN',name,'/F']),'deletion'); + if(result.code!==0)throw new Error(`Task Scheduler job removal failed: ${windowsCommandMessage(result)}`); + }); + let state:WindowsTaskState; + try{ + const result=await boundedWindowsCommand(()=>common.run('powershell.exe',windowsTaskLookupCommand(name)),'absence lookup'); + state=windowsTaskState(result,name); + }catch(error){throw new Error(`Unable to verify Task Scheduler job removal; repair artifacts were retained. ${removal.error??''} ${errorMessage(error)}`);} + if(state.state!=='absent')throw new Error(`Unable to verify Task Scheduler job absence; repair artifacts were retained. ${removal.error??''} ${state.message}`); + common.removeFile(xmlPath(id)); + }, async status(id){const result=await common.run('schtasks.exe',['/Query','/TN',`\\RadioCLI\\${jobName(id)}`]);const artifact=existsSync(xmlPath(id));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:result.code===0&&artifact,healthy:result.code===0&&artifact&&executable,message:result.code!==0?'task not registered':!artifact?'task XML artifact missing':!executable?'RadioCLI executable missing':'registered and executable'};} }; } +function windowsTaskLookupCommand(taskPath:string):string[]{ + // Only GetTask failures can establish absence. A missing COM service or root + // folder must remain a repairable error, regardless of the UI language. + const encodedPath=Buffer.from(taskPath,'utf8').toString('base64'); + const script=[ + "$ErrorActionPreference='Stop'", + "$ProgressPreference='SilentlyContinue'", + '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)', + `$radiocliTaskPath=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`, + "$radiocliResult=@{taskPath=$radiocliTaskPath;stage='connect';status='error';hresult=0;message=''}", + "try{$radiocliService=New-Object -ComObject 'Schedule.Service';$null=$radiocliService.Connect();$radiocliResult.stage='root';$radiocliFolder=$radiocliService.GetFolder('\\');$radiocliResult.stage='lookup';$null=$radiocliFolder.GetTask($radiocliTaskPath);$radiocliResult.status='present'}catch{$radiocliException=$_.Exception;$radiocliResult.message=$radiocliException.ToString();for($radiocliDepth=0;$null -ne $radiocliException.InnerException -and $radiocliDepth -lt 16;$radiocliDepth++){$radiocliException=$radiocliException.InnerException};$radiocliResult.hresult=$radiocliException.HResult}", + '[Console]::WriteLine(($radiocliResult | ConvertTo-Json -Compress))', + 'exit 0' + ].join(';'); + return ['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')]; +} + +type WindowsTaskState={state:'absent'|'present'|'unknown';message:string}; +function windowsTaskState(result:CommandResult,taskPath:string):WindowsTaskState{ + const unknown=(message:string):WindowsTaskState=>({state:'unknown',message:`${message} ${windowsCommandMessage(result)}`}); + if(result.code!==0)return unknown('Task Scheduler lookup process failed.'); + let value:unknown; + try{value=JSON.parse(result.stdout);}catch{return unknown('Malformed Task Scheduler lookup response.');} + if(!value||typeof value!=='object'||Array.isArray(value))return unknown('Malformed Task Scheduler lookup response.'); + const response=value as Record; + if(response.taskPath!==taskPath||!['connect','root','lookup'].includes(String(response.stage))|| + !['present','error'].includes(String(response.status))||typeof response.hresult!=='number'|| + !Number.isInteger(response.hresult)||response.hresult< -2147483648||response.hresult>2147483647||typeof response.message!=='string'){ + return unknown('Invalid or unrelated Task Scheduler lookup response.'); + } + if(response.status==='present'&&response.stage==='lookup'&&response.hresult===0)return{state:'present',message:'Task Scheduler job is still registered.'}; + // HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND), from the + // exact GetTask lookup. Never interpret schtasks exit 1 or localized text. + if(response.status==='error'&&response.stage==='lookup'&&[-2147024894,-2147024893].includes(response.hresult))return{state:'absent',message:response.message}; + return unknown(`Task Scheduler ${response.stage} HRESULT 0x${(response.hresult>>>0).toString(16).padStart(8,'0')}: ${response.message}`); +} +function windowsCommandMessage(result:CommandResult):string{return [`exit ${result.code}`,result.stderr.trim(),result.stdout.trim()].filter(Boolean).join(' ');} +async function boundedWindowsCommand(work:()=>Promise,label:string):Promise{ + let timer:NodeJS.Timeout|undefined; + try{return await Promise.race([Promise.resolve().then(work),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(`Task Scheduler ${label} timed out.`)),10_000);})]);} + finally{if(timer)clearTimeout(timer);} +} + function unsupportedAdapter(message:string):SchedulerAdapter{return{capabilities:()=>({supported:false,exactWake:false,catchUpAfterWake:false,message}),install:async()=>{throw new Error(message);},remove:async()=>{},status:async()=>({installed:false,healthy:false,message})};} -function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',v=>stdout+=String(v));child.stderr.on('data',v=>stderr+=String(v));child.on('error',e=>resolve({code:127,stdout,stderr:e.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} +function unavailableAdapter(message: string): SchedulerAdapter { + return {...unsupportedAdapter(message), remove: async () => {throw new Error(message);}}; +} +function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true,timeout:10_000});let stdout='';let stderr='';child.stdout.on('data',v=>stdout+=String(v));child.stderr.on('data',v=>stderr+=String(v));child.on('error',e=>resolve({code:127,stdout,stderr:e.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} +async function boundedProbe(probe:Promise):Promise{let timer:NodeJS.Timeout|undefined;try{return await Promise.race([probe,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('User-manager probe timed out.')),3_000);})]);}finally{if(timer)clearTimeout(timer);}} +async function removeWithVerification(remove:()=>Promise,query:()=>Promise,isAbsent:(result:CommandResult)=>boolean,label:string):Promise{ + let failure:unknown; + try{ensureSuccess(await remove(),`${label} removal`);}catch(error){failure=error;} + if(!failure)return; + let result:CommandResult; + try{result=await query();}catch(error){throw new Error(`Unable to verify ${label} removal: ${errorMessage(error)}`);} + if(isAbsent(result))return; + throw new Error(`Unable to verify ${label} absence; repair artifacts were retained. ${errorMessage(failure)} ${commandMessage(result)}`); +} +function commandMessage(result:CommandResult){return(result.stderr||result.stdout||`exit ${result.code}`).trim();} function ensureSuccess(result:CommandResult,label:string){if(result.code!==0)throw new Error(`${label} failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);} function xml(value:string){return value.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');} function systemdQuote(value:string){if(/[\r\n\0]/.test(value))throw new Error('Scheduler arguments cannot contain control characters.');return `"${value.replaceAll('%','%%').replaceAll('\\','\\\\').replaceAll('"','\\"')}"`;} -function cmdEscape(value:string){return value.replaceAll('%','%%').replaceAll('"','""').replaceAll('^','^^').replaceAll('&','^&').replaceAll('|','^|').replaceAll('<','^<').replaceAll('>','^>');} -function windowsCommandLine(command:string,args:string[]){return [command,...args].map(windowsArg).join(' ');} -function windowsArg(value:string){return `"${value.replace(/(\\*)"/g,'$1$1\\"').replace(/(\\+)$/,'$1$1')}"`;} +function systemdExecCommand(values: string[]): string { + // ':' disables $VAR/${VAR} expansion for ExecStart. Environment= has + // different syntax: its dollar signs are already literal and must stay so. + return `:${values.map(systemdQuote).join(' ')}`; +} function localParts(date:Date){return{year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};} function systemdCalendar(date:Date){return `${date.getUTCFullYear()}-${String(date.getUTCMonth()+1).padStart(2,'0')}-${String(date.getUTCDate()).padStart(2,'0')} ${String(date.getUTCHours()).padStart(2,'0')}:${String(date.getUTCMinutes()).padStart(2,'0')}:${String(date.getUTCSeconds()).padStart(2,'0')} UTC`;} function windowsLocalBoundary(date:Date){const p=localParts(date);return `${p.year}-${String(p.month).padStart(2,'0')}-${String(p.day).padStart(2,'0')}T${String(p.hour).padStart(2,'0')}:${String(p.minute).padStart(2,'0')}:${String(p.second).padStart(2,'0')}`;} function unitText(value:string){return value.replace(/[\r\n\0]/g,' ').replaceAll('%','%%').slice(0,200);} function errorMessage(error:unknown){return error instanceof Error?error.message:String(error);} -function isLaunchdServiceNotFound(result:CommandResult,label:string){const output=`${result.stderr}\n${result.stdout}`;return result.code!==0&&output.includes(label)&&/could not find service\b/i.test(output)&&/\bin domain for\b/i.test(output);} +function isLaunchdServiceNotFound(result:CommandResult,label:string){const output=`${result.stderr}\n${result.stdout}`;const match=/could not find service "([^"]+)" in domain for\b/i.exec(output);return result.code!==0&&match?.[1]===label;} async function mapConcurrent(items:readonly T[],limit:number,work:(item:T)=>Promise):Promise{const results=new Array(items.length);let next=0;await Promise.all(Array.from({length:Math.min(limit,items.length)},async()=>{while(true){const index=next;next+=1;if(index>=items.length)return;results[index]=await work(items[index]!);}}));return results;} diff --git a/src/alarms/setup-verification.test.ts b/src/alarms/setup-verification.test.ts index 0af70e9..6f6ff2e 100644 --- a/src/alarms/setup-verification.test.ts +++ b/src/alarms/setup-verification.test.ts @@ -18,4 +18,5 @@ describe('alarm setup verification',()=>{ it('does not claim success when an audible sample cannot be stopped cleanly',async()=>{const {alarm,settings}=fixture();const d=dependencies();d.deps.player=vi.fn(()=>({refreshDetectedBackends:vi.fn(),play:vi.fn(async()=>{}),stop:vi.fn(async()=>new Promise(()=>{}))}));const report=await verifyAlarmSetup(scheduler() as never,alarm,settings,()=>{}, {...d.deps,wait:vi.fn(async()=>{})});expect(report.state).toBe('failed');expect(report.steps.find(step=>step.id==='playback')).toMatchObject({state:'failed'});},5_000); it('attempts cleanup even when native registration reports a hard failure',async()=>{const {alarm,settings}=fixture();const d=dependencies();const s=scheduler({sync:vi.fn(async()=>{throw new Error('registration denied');})});const report=await verifyAlarmSetup(s as never,alarm,settings,()=>{},d.deps);expect(report.state).toBe('failed');expect(s.remove).toHaveBeenCalledOnce();expect(report.steps.find(step=>step.id==='scheduler')).toMatchObject({state:'failed'});expect(report.steps.find(step=>step.id==='cleanup')).toMatchObject({state:'passed'});}); it('requires a configured alarm for the audible station rehearsal while still cleaning up',async()=>{const {settings}=fixture();const d=dependencies();const s=scheduler();const report=await verifyAlarmSetup(s as never,undefined,settings,()=>{},d.deps);expect(report.state).toBe('failed');expect(report.steps.find(step=>step.id==='playback')?.detail).toMatch(/create at least one alarm/i);expect(s.remove).toHaveBeenCalledOnce();}); + it('names the adapter that performed registration instead of inferring one from the host',async()=>{const {alarm,settings}=fixture();const d=dependencies();const s=scheduler({doctor:()=>({supported:true,exactWake:false,catchUpAfterWake:true,name:'Selected test scheduler',message:'ready'})});const report=await verifyAlarmSetup(s as never,alarm,settings,()=>{},d.deps);expect(report.steps.find(step=>step.id==='scheduler')).toMatchObject({state:'passed',detail:'Registered and queried a disposable Selected test scheduler job.'});}); }); diff --git a/src/alarms/setup-verification.ts b/src/alarms/setup-verification.ts index fe58def..51f1415 100644 --- a/src/alarms/setup-verification.ts +++ b/src/alarms/setup-verification.ts @@ -58,7 +58,7 @@ export async function verifyAlarmSetup(scheduler:SchedulerService,alarm:Alarm|un temporary=temporaryAlarm(alarm,now(),deps.id?.()??randomUUID()); const occurrence=await scheduler.sync(temporary);if(!occurrence)throw new Error('The disposable alarm did not receive a future occurrence.');schedulerInstalled=true; const [status]=await scheduler.statusAll([temporary]);if(!status?.native.installed||!status.native.healthy)throw new Error(status?.native.message??'The native job could not be verified.'); - set('scheduler','passed',`Registered and queried a disposable ${platformSchedulerName()} job.`); + set('scheduler','passed',`Registered and queried a disposable ${capability.name??'native scheduler'} job.`); if(alarm?.reliability.wakeIfSupported)set('wake',capability.exactWake?'passed':'warning',capability.exactWake?'This scheduler reports exact wake support.':`The OS accepted the job, but wake remains hardware and policy dependent. ${capability.message}`); else set('wake','passed',capability.catchUpAfterWake?'Wake was not requested; the native scheduler supports catch-up after the machine wakes.':'Wake was not requested for this alarm.'); }catch(error){set('scheduler','failed',messageOf(error));set('wake','warning','Wake behavior could not be assessed because native scheduler verification failed.');} @@ -98,7 +98,6 @@ export async function verifyAlarmSetup(scheduler:SchedulerService,alarm:Alarm|un function temporaryAlarm(source:Alarm|undefined,now:Date,id:string):Alarm{const at=new Date(Math.ceil((now.getTime()+10*60_000)/60_000)*60_000);return{id:`setup-verification-${id}`,label:'RadioCLI setup verification',enabled:true,station:source?.station??{id:'verification',provider:'playlist',name:'RadioCLI verification',tags:[],streamUrl:'https://127.0.0.1/'},schedule:{type:'once',at:at.toISOString()},playback:{volume:source?.playback.volume??40,fadeSeconds:0,stopAfterMinutes:1},reliability:{missedRunGraceMinutes:1,wakeIfSupported:false},createdAt:now.toISOString(),updatedAt:now.toISOString()};} async function probeControlChannel():Promise{const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-verify-'));const file=join(root,'probe.json');const server=await startActiveAlarmSession({alarmId:'setup-verification',scheduledAt:new Date().toISOString(),stationName:'RadioCLI verification',startedAt:new Date().toISOString()},{filePath:file,onDismiss:()=>{},onSnooze:()=>{},onKeepPlaying:()=>{}});try{const client=await connectActiveAlarm(file);if(!client)throw new Error('The ringing control channel was not discoverable.');const status=await client.status();if(status.alarmId!=='setup-verification')throw new Error('The ringing control channel returned the wrong alarm identity.');}finally{await server.close();rmSync(root,{recursive:true,force:true});}} -function platformSchedulerName(){return process.platform==='darwin'?'launchd':process.platform==='win32'?'Task Scheduler':'systemd';} function friendlyTerminal(value:string){return value.replace(/^darwin:/,'').replace(/^win32:/,'').replace(/^linux:/,'');} function messageOf(error:unknown){return error instanceof Error?error.message:String(error);} function wait(milliseconds:number){return new Promise(resolve=>setTimeout(resolve,milliseconds));} diff --git a/src/alarms/system-volume-native.test.ts b/src/alarms/system-volume-native.test.ts new file mode 100644 index 0000000..5c1b2ad --- /dev/null +++ b/src/alarms/system-volume-native.test.ts @@ -0,0 +1,105 @@ +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import {EventEmitter} from 'node:events'; +import {PassThrough} from 'node:stream'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import {createSystemVolumeController} from './system-volume.js'; + +vi.mock('node:child_process',async importOriginal=>({...await importOriginal()})); +vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); +let directory:string; +beforeEach(()=>{directory=fs.mkdtempSync(join(tmpdir(),'radiocli-native-volume-'));}); +afterEach(()=>{vi.restoreAllMocks();fs.rmSync(directory,{recursive:true,force:true});}); + +describe('native system-output command lifetime',()=>{ + it.skipIf(process.platform === 'win32')('waits for an orphan native helper before recovering the saved baseline',async()=>{ + const output=join(directory,'fake-output.json');const gate=join(directory,'gate');const helperFile=join(directory,'helper.json'); + fs.writeFileSync(output,JSON.stringify({volume:20,muted:true})); + const helperSource=` + const fs=require('node:fs');const [output,gate,helperFile,text]=process.argv.slice(1);const args=JSON.parse(text); + if(args.includes('set s to get volume settings')){const state=JSON.parse(fs.readFileSync(output,'utf8'));process.stdout.write(state.volume+','+state.muted);} + else{ + const match=/set volume output volume (\\d+) (with|without) output muted/.exec(args.join(' ')); + const state={volume:Number(match[1]),muted:match[2]==='with'}; + if(state.volume===70){fs.writeFileSync(helperFile,JSON.stringify({pid:process.pid}));const timer=setInterval(()=>{if(fs.existsSync(gate)){fs.writeFileSync(output,JSON.stringify(state));clearInterval(timer);}},10);setTimeout(()=>process.exit(3),8000).unref();} + else fs.writeFileSync(output,JSON.stringify(state)); + } + `; + const source=` + import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module'; + const [directory,output,gate,helperFile]=process.argv.slice(1);const original=cp.spawn; + cp.spawn=(_command,args,options)=>original(process.execPath,['-e',${JSON.stringify(helperSource)},output,gate,helperFile,JSON.stringify(args)],options); + syncBuiltinESMExports(); + const {createSystemVolumeController}=await import(${JSON.stringify(new URL('./system-volume.ts',import.meta.url).href)}); + await createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70); + `; + const parent=childProcess.spawn(process.execPath,['--import','tsx','--input-type=module','-e',source,directory,output,gate,helperFile],{stdio:'ignore',windowsHide:true,timeout:10_000,killSignal:'SIGKILL'}); + const parentClosed=new Promise(resolve=>parent.once('close',()=>resolve())); + let helperPid:number|undefined;let acquired=false;let acquisition:ReturnType['acquireMinimum']>|undefined; + try{ + await until(()=>fs.existsSync(helperFile));helperPid=(JSON.parse(fs.readFileSync(helperFile,'utf8')) as {pid:number}).pid; + expect(JSON.parse(fs.readFileSync(join(directory,'native-write.json'),'utf8'))).toMatchObject({helperPid}); + parent.kill('SIGKILL');await parentClosed;expect(()=>process.kill(helperPid!,0)).not.toThrow(); + const run=async(_command:string,args:string[])=>{ + const before=JSON.parse(fs.readFileSync(output,'utf8')) as {volume:number;muted:boolean}; + if(args.includes('set s to get volume settings'))return{code:0,stdout:`${before.volume},${before.muted}`,stderr:''}; + const match=/set volume output volume (\d+) (with|without) output muted/.exec(args.join(' ')); + fs.writeFileSync(output,JSON.stringify({volume:Number(match![1]),muted:match![2]==='with'}));return{code:0,stdout:'',stderr:''}; + }; + acquisition=createSystemVolumeController('darwin',run,()=>null,{directory,lockTimeoutMs:2_000}).acquireMinimum(80).then(lease=>{acquired=true;return lease;}); + await new Promise(resolve=>setTimeout(resolve,80));expect(acquired).toBe(false); + fs.writeFileSync(gate,'go');const lease=await acquisition; + expect(JSON.parse(fs.readFileSync(output,'utf8'))).toEqual({volume:80,muted:false}); + await lease.release();expect(JSON.parse(fs.readFileSync(output,'utf8'))).toEqual({volume:20,muted:true}); + expect(fs.existsSync(join(directory,'native-write.json'))).toBe(false); + }finally{ + fs.writeFileSync(gate,'go');if(parent.exitCode===null&&parent.signalCode===null)parent.kill('SIGKILL');await parentClosed; + if(acquisition)await acquisition.then(lease=>lease.release()).catch(()=>{}); + if(helperPid)try{process.kill(helperPid,'SIGKILL');}catch{} + } + }); + + it('kills and awaits an owned helper when persisting its PID fails',async()=>{ + const writes:ReturnType[]=[];let saves=0;const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'native-write.json')&&++saves===2)throw new Error('PID disk failure');return rename(source,destination);}); + vi.spyOn(childProcess,'spawn').mockImplementation((_command,args)=>{ + const child=fakeChild();const reading=args?.includes('set s to get volume settings'); + if(reading)queueMicrotask(()=>{child.stdout.write('20,true');child.emit('close',0,null);}); + else{writes.push(child);if(writes.length>1)queueMicrotask(()=>child.emit('close',0,null));} + return child as unknown as childProcess.ChildProcess; + }); + let settled=false;const acquisition=createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70).finally(()=>{settled=true;}); + const rejected=expect(acquisition).rejects.toThrow('PID disk failure'); + await until(()=>writes.length>0);expect(writes[0]!.kill).toHaveBeenCalledWith('SIGKILL'); + expect(settled).toBe(false);expect(fs.existsSync(join(directory,'lock'))).toBe(true); + expect(JSON.parse(fs.readFileSync(join(directory,'native-write.json'),'utf8')).helperPid).toBeNull(); + writes[0]!.emit('close',null,'SIGKILL');await rejected; + expect(fs.existsSync(join(directory,'lock'))).toBe(false);expect(fs.existsSync(join(directory,'native-write.json'))).toBe(false); + }); + + it('clears the native journal when spawning throws before creating a helper',async()=>{ + vi.spyOn(childProcess,'spawn').mockImplementation((_command,args)=>{ + if(!args?.includes('set s to get volume settings'))throw new Error('helper could not start'); + const child=fakeChild();queueMicrotask(()=>{child.stdout.write('20,true');child.emit('close',0,null);});return child as unknown as childProcess.ChildProcess; + }); + await expect(createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70)).rejects.toThrow('helper could not start'); + expect(fs.existsSync(join(directory,'native-write.json'))).toBe(false); + expect(fs.existsSync(join(directory,'state.json'))).toBe(true); + }); + + it('does not start a mutating helper if its intent cannot be journaled',async()=>{ + const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'native-write.json'))throw new Error('native journal disk failure');return rename(source,destination);}); + const spawn=vi.spyOn(childProcess,'spawn').mockImplementation((_command,args)=>{ + expect(args).toContain('set s to get volume settings'); + const child=fakeChild();queueMicrotask(()=>{child.stdout.write('20,true');child.emit('close',0,null);});return child as unknown as childProcess.ChildProcess; + }); + await expect(createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70)).rejects.toThrow('native journal disk failure'); + expect(spawn).toHaveBeenCalledOnce();expect(fs.existsSync(join(directory,'state.json'))).toBe(true); + }); +}); + +function fakeChild(){const child=new EventEmitter() as EventEmitter&{pid:number;stdout:PassThrough;stderr:PassThrough;kill:ReturnType};child.pid=process.pid;child.stdout=new PassThrough();child.stderr=new PassThrough();child.kill=vi.fn(()=>true);return child;} +async function until(predicate:()=>boolean):Promise{const deadline=Date.now()+3_000;while(!predicate()){if(Date.now()>=deadline)throw new Error('Owned fake output helper did not reach the expected state.');await new Promise(resolve=>setTimeout(resolve,10));}} diff --git a/src/alarms/system-volume-ownership.test.ts b/src/alarms/system-volume-ownership.test.ts new file mode 100644 index 0000000..2470559 --- /dev/null +++ b/src/alarms/system-volume-ownership.test.ts @@ -0,0 +1,100 @@ +import * as fs from 'node:fs'; +import {randomUUID} from 'node:crypto'; +import {existsSync,mkdirSync,mkdtempSync,readdirSync,rmSync,utimesSync,writeFileSync} from 'node:fs'; +import {tmpdir,userInfo} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import {defaultSystemVolumeDirectory,SystemVolumeOwnership,volumeProcessAlive} from './system-volume-ownership.js'; + +vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); + +let directory:string; +beforeEach(()=>{directory=mkdtempSync(join(tmpdir(),'radiocli-volume-ownership-'));}); +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();rmSync(directory,{recursive:true,force:true});}); +const missingProcess=99999999; +function ownerMarker(pid=process.pid){return`owner-${pid}-${randomUUID()}`;} +function lockWith(marker:string){const lock=join(directory,'lock');mkdirSync(lock);writeFileSync(join(lock,marker),'');return lock;} +function deadProcessFixture(){vi.spyOn(process,'kill').mockImplementation(pid=>{if(pid===missingProcess)throw Object.assign(new Error('gone'),{code:'ESRCH'});return true;});} + +describe('shared system-volume ownership',()=>{ + it('uses the actual OS account home across library, HOME and XDG overrides',()=>{ + const expected=join(userInfo().homedir,'.radiocli','system-volume'); + vi.stubEnv('RADIOCLI_HOME',join(directory,'profile'));vi.stubEnv('RADIO_ATLAS_HOME',join(directory,'legacy')); + vi.stubEnv('HOME',directory);vi.stubEnv('XDG_RUNTIME_DIR',join(directory,'runtime'));vi.stubEnv('XDG_STATE_HOME',join(directory,'state'));vi.stubEnv('USERPROFILE',directory); + expect(defaultSystemVolumeDirectory()).toBe(expected); + }); + + it('only treats ESRCH as proof that a participant is dead',()=>{ + vi.spyOn(process,'kill').mockImplementationOnce(()=>{throw Object.assign(new Error('denied'),{code:'EPERM'});}).mockImplementationOnce(()=>{throw Object.assign(new Error('unknown'),{code:'EINVAL'});}).mockImplementationOnce(()=>{throw Object.assign(new Error('gone'),{code:'ESRCH'});}); + expect(volumeProcessAlive(1)).toBe(true);expect(volumeProcessAlive(2)).toBe(true);expect(volumeProcessAlive(3)).toBe(false); + }); + + it('does not steal an old lock from a live owner',async()=>{ + const marker=ownerMarker();const lock=lockWith(marker);utimesSync(lock,new Date(0),new Date(0)); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/busy.*live owner/); + expect(work).not.toHaveBeenCalled();expect(readdirSync(lock)).toEqual([marker]); + }); + + it('recovers a dead owner and an empty directory left during cleanup',async()=>{ + deadProcessFixture();lockWith(ownerMarker(missingProcess)); + await expect(new SystemVolumeOwnership(directory,25).transaction(async()=>1)).resolves.toBe(1); + mkdirSync(join(directory,'lock')); + await expect(new SystemVolumeOwnership(directory,25).transaction(async()=>2)).resolves.toBe(2); + expect(existsSync(join(directory,'lock'))).toBe(false); + }); + + it('does not displace a new live generation while reclaiming an observed dead owner',async()=>{ + deadProcessFixture();const stale=ownerMarker(missingProcess);const lock=lockWith(stale);const current=ownerMarker(); + const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{ + if(String(source)===join(lock,stale)){ + fs.unlinkSync(join(lock,stale));fs.rmdirSync(lock);mkdirSync(lock);writeFileSync(join(lock,current),''); + writeFileSync(join(directory,'native-write.json'),JSON.stringify({version:1,ownerPid:process.pid,helperPid:null})); + } + return rename(source,destination); + }); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/busy/); + expect(work).not.toHaveBeenCalled();expect(readdirSync(lock)).toEqual([current]); + }); + + it('keeps an unreadable lock intact instead of allowing independent output mutations',async()=>{ + const lock=lockWith('unknown-owner');const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/lock is unreadable/); + expect(work).not.toHaveBeenCalled();expect(readdirSync(lock)).toEqual(['unknown-owner']); + }); + + it('returns a completed acquisition after cleanup fails and permits the next transaction to recover',async()=>{ + vi.spyOn(fs,'unlinkSync').mockImplementationOnce(()=>{throw Object.assign(new Error('cleanup denied'),{code:'EACCES'});}); + const ownership=new SystemVolumeOwnership(directory,25); + await expect(ownership.transaction(async()=>({lease:'owned'}))).resolves.toEqual({lease:'owned'}); + await expect(ownership.transaction(async()=>2)).resolves.toBe(2); + expect(existsSync(join(directory,'lock'))).toBe(false); + }); + + it('does not reap a dead parent while its recorded native output helper is alive',async()=>{ + deadProcessFixture();const lock=lockWith(ownerMarker(missingProcess)); + const journal=join(directory,'native-write.json');writeFileSync(journal,JSON.stringify({version:1,ownerPid:missingProcess,helperPid:process.pid})); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/helper.*running/i); + expect(work).not.toHaveBeenCalled();expect(existsSync(lock)).toBe(true);expect(existsSync(journal)).toBe(true); + }); + + it('waits for an orphan output helper to exit before recovering its dead parent lock',async()=>{ + let helperAlive=true;const helperPid=88888888; + vi.spyOn(process,'kill').mockImplementation(pid=>{if(pid===missingProcess||pid===helperPid&&!helperAlive)throw Object.assign(new Error('gone'),{code:'ESRCH'});return true;}); + lockWith(ownerMarker(missingProcess));const journal=join(directory,'native-write.json');writeFileSync(journal,JSON.stringify({version:1,ownerPid:missingProcess,helperPid})); + const exited=setTimeout(()=>{helperAlive=false;},15); + try{await new SystemVolumeOwnership(directory,100).transaction(async()=>{expect(helperAlive).toBe(false);});} + finally{clearTimeout(exited);} + expect(existsSync(journal)).toBe(false); + }); + + it.each([JSON.stringify({version:1,ownerPid:missingProcess,helperPid:null}),'unreadable native journal'])('retains ambiguous native work and explains safe recovery: %s',async text=>{ + const journal=join(directory,'native-write.json');writeFileSync(journal,text); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/restart.*native-write\.json/i); + expect(work).not.toHaveBeenCalled();expect(fs.readFileSync(journal,'utf8')).toBe(text); + }); +}); diff --git a/src/alarms/system-volume-ownership.ts b/src/alarms/system-volume-ownership.ts new file mode 100644 index 0000000..0dcd194 --- /dev/null +++ b/src/alarms/system-volume-ownership.ts @@ -0,0 +1,129 @@ +import {randomUUID} from 'node:crypto'; +import {mkdirSync,readFileSync,readdirSync,renameSync,rmSync,rmdirSync,unlinkSync,writeFileSync} from 'node:fs'; +import {userInfo} from 'node:os'; +import {isAbsolute,join} from 'node:path'; + +export type OutputSnapshot={volume:number;muted:boolean}; +export type SystemVolumeState={version:1;generation:string;baseline:OutputSnapshot|null;changed:boolean;participants:Array<{id:string;pid:number;phase:'pending'|'active'}>}; + +/** The output device belongs to the OS account, not a RADIOCLI_HOME/XDG profile. */ +export function defaultSystemVolumeDirectory():string{ + const home=userInfo().homedir; + if(!home||!isAbsolute(home))throw new Error('The OS account home is unavailable for shared output-volume ownership.'); + return join(home,'.radiocli','system-volume'); +} + +/** One short transaction covers both the saved baseline and native mixer writes. */ +export class SystemVolumeOwnership{ + constructor(private readonly directory:string,private readonly timeoutMs=30_000){} + + async transaction(work:()=>Promise):Promise{ + const release=await this.lock(); + try{return await work();} + finally{release();} + } + + read():SystemVolumeState|undefined{ + let text:string; + try{text=readFileSync(join(this.directory,'state.json'),'utf8');} + catch(error){if(codeOf(error)==='ENOENT')return undefined;throw error;} + try{ + const value=JSON.parse(text) as SystemVolumeState; + if(value?.version!==1||!validId(value.generation)||typeof value.changed!=='boolean'||!(value.baseline===null?!value.changed:validSnapshot(value.baseline))||!Array.isArray(value.participants)||!value.participants.every(item=>item&&validId(item.id)&&validPid(item.pid)&&(item.phase==='pending'||item.phase==='active'))||new Set(value.participants.map(item=>item.id)).size!==value.participants.length)throw new Error(); + return value; + }catch{throw new Error('Shared output-volume ownership state is unreadable; the saved output state was left intact.');} + } + + save(value:SystemVolumeState):void{ + const temporary=join(this.directory,`.state-${process.pid}-${randomUUID()}`); + try{writeFileSync(temporary,JSON.stringify(value),{mode:0o600,flag:'wx'});renameSync(temporary,join(this.directory,'state.json'));} + catch(error){try{rmSync(temporary,{force:true});}catch{}throw error;} + } + + clear():void{try{unlinkSync(join(this.directory,'state.json'));}catch(error){if(codeOf(error)!=='ENOENT')throw error;}} + + /** A null PID is intentional: a crash before PID persistence is ambiguous. */ + beginNativeWrite():void{this.saveNativeWrite(null);} + recordNativeWritePid(pid:number):void{if(!validPid(pid))throw new Error('The system-output helper did not report a valid process ID.');this.saveNativeWrite(pid);} + finishNativeWrite():void{try{unlinkSync(join(this.directory,'native-write.json'));}catch(error){if(codeOf(error)!=='ENOENT')throw error;}} + + private saveNativeWrite(helperPid:number|null):void{ + const temporary=join(this.directory,`.native-write-${process.pid}-${randomUUID()}`); + try{writeFileSync(temporary,JSON.stringify({version:1,ownerPid:process.pid,helperPid}),{mode:0o600,flag:'wx'});renameSync(temporary,join(this.directory,'native-write.json'));} + catch(error){try{rmSync(temporary,{force:true});}catch{}throw error;} + } + + private nativeWriteSettled():boolean{ + const path=join(this.directory,'native-write.json');let value:{version?:number;ownerPid?:number;helperPid?:number|null}; + try{value=JSON.parse(readFileSync(path,'utf8')) as typeof value;} + catch(error){if(codeOf(error)==='ENOENT')return true;throw this.unknownNativeWrite(path);} + if(value?.version!==1||!validPid(value.ownerPid)||!validPid(value.helperPid))throw this.unknownNativeWrite(path); + if(volumeProcessAlive(value.helperPid))return false; + // Removal is only allowed after acquiring the lock, so an old observer + // cannot remove a newer owner's journal. + this.finishNativeWrite();return true; + } + + private unknownNativeWrite(path:string):Error{return new Error(`An interrupted system-output helper could not be identified; the saved volume was retained. Restart the computer, then remove ${path} before retrying system output control.`);} + private async waitForNativeWrite(deadline:number):Promise{while(!this.nativeWriteSettled()){if(Date.now()>=deadline)throw new Error('A system-output helper is still running; the saved volume will be recovered after it exits.');await pause();}} + + private async lock():Promise<()=>void>{ + mkdirSync(this.directory,{recursive:true,mode:0o700}); + const marker=`owner-${process.pid}-${randomUUID()}`; + const candidate=join(this.directory,`.lock-${marker}`);const lock=join(this.directory,'lock'); + mkdirSync(candidate,{mode:0o700}); + let published=false; + try{ + writeFileSync(join(candidate,marker),'',{mode:0o600,flag:'wx'}); + const deadline=Date.now()+this.timeoutMs; + while(true){ + let acquired=false; + try{renameSync(candidate,lock);published=true;acquired=true;} + catch(error){if(!['EEXIST','ENOTEMPTY','EPERM','EACCES'].includes(codeOf(error)??''))throw error;} + if(acquired){try{await this.waitForNativeWrite(deadline);}catch(error){finishLock(lock,marker);throw error;}return()=>finishLock(lock,marker);} + if(Date.now()>=deadline)throw new Error('Shared output-volume ownership is busy; its live owner was left intact.'); + let owners:string[]; + try{owners=readdirSync(lock);}catch(error){if(codeOf(error)==='ENOENT'){await pause();continue;}throw error;} + if(owners.length===0){removeEmptyLock(lock);continue;} + const owner=owners[0]!;const match=/^(owner|released)-([1-9]\d*)-([a-f\d-]{36})$/.exec(owner); + if(owners.length!==1||!match||!validPid(Number(match[2]))||!validId(match[3]))throw new Error('Shared output-volume ownership lock is unreadable; it was left intact.'); + if(match[1]==='released'||!volumeProcessAlive(Number(match[2]))){ + // Claim this exact dead generation, never rename/remove a replacement + // lock directory. A reaper that dies is recovered by the same protocol. + const reaper=`owner-${process.pid}-${randomUUID()}`; + try{renameSync(join(lock,owner),join(lock,reaper));} + catch(error){if(codeOf(error)==='ENOENT')continue;throw error;} + try{await this.waitForNativeWrite(deadline);} + catch(error){try{renameSync(join(lock,reaper),join(lock,owner));}catch{finishLock(lock,reaper);}throw error;} + finishLock(lock,reaper);await pause();continue; + } + await pause(); + } + }finally{if(!published)try{rmSync(candidate,{recursive:true,force:true});}catch{}} + } +} + +export function volumeProcessAlive(pid:number):boolean{ + try{process.kill(pid,0);return true;}catch(error){return codeOf(error)!=='ESRCH';} +} +function finishLock(directory:string,marker:string):void{ + const released=`released-${process.pid}-${randomUUID()}`; + try{renameSync(join(directory,marker),join(directory,released));} + catch{try{removeOwnedLock(directory,marker);}catch{}return;} + // Do not lose a returned lease or replace a mixer failure with cleanup errors. + // A published completion can be reclaimed even while this PID remains alive. + try{removeOwnedLock(directory,released);}catch{} +} +function removeOwnedLock(directory:string,marker:string):void{ + try{unlinkSync(join(directory,marker));}catch(error){if(codeOf(error)==='ENOENT')return;throw error;} + removeEmptyLock(directory); +} +function removeEmptyLock(directory:string):void{ + // Another process may already have published a new nonempty generation. + try{rmdirSync(directory);}catch(error){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes(codeOf(error)??''))throw error;} +} +function validPid(value:unknown):value is number{return Number.isSafeInteger(value)&&Number(value)>0;} +function validId(value:unknown):value is string{return typeof value==='string'&&/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/.test(value);} +function validSnapshot(value:unknown):value is OutputSnapshot{if(!value||typeof value!=='object')return false;const state=value as OutputSnapshot;return Number.isInteger(state.volume)&&state.volume>=0&&state.volume<=100&&typeof state.muted==='boolean';} +function codeOf(error:unknown):string|undefined{return(error as NodeJS.ErrnoException)?.code;} +function pause():Promise{return new Promise(resolve=>setTimeout(resolve,20));} diff --git a/src/alarms/system-volume-process.test.ts b/src/alarms/system-volume-process.test.ts new file mode 100644 index 0000000..747d91a --- /dev/null +++ b/src/alarms/system-volume-process.test.ts @@ -0,0 +1,102 @@ +import {spawn} from 'node:child_process'; +import {mkdtempSync,readFileSync,rmSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it} from 'vitest'; + +let directory:string;let mixer:string; +type OutputWorker={held:Promise;call(action:string,fields:Record):Promise;stop():Promise}; +const workers:OutputWorker[]=[]; +beforeEach(()=>{directory=mkdtempSync(join(tmpdir(),'radiocli-volume-process-'));mixer=join(directory,'fake-output.json');writeFileSync(mixer,JSON.stringify({volume:20,muted:true}));}); +afterEach(async()=>{await Promise.all(workers.splice(0).map(worker=>worker.stop()));rmSync(directory,{recursive:true,force:true});}); +function output(){return JSON.parse(readFileSync(mixer,'utf8')) as {volume:number;muted:boolean};} + +describe('system output ownership between real processes',()=>{ + it.each([false,true])('restores once after overlapping processes with separate library profiles end (reverse release: %s)',async reverse=>{ + const [first,second]=await Promise.all([startWorker('first'),startWorker('second')]); + await first.call('acquire',{key:'a',target:40});await second.call('acquire',{key:'b',target:70}); + await (reverse?second:first).call('release',{key:reverse?'b':'a'}); + expect(output()).toEqual({volume:70,muted:false}); + await (reverse?first:second).call('release',{key:reverse?'a':'b'}); + expect(output()).toEqual({volume:20,muted:true}); + expect(readFileSync(join(directory,'writes.jsonl'),'utf8').trim().split('\n').map(line=>JSON.parse(line))).toEqual([{volume:40,muted:false},{volume:70,muted:false},{volume:20,muted:true}]); + }); + + it('serializes simultaneous acquisition and release across processes',async()=>{ + const [first,second]=await Promise.all([startWorker('first'),startWorker('second')]); + await Promise.all([first.call('acquire',{key:'a',target:40}),second.call('acquire',{key:'b',target:70})]); + expect(output()).toEqual({volume:70,muted:false}); + await Promise.all([first.call('release',{key:'a'}),second.call('release',{key:'b'})]); + expect(output()).toEqual({volume:20,muted:true}); + }); + + it('restores the original baseline when an earlier participating process dies',async()=>{ + const first=await startWorker('first');const second=await startWorker('second'); + await first.call('acquire',{key:'a',target:40});await second.call('acquire',{key:'b',target:70}); + await first.stop();expect(output()).toEqual({volume:70,muted:false}); + await second.call('release',{key:'b'});expect(output()).toEqual({volume:20,muted:true}); + }); + + it('recovers a dead mutation owner and its persisted baseline after an interrupted write',async()=>{ + const first=await startWorker('first'); + const interrupted=first.call('acquire',{key:'a',target:70,hold:'write'}).catch(()=>undefined); + await first.held;expect(output()).toEqual({volume:70,muted:false});await first.stop();await interrupted; + const second=await startWorker('second');await second.call('acquire',{key:'b',target:80}); + await second.call('release',{key:'b'});expect(output()).toEqual({volume:20,muted:true}); + }); + + it('preserves a handoff after the original owner exits and another alarm releases',async()=>{ + const first=await startWorker('first');const second=await startWorker('second'); + await first.call('acquire',{key:'a',target:40});await second.call('acquire',{key:'b',target:70}); + await first.call('release',{key:'a',preserve:true});await first.stop(); + await second.call('release',{key:'b'});expect(output()).toEqual({volume:70,muted:false}); + await second.call('acquire',{key:'c',target:90});await second.call('release',{key:'c'}); + expect(output()).toEqual({volume:70,muted:false}); + }); +}); + +async function startWorker(profile:string):Promise{ + const source=` + import {createSystemVolumeController} from ${JSON.stringify(new URL('./system-volume.ts',import.meta.url).href)}; + import {appendFileSync,readFileSync,writeFileSync} from 'node:fs'; + import {join} from 'node:path'; + const [directory,mixer]=process.argv.slice(1);const leases=new Map();let hold; + const run=async(_command,args)=>{ + if(args.includes('set s to get volume settings')){const state=JSON.parse(readFileSync(mixer,'utf8'));return{code:0,stdout:state.volume+','+state.muted,stderr:''};} + const match=/set volume output volume (\\d+) (with|without) output muted/.exec(args.join(' ')); + if(!match)throw new Error('Unexpected fake mixer invocation'); + const state={volume:Number(match[1]),muted:match[2]==='with'}; + writeFileSync(mixer,JSON.stringify(state));appendFileSync(join(directory,'writes.jsonl'),JSON.stringify(state)+'\\n'); + if(hold==='write'){process.send({event:'held'});await new Promise(()=>{});} + return{code:0,stdout:'',stderr:''}; + }; + const controller=createSystemVolumeController('darwin',run,()=>null,{directory}); + process.on('message',async message=>{ + try{ + if(message.action==='acquire'){hold=message.hold;leases.set(message.key,await controller.acquireMinimum(message.target));} + else if(message.action==='release'){await leases.get(message.key).release({preserve:message.preserve});} + else throw new Error('Unknown test action'); + process.send({id:message.id}); + }catch(error){process.send({id:message.id,error:String(error)});} + }); + process.send({event:'ready'}); + `; + const child=spawn(process.execPath,['--import','tsx','--input-type=module','-e',source,directory,mixer],{env:{...process.env,RADIOCLI_HOME:join(directory,profile),XDG_RUNTIME_DIR:join(directory,`${profile}-runtime`)},stdio:['ignore','pipe','pipe','ipc'],windowsHide:true,timeout:15_000,killSignal:'SIGKILL'}); + let stderr='';let readyResolve:()=>void=()=>{};let readyReject:(error:Error)=>void=()=>{};let heldResolve:()=>void=()=>{};let sequence=0; + const ready=new Promise((resolve,reject)=>{readyResolve=resolve;readyReject=reject;}); + const held=new Promise(resolve=>{heldResolve=resolve;}); + const pending=new Map(); + child.stderr!.on('data',data=>{stderr+=String(data);}); + child.on('message',(message:{event?:string;id?:number;error?:string})=>{ + if(message.event==='ready')readyResolve();else if(message.event==='held')heldResolve(); + else if(message.id!==undefined){const request=pending.get(message.id);pending.delete(message.id);if(message.error)request?.reject(new Error(message.error));else request?.resolve();} + }); + child.on('error',error=>{readyReject(error);for(const request of pending.values())request.reject(error);pending.clear();}); + const closed=new Promise(resolve=>child.once('close',()=>{const error=new Error(`Fake output worker exited: ${stderr}`);readyReject(error);for(const request of pending.values())request.reject(error);pending.clear();resolve();})); + const worker={ + held, + call(action:string,fields:Record){return new Promise((resolve,reject)=>{const id=++sequence;pending.set(id,{resolve,reject});child.send({id,action,...fields},error=>{if(error){pending.delete(id);reject(error);}});});}, + async stop(){if(child.exitCode===null&&child.signalCode===null)child.kill('SIGKILL');await closed;} + }; + workers.push(worker);await ready;return worker; +} diff --git a/src/alarms/system-volume.test.ts b/src/alarms/system-volume.test.ts index c09e369..7c83ece 100644 --- a/src/alarms/system-volume.test.ts +++ b/src/alarms/system-volume.test.ts @@ -1,7 +1,151 @@ -import {describe,expect,it,vi} from 'vitest'; -import {createSystemVolumeController} from './system-volume.js'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import * as fs from 'node:fs'; +import {existsSync,mkdirSync,mkdtempSync,readFileSync,rmSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {createSystemVolumeController as createController} from './system-volume.js'; + +vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); + +let directory:string; +beforeEach(()=>{directory=mkdtempSync(join(tmpdir(),'radiocli-volume-'));}); +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();rmSync(directory,{recursive:true,force:true});}); +function createSystemVolumeController(...args:Parameters){return createController(args[0],args[1],args[2],{directory});} + +function fakeOutput(volume=20,muted=true){ + let state={volume,muted};const writes:Array=[]; + const run=vi.fn(async(_command:string,args:string[])=>{ + if(args.includes('set s to get volume settings'))return{code:0,stdout:`${state.volume},${state.muted}`,stderr:''}; + const match=/set volume output volume (\d+) (with|without) output muted/.exec(args.join(' ')); + if(!match)throw new Error('Unexpected fake mixer command'); + state={volume:Number(match[1]),muted:match[2]==='with'};writes.push({...state}); + return{code:0,stdout:'',stderr:''}; + }); + return{run,writes,state:()=>({...state})}; +} describe('alarm system output volume',()=>{ + it.each([false,true])('keeps overlapping alarms audible and restores the shared baseline (reverse release: %s)',async reverse=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + const second=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + await (reverse?second:first).release(); + expect(output.state()).toEqual({volume:70,muted:false}); + await (reverse?first:second).release(); + expect(output.state()).toEqual({volume:20,muted:true}); + expect(output.writes).toEqual([{volume:40,muted:false},{volume:70,muted:false},{volume:20,muted:true}]); + }); + + it('joins an already-loud output lease so another alarm cannot restore underneath it',async()=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + const second=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + await first.release();expect(output.state()).toEqual({volume:70,muted:false}); + await second.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('serializes concurrent acquisitions and duplicate releases',async()=>{ + const output=fakeOutput(); + const [first,second]=await Promise.all([createSystemVolumeController('darwin',output.run).acquireMinimum(40),createSystemVolumeController('darwin',output.run).acquireMinimum(70)]); + await Promise.all([first.release(),first.release(),second.release(),second.release()]); + expect(output.state()).toEqual({volume:20,muted:true}); + expect(output.writes.filter(state=>state.muted)).toHaveLength(1); + }); + + it('preserves handed-off output after remaining alarms end and rebases later raises',async()=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + const second=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + await first.release({preserve:true}); + const third=await createSystemVolumeController('darwin',output.run).acquireMinimum(90); + await second.release();expect(output.state()).toEqual({volume:90,muted:false}); + await third.release();expect(output.state()).toEqual({volume:70,muted:false}); + const fourth=await createSystemVolumeController('darwin',output.run).acquireMinimum(80); + await fourth.release();expect(output.state()).toEqual({volume:70,muted:false}); + }); + + it('rolls a failed overlapping adjustment back to the active alarm output',async()=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + const failRaise=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.join(' ').includes('output volume 70 ')?{...result,code:1,stderr:'partial adjustment'}:result;}; + await expect(createSystemVolumeController('darwin',failRaise).acquireMinimum(70)).rejects.toThrow('partial adjustment'); + expect(output.state()).toEqual({volume:40,muted:false}); + await first.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('retains the original baseline after both an adjustment and its rollback fail',async()=>{ + const output=fakeOutput(); + const failWrites=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.includes('set s to get volume settings')?result:{...result,code:1,stderr:'mixer failed'};}; + await expect(createSystemVolumeController('darwin',failWrites).acquireMinimum(70)).rejects.toThrow(/restoring.*failed/); + expect(existsSync(join(directory,'state.json'))).toBe(true); + const next=await createSystemVolumeController('darwin',output.run).acquireMinimum(80); + await next.release();expect(output.state()).toEqual({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it('recovers a failed final restoration before taking a later alarm baseline',async()=>{ + const output=fakeOutput();let failRestore=true; + const run=async(command:string,args:string[])=>{if(failRestore&&args.join(' ').includes('output volume 20 '))return{code:1,stdout:'',stderr:'restore failed'};return output.run(command,args);}; + const first=await createSystemVolumeController('darwin',run).acquireMinimum(70); + await expect(first.release()).rejects.toThrow('restore failed'); + expect(output.state()).toEqual({volume:70,muted:false}); + failRestore=false; + const second=await createSystemVolumeController('darwin',run).acquireMinimum(80); + await first.release();expect(output.state()).toEqual({volume:80,muted:false}); + await second.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('allows concurrent release retries after a failed final restoration',async()=>{ + const output=fakeOutput();let attempts=0; + const run=async(command:string,args:string[])=>{if(args.join(' ').includes('output volume 20 ')&&++attempts===1)return{code:1,stdout:'',stderr:'retry restore'};return output.run(command,args);}; + const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70); + const results=await Promise.allSettled([lease.release(),lease.release(),lease.release()]); + expect(results.map(result=>result.status).sort()).toEqual(['fulfilled','fulfilled','rejected']); + expect(output.state()).toEqual({volume:20,muted:true});expect(attempts).toBe(2); + }); + + it('does not touch the mixer when saved ownership is corrupt',async()=>{ + const text='{"version":77,"baseline":"unknown"}';writeFileSync(join(directory,'state.json'),text); + const output=fakeOutput();await expect(createSystemVolumeController('darwin',output.run).acquireMinimum(70)).rejects.toThrow(/ownership state is unreadable/); + expect(output.run).not.toHaveBeenCalled();expect(readFileSync(join(directory,'state.json'),'utf8')).toBe(text); + }); + + it('does not raise output if the original state cannot be saved',async()=>{ + const output=fakeOutput(); + const run=async(command:string,args:string[])=>{const result=await output.run(command,args);if(args.includes('set s to get volume settings'))mkdirSync(join(directory,'state.json'));return result;}; + await expect(createSystemVolumeController('darwin',run).acquireMinimum(70)).rejects.toThrow(); + expect(output.writes).toEqual([]);expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('does not leave a live phantom participant when adjustment, rollback and recovery persistence all fail',async()=>{ + const output=fakeOutput();const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + let saves=0;const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'state.json')&&++saves===2)throw new Error('recovery disk failure');return rename(source,destination);}); + const failWrites=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.includes('set s to get volume settings')?result:{...result,code:1,stderr:'mixer failure'};}; + await expect(createSystemVolumeController('darwin',failWrites).acquireMinimum(70)).rejects.toThrow(/mixer failure.*restoring.*mixer failure.*recovery disk failure/); + await first.release();expect(output.state()).toEqual({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it('recovers an unreturned lease while its process lives when rollback succeeds but cleanup persistence fails',async()=>{ + const output=fakeOutput();const unlink=fs.unlinkSync;let failed=false; + vi.spyOn(fs,'unlinkSync').mockImplementation(path=>{if(String(path)===join(directory,'state.json')&&!failed){failed=true;throw new Error('recovery cleanup failure');}return unlink(path);}); + const failRaise=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.join(' ').includes('output volume 70 ')?{...result,code:1,stderr:'raise failed'}:result;}; + await expect(createSystemVolumeController('darwin',failRaise).acquireMinimum(70)).rejects.toThrow(/raise failed.*recovery cleanup failure/); + const next=await createSystemVolumeController('darwin',output.run).acquireMinimum(80); + await next.release();expect(output.state()).toEqual({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it.each([40,80])('rolls back an acquisition whose active ownership commit fails (target: %s)',async target=>{ + const output=fakeOutput();const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + let saves=0;const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'state.json')&&++saves===2)throw new Error('ownership commit failed');return rename(source,destination);}); + await expect(createSystemVolumeController('darwin',output.run).acquireMinimum(target)).rejects.toThrow('ownership commit failed'); + expect(output.state()).toEqual({volume:70,muted:false}); + await first.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + it('unmutes and raises macOS output for an alarm, then restores it',async()=>{const calls:Array<{command:string;args:string[]}>=[];const run=vi.fn(async(command:string,args:string[])=>{calls.push({command,args});return{code:0,stdout:args.includes('set s to get volume settings')?'20,true\n':'',stderr:''};});const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70);expect(calls[1]?.args.join(' ')).toContain('set volume output volume 70 without output muted');await lease.release();expect(calls[2]?.args.join(' ')).toContain('set volume output volume 20 with output muted');}); it('does not lower a device that is already louder than the alarm setting',async()=>{const run=vi.fn(async()=>({code:0,stdout:'85,false\n',stderr:''}));const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70);expect(run).toHaveBeenCalledOnce();await lease.release();expect(run).toHaveBeenCalledOnce();}); @@ -11,4 +155,26 @@ describe('alarm system output volume',()=>{ it('uses Windows Core Audio through built-in PowerShell',async()=>{const run=vi.fn(async(_command:string,args:string[])=>({code:0,stdout:args.at(-1)?.includes('::Get')?'10,true':'',stderr:''}));const controller=createSystemVolumeController('win32',run,command=>command==='powershell.exe'?'powershell.exe':null);const lease=await controller.acquireMinimum(55);const setScript=(run.mock.calls as unknown as Array<[string,string[]]>)[1]?.[1].at(-1)??'';expect(setScript).toContain('[RadioCliVolume]::Set(55, $false)');await lease.release();}); it('fails truthfully when no platform volume backend exists',async()=>{await expect(createSystemVolumeController('linux',vi.fn(),()=>null).acquireMinimum(50)).rejects.toThrow(/unavailable/i);}); + + it('restores the previous volume and mute state when an adjustment only partly succeeds',async()=>{ + const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='set-mute'&&args[2]==='0'?1:0,stdout:args[0]==='get-volume'?'Volume: 0.15 [MUTED]':'',stderr:args[0]==='set-mute'&&args[2]==='0'?'mute denied':''})); + await expect(createSystemVolumeController('linux',run,()=>'/test/wpctl').acquireMinimum(60)).rejects.toThrow(/mute denied/); + expect(run.mock.calls.map(call=>call[1])).toEqual([['get-volume','@DEFAULT_AUDIO_SINK@'],['set-volume','@DEFAULT_AUDIO_SINK@','60%'],['set-mute','@DEFAULT_AUDIO_SINK@','0'],['set-volume','@DEFAULT_AUDIO_SINK@','15%'],['set-mute','@DEFAULT_AUDIO_SINK@','1']]); + }); + + it('reports restoration failure after a partially applied adjustment',async()=>{ + const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='set-mute'||args[2]==='15%'?1:0,stdout:args[0]==='get-volume'?'Volume: 0.15 [MUTED]':'',stderr:args[2]==='15%'?'restore denied':'mute denied'})); + await expect(createSystemVolumeController('linux',run,()=>'/test/wpctl').acquireMinimum(60)).rejects.toThrow(/mute denied.*restor.*restore denied/i); + }); + + it('allows a failed restoration to be retried when the lease is released again',async()=>{ + let restorationAttempts=0; + const run=vi.fn(async(_command:string,args:string[])=>{const restoring=args.some(arg=>arg.includes('set volume output volume 20 '));if(restoring)restorationAttempts+=1;return{code:restoring&&restorationAttempts===1?1:0,stdout:args.includes('set s to get volume settings')?'20,true':'',stderr:'temporary mixer failure'};}); + const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70); + await expect(lease.release()).rejects.toThrow(/temporary mixer failure/);await expect(lease.release()).resolves.toBeUndefined();await lease.release();expect(restorationAttempts).toBe(2); + }); + + it('does not control a Termux mixer through the Linux desktop adapter',async()=>{vi.stubEnv('TERMUX_VERSION','0.118.3');const run=vi.fn();const resolve=vi.fn(()=>'/test/wpctl');await expect(createSystemVolumeController('linux',run,resolve).acquireMinimum(50)).rejects.toThrow(/unavailable.*termux/i);expect(resolve).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();}); + + it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('reports unavailable output-volume control on %s without commands',async platform=>{const run=vi.fn();const resolve=vi.fn(()=>'/test/wpctl');await expect(createSystemVolumeController(platform,run,resolve).acquireMinimum(50)).rejects.toThrow(/unavailable/i);expect(resolve).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();}); }); diff --git a/src/alarms/system-volume.ts b/src/alarms/system-volume.ts index 7f2c340..4c38292 100644 --- a/src/alarms/system-volume.ts +++ b/src/alarms/system-volume.ts @@ -1,28 +1,95 @@ -import {spawn} from 'node:child_process'; -import {resolveCommand} from '../player/command.js'; +import {spawn,type ChildProcessByStdio} from 'node:child_process'; +import {randomUUID} from 'node:crypto'; +import type {Readable} from 'node:stream'; +import {resolveCommand} from '../platform/executables.js'; +import {identifyPlatform,nativeAdapters} from '../platform/runtime.js'; +import {defaultSystemVolumeDirectory,SystemVolumeOwnership,volumeProcessAlive,type OutputSnapshot,type SystemVolumeState} from './system-volume-ownership.js'; -type Snapshot={volume:number;muted:boolean}; +type Snapshot=OutputSnapshot; type Result={code:number;stdout:string;stderr:string}; type Run=(command:string,args:string[])=>Promise; -export type SystemVolumeLease={release():Promise;message:string}; +export type SystemVolumeLease={release(options?:{preserve?:boolean}):Promise;message:string}; export type SystemVolumeController={acquireMinimum(volume:number):Promise}; -export function createSystemVolumeController(platform:NodeJS.Platform=process.platform,run:Run=runCommand,resolve:(command:string)=>string|null=resolveCommand):SystemVolumeController{ - const backend=platform==='darwin'?macBackend(run):platform==='linux'?linuxBackend(run,resolve):platform==='win32'?windowsBackend(run,resolve):undefined; - return{async acquireMinimum(volume){if(!backend)throw new Error(`System output volume control is unavailable on ${platform}.`);const target=clamp(volume);const before=await backend.read();const applied=Math.max(before.volume,target);if(before.muted||before.volumestring|null=resolveCommand,options:{directory?:string;lockTimeoutMs?:number}={}):SystemVolumeController{ + const host=identifyPlatform({platform});const adapter=nativeAdapters(host).volume; + const backend=adapter==='macos'?macBackend(run):adapter==='unix-audio'?linuxBackend(run,resolve):adapter==='windows'?windowsBackend(run,resolve):undefined; + return{async acquireMinimum(volume){ + if(!backend)throw new Error(`System output volume control is unavailable on ${host.id==='unknown'?platform:host.id}.`); + const ownership=new SystemVolumeOwnership(options.directory??defaultSystemVolumeDirectory(),options.lockTimeoutMs); + const write=(value:Snapshot)=>backend.write(value,run===runCommand?(command,args)=>runCommand(command,args,ownership):run); + return ownership.transaction(async()=>{ + let state=ownership.read(); + if(state){ + state.participants=state.participants.filter(activeParticipant); + if(state.participants.length===0){ + // A crashed owner or failed final restore must not turn the raised + // output into the next alarm's baseline. + if(state.changed&&state.baseline)await write(state.baseline); + ownership.clear();state=undefined; + } + } + const target=clamp(volume);const before=await backend.read();const changed=before.muted||before.volumeitem.id===id?{...item,phase:'active'}:item)}); + }catch(error){ + let restoreError:unknown; + if(changed)try{await write(before);}catch(cause){restoreError=cause;} + try{ + if(restoreError)ownership.save({...joined,participants:joined.participants.filter(item=>item.id!==id)}); + else if(prior)ownership.save(prior);else ownership.clear(); + }catch(saveError){throw new Error(`${messageOf(error)}${restoreError?`; restoring the previous output state also failed: ${messageOf(restoreError)}`:''}; output-volume recovery state could not be updated: ${messageOf(saveError)}`);} + if(restoreError)throw new Error(`${messageOf(error)}; restoring the previous output state also failed: ${messageOf(restoreError)}`); + throw error; + } + let released=false; + return{ + message:changed?`Local output raised to at least ${target}% and unmuted for the alarm.`:`Local output was already at least ${target}% and unmuted.`, + async release({preserve=false}={}){ + if(released)return; + await ownership.transaction(async()=>{ + if(released)return; + const current=ownership.read(); + if(!current||current.generation!==joined.generation){released=true;return;} + current.participants=current.participants.filter(item=>item.id!==id&&activeParticipant(item)); + if(preserve){ + // Handoff commits the current output as the user's new baseline. + // A later acquisition samples it before making another adjustment. + current.baseline=null;current.changed=false; + } + ownership.save(current); + if(current.participants.length===0){ + if(current.changed&¤t.baseline)await write(current.baseline); + ownership.clear(); + } + released=true; + }); + } + }; + }); + }}; } -type Backend={read():Promise;write(value:Snapshot):Promise}; -function macBackend(run:Run):Backend{return{async read(){const result=await checked(run('/usr/bin/osascript',['-e','set s to get volume settings','-e','return (output volume of s as text) & "," & (output muted of s as text)']),'read macOS output volume');const match=/^(\d+)\s*,\s*(true|false)/i.exec(result.stdout.trim());if(!match)throw new Error('macOS returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value){await checked(run('/usr/bin/osascript',['-e',`set volume output volume ${clamp(value.volume)} ${value.muted?'with':'without'} output muted`]),'set macOS output volume');}};} +function activeParticipant(item:SystemVolumeState['participants'][number]):boolean{return item.phase==='active'&&volumeProcessAlive(item.pid);} + +type Backend={read():Promise;write(value:Snapshot,execute?:Run):Promise}; +function macBackend(run:Run):Backend{return{async read(){const result=await checked(run('/usr/bin/osascript',['-e','set s to get volume settings','-e','return (output volume of s as text) & "," & (output muted of s as text)']),'read macOS output volume');const match=/^(\d+)\s*,\s*(true|false)/i.exec(result.stdout.trim());if(!match)throw new Error('macOS returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value,execute=run){await checked(execute('/usr/bin/osascript',['-e',`set volume output volume ${clamp(value.volume)} ${value.muted?'with':'without'} output muted`]),'set macOS output volume');}};} function linuxBackend(run:Run,resolve:(command:string)=>string|null):Backend|undefined{ - const wpctl=resolve('wpctl');if(wpctl)return{async read(){const result=await checked(run(wpctl,['get-volume','@DEFAULT_AUDIO_SINK@']),'read PipeWire output volume');const match=/Volume:\s*([\d.]+)/i.exec(result.stdout);if(!match)throw new Error('wpctl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])*100),muted:/\[MUTED\]/i.test(result.stdout)};},async write(value){await checked(run(wpctl,['set-volume','@DEFAULT_AUDIO_SINK@',`${clamp(value.volume)}%`]),'set PipeWire output volume');await checked(run(wpctl,['set-mute','@DEFAULT_AUDIO_SINK@',value.muted?'1':'0']),'set PipeWire mute state');}}; - const pactl=resolve('pactl');if(pactl)return{async read(){const [volume,mute]=await Promise.all([checked(run(pactl,['get-sink-volume','@DEFAULT_SINK@']),'read PulseAudio output volume'),checked(run(pactl,['get-sink-mute','@DEFAULT_SINK@']),'read PulseAudio mute state')]);const match=/(\d+)%/.exec(volume.stdout);if(!match)throw new Error('pactl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:/yes/i.test(mute.stdout)};},async write(value){await checked(run(pactl,['set-sink-volume','@DEFAULT_SINK@',`${clamp(value.volume)}%`]),'set PulseAudio output volume');await checked(run(pactl,['set-sink-mute','@DEFAULT_SINK@',value.muted?'1':'0']),'set PulseAudio mute state');}}; - const amixer=resolve('amixer');if(amixer)return{async read(){const result=await checked(run(amixer,['get','Master']),'read ALSA output volume');const matches=[...result.stdout.matchAll(/\[(\d+)%\]/g)];const last=matches.at(-1);if(!last)throw new Error('amixer returned an unreadable output-volume state.');return{volume:clamp(Number(last[1])),muted:/\[off\]/i.test(result.stdout)};},async write(value){await checked(run(amixer,['set','Master',`${clamp(value.volume)}%`,value.muted?'mute':'unmute']),'set ALSA output volume');}}; + const wpctl=resolve('wpctl');if(wpctl)return{async read(){const result=await checked(run(wpctl,['get-volume','@DEFAULT_AUDIO_SINK@']),'read PipeWire output volume');const match=/Volume:\s*([\d.]+)/i.exec(result.stdout);if(!match)throw new Error('wpctl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])*100),muted:/\[MUTED\]/i.test(result.stdout)};},async write(value,execute=run){await checked(execute(wpctl,['set-volume','@DEFAULT_AUDIO_SINK@',`${clamp(value.volume)}%`]),'set PipeWire output volume');await checked(execute(wpctl,['set-mute','@DEFAULT_AUDIO_SINK@',value.muted?'1':'0']),'set PipeWire mute state');}}; + const pactl=resolve('pactl');if(pactl)return{async read(){const [volume,mute]=await Promise.all([checked(run(pactl,['get-sink-volume','@DEFAULT_SINK@']),'read PulseAudio output volume'),checked(run(pactl,['get-sink-mute','@DEFAULT_SINK@']),'read PulseAudio mute state')]);const match=/(\d+)%/.exec(volume.stdout);if(!match)throw new Error('pactl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:/yes/i.test(mute.stdout)};},async write(value,execute=run){await checked(execute(pactl,['set-sink-volume','@DEFAULT_SINK@',`${clamp(value.volume)}%`]),'set PulseAudio output volume');await checked(execute(pactl,['set-sink-mute','@DEFAULT_SINK@',value.muted?'1':'0']),'set PulseAudio mute state');}}; + const amixer=resolve('amixer');if(amixer)return{async read(){const result=await checked(run(amixer,['get','Master']),'read ALSA output volume');const matches=[...result.stdout.matchAll(/\[(\d+)%\]/g)];const last=matches.at(-1);if(!last)throw new Error('amixer returned an unreadable output-volume state.');return{volume:clamp(Number(last[1])),muted:/\[off\]/i.test(result.stdout)};},async write(value,execute=run){await checked(execute(amixer,['set','Master',`${clamp(value.volume)}%`,value.muted?'mute':'unmute']),'set ALSA output volume');}}; return undefined; } -function windowsBackend(run:Run,resolve:(command:string)=>string|null):Backend|undefined{const powershell=resolve('powershell.exe')??resolve('pwsh.exe');if(!powershell)return undefined;return{async read(){const result=await checked(run(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Get()`]),'read Windows output volume');const match=/^(\d+)\s*,\s*(true|false)/im.exec(result.stdout);if(!match)throw new Error('Windows returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value){await checked(run(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted?'true':'false'})`]),'set Windows output volume');}};} +function windowsBackend(run:Run,resolve:(command:string)=>string|null):Backend|undefined{const powershell=resolve('powershell.exe')??resolve('pwsh.exe');if(!powershell)return undefined;return{async read(){const result=await checked(run(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Get()`]),'read Windows output volume');const match=/^(\d+)\s*,\s*(true|false)/im.exec(result.stdout);if(!match)throw new Error('Windows returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value,execute=run){await checked(execute(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted?'true':'false'})`]),'set Windows output volume');}};} const windowsCoreAudio=String.raw`Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; @@ -34,5 +101,24 @@ public enum EDataFlow { eRender, eCapture, eAll } public enum ERole { eConsole, public static class RadioCliVolume { static IAudioEndpointVolume Endpoint(){IMMDevice d;((IMMDeviceEnumerator)new MMDeviceEnumerator()).GetDefaultAudioEndpoint(EDataFlow.eRender,ERole.eMultimedia,out d);object o;Guid id=typeof(IAudioEndpointVolume).GUID;d.Activate(ref id,23,IntPtr.Zero,out o);return(IAudioEndpointVolume)o;} public static string Get(){float v;bool m;var e=Endpoint();e.GetMasterVolumeLevelScalar(out v);e.GetMute(out m);return Math.Round(v*100)+","+m.ToString().ToLower();} public static void Set(int v,bool m){var e=Endpoint();e.SetMasterVolumeLevelScalar(Math.Max(0,Math.Min(100,v))/100f,Guid.Empty);e.SetMute(m,Guid.Empty);} } '@`; function clamp(value:number){return Math.max(0,Math.min(100,Math.round(Number.isFinite(value)?value:0)));} +function messageOf(error:unknown){return error instanceof Error?error.message:String(error);} async function checked(promise:Promise,label:string){const result=await promise;if(result.code!==0)throw new Error(`${label} failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);return result;} -function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',value=>stdout+=String(value));child.stderr.on('data',value=>stderr+=String(value));child.on('error',error=>resolve({code:127,stdout,stderr:error.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} +function runCommand(command:string,args:string[],ownership?:SystemVolumeOwnership):Promise{ + return new Promise(resolve=>{ + ownership?.beginNativeWrite(); + let child:ChildProcessByStdio; + try{child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true,timeout:10_000,killSignal:'SIGKILL'});} + catch(error){let message=messageOf(error);try{ownership?.finishNativeWrite();}catch(cleanupError){message+=`; system-output command journal cleanup failed: ${messageOf(cleanupError)}`;}resolve({code:127,stdout:'',stderr:message});return;} + let stdout='';let stderr='';let failure:string|undefined; + child.stdout.on('data',value=>stdout+=String(value));child.stderr.on('data',value=>stderr+=String(value)); + // Spawn/kill errors do not prove the helper has closed. Keep ownership until + // close, including when recording its PID fails after the process starts. + child.on('error',error=>{failure=error.message;}); + child.on('close',(code,signal)=>{ + try{ownership?.finishNativeWrite();}catch(error){failure=`${failure?`${failure}; `:''}system-output command journal cleanup failed: ${messageOf(error)}`;} + resolve({code:failure?1:code??1,stdout,stderr:failure||stderr||(signal?'The output-volume command was interrupted or timed out.':'')}); + }); + if(ownership&&child.pid)try{ownership.recordNativeWritePid(child.pid);} + catch(error){failure=`The system-output helper PID could not be saved: ${messageOf(error)}`;try{child.kill('SIGKILL');}catch(killError){failure+=`; stopping the helper failed: ${messageOf(killError)}`;}} + }); +} diff --git a/src/alarms/terminal-launcher.test.ts b/src/alarms/terminal-launcher.test.ts index 02e6a26..ad6e368 100644 --- a/src/alarms/terminal-launcher.test.ts +++ b/src/alarms/terminal-launcher.test.ts @@ -1,35 +1,69 @@ import {EventEmitter} from 'node:events'; import {spawn as nodeSpawn,type ChildProcess} from 'node:child_process'; +import {connect, type Socket} from 'node:net'; +import {homedir} from 'node:os'; import {describe,expect,it,vi} from 'vitest'; -import {detectAlarmTerminal,openAlarmControls,prepareAlarmTerminalAccess,verifyAlarmTerminalLaunch} from './terminal-launcher.js'; +import {openAlarmControls,prepareAlarmTerminalAccess,verifyAlarmTerminalLaunch} from './terminal-launcher.js'; +import {detectGraphicalTerminal} from '../platform/terminals.js'; function spawnRecorder(){ const calls:Array<{command:string;args:readonly string[]}>=[]; const spawn=vi.fn((command:string,args:readonly string[])=>{calls.push({command,args});const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();queueMicrotask(()=>child.emit('spawn'));return child;}); return{calls,spawn}; } +function powershellInvocation(args: readonly string[]): {command: string; args: string[]} { + const index = args.indexOf('-EncodedCommand'); + expect(index).toBeGreaterThanOrEqual(0); + const script = Buffer.from(args[index + 1]!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(encoded).toBeDefined(); + return JSON.parse(Buffer.from(encoded!, 'base64').toString('utf8')); +} +function bootstrapPayload(args: readonly string[]): {args: string[]; environment: Record} { + return JSON.parse(Buffer.from(args.at(-1)!, 'base64url').toString('utf8')); +} describe('alarm terminal launcher',()=>{ it('remembers recognizable terminal emulators per platform',()=>{ - expect(detectAlarmTerminal('darwin',{TERM_PROGRAM:'iTerm.app'})).toBe('darwin:iterm'); - expect(detectAlarmTerminal('darwin',{})).toBe('darwin:apple-terminal'); - expect(detectAlarmTerminal('win32',{WT_SESSION:'1'})).toBe('win32:windows-terminal'); - expect(detectAlarmTerminal('win32',{})).toBe('win32:console'); + expect(detectGraphicalTerminal('darwin',{TERM_PROGRAM:'iTerm.app'})).toBe('darwin:iterm'); + expect(detectGraphicalTerminal('darwin',{})).toBe('darwin:apple-terminal'); + expect(detectGraphicalTerminal('win32',{WT_SESSION:'1'})).toBe('win32:windows-terminal'); + expect(detectGraphicalTerminal('win32',{})).toBe('win32:console'); }); it('opens Apple Terminal with RadioCLI and its saved data home',async()=>{ const recorded=spawnRecorder(); const result=await openAlarmControls({platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal',RADIOCLI_HOME:"/Data/O'Brien"},nodePath:'/usr/bin/node',cliPath:'/app/cli.js',spawn:recorded.spawn}); - expect(result.opened).toBe(true); + expect(result).toMatchObject({opened:false,requested:true,message:expect.stringMatching(/requested.*unverified/i)}); expect(recorded.calls[0]?.command).toBe('/usr/bin/osascript'); - expect(recorded.calls[0]?.args.at(-1)).toContain("RADIOCLI_HOME='/Data/O'\\''Brien'"); - expect(recorded.calls[0]?.args.at(-1)).toContain("'/app/cli.js'"); + const shell=recorded.calls[0]!.args.at(-1)!; + expect(shell.startsWith("'/usr/bin/node' '-e' ")).toBe(true); + const encoded=/'([A-Za-z0-9_-]+)'$/.exec(shell)?.[1];expect(encoded).toBeDefined(); + expect(bootstrapPayload([encoded!])).toEqual({args:['/app/cli.js'],environment:{RADIOCLI_HOME:"/Data/O'Brien"}}); }); - it('opens the saved Windows Terminal profile in a new RadioCLI tab',async()=>{ + it('reports opened only after the new TUI becomes observable',async()=>{const recorded=spawnRecorder();const hasLiveTui=vi.fn().mockReturnValueOnce(false).mockReturnValue(true);const result=await openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,hasLiveTui,timeoutMs:100});expect(result).toMatchObject({opened:true,requested:true});expect(hasLiveTui).toHaveBeenCalledTimes(2);}); + + it('distinguishes an accepted launch request from a TUI that never appears',async()=>{const recorded=spawnRecorder();const result=await openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,hasLiveTui:()=>false,timeoutMs:20});expect(result).toMatchObject({opened:false,requested:true,message:expect.stringMatching(/requested.*did not become ready/i)});}); + + it('rejects native launcher failure even when a process was spawned',async()=>{const launch=vi.fn(()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',1);});return child;});await expect(openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:launch})).rejects.toThrow(/terminal.*exit|launcher.*exit/i);}); + + it('requests the saved Windows Terminal with its literal data home in a new tab',async()=>{ const recorded=spawnRecorder(); - await openAlarmControls({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:windows-terminal'},nodePath:'C:\\Node\\node.exe',cliPath:'C:\\RadioCLI\\cli.js',spawn:recorded.spawn}); - expect(recorded.calls[0]).toEqual({command:'wt.exe',args:['-w','new','new-tab','--title','RadioCLI Alarm','C:\\Node\\node.exe','C:\\RadioCLI\\cli.js']}); + const home='C:\\Radio %PATH%! & "quotes" 单播'; + await openAlarmControls({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:windows-terminal',RADIOCLI_HOME:home},nodePath:'C:\\Node\\node.exe',cliPath:'C:\\RadioCLI\\cli.js',spawn:recorded.spawn,resolve:command=>command}); + expect(recorded.calls[0]?.command).toBe('wt.exe'); + expect(recorded.calls[0]?.args.slice(0,7)).toEqual(['-w','new','new-tab','--title','RadioCLI Alarm','powershell.exe','-NoLogo']); + const invocation=powershellInvocation(recorded.calls[0]!.args); + expect(invocation.command).toBe('C:\\Node\\node.exe'); + expect(bootstrapPayload(invocation.args)).toEqual({args:['C:\\RadioCLI\\cli.js'],environment:{RADIOCLI_HOME:home,...(process.platform==='win32'?{USERPROFILE:homedir()}:{})}}); + }); + + it.each(['freebsd','openbsd','netbsd'] as const)('requests an installed graphical terminal on %s',async platform=>{ + const recorded=spawnRecorder();const result=await openAlarmControls({platform,env:{DISPLAY:':0',TERMINAL:'xterm'},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined}); + expect(result).toMatchObject({opened:false,requested:true,terminal:`${platform}:/usr/local/bin/xterm`}); + expect(recorded.calls[0]!.command).toBe('/usr/local/bin/xterm');expect(recorded.calls[0]!.args.slice(0,3)).toEqual(['-e','/node','-e']); + expect(bootstrapPayload(recorded.calls[0]!.args)).toEqual({args:['/cli.js'],environment:{}}); }); it('does not open a second terminal when a RadioCLI TUI is already live',async()=>{ @@ -43,7 +77,25 @@ describe('alarm terminal launcher',()=>{ it('surfaces a denied macOS terminal permission without affecting other platforms',async()=>{const spawn=vi.fn(()=>{const child=new EventEmitter() as ChildProcess;queueMicrotask(()=>child.emit('close',1));return child;});await expect(prepareAlarmTerminalAccess({platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal'},spawn})).rejects.toThrow(/permission.*automatic ringing controls/i);await expect(prepareAlarmTerminalAccess({platform:'win32',spawn})).resolves.toBeUndefined();expect(spawn).toHaveBeenCalledOnce();}); - it('rejects setup verification when no graphical terminal can expose ringing controls',async()=>{await expect(verifyAlarmTerminalLaunch({platform:'freebsd',timeoutMs:10})).rejects.toThrow(/no supported graphical terminal/i);}); + it('rejects setup verification when no graphical terminal can expose ringing controls',async()=>{await expect(verifyAlarmTerminalLaunch({platform:'freebsd',env:{},timeoutMs:10})).rejects.toThrow(/no supported graphical terminal/i);}); - it('requires the launched terminal process to complete an authenticated callback',async()=>{const launch=vi.fn((_command:string,args:readonly string[])=>{const index=args.indexOf(process.execPath);expect(index).toBeGreaterThanOrEqual(0);return nodeSpawn(process.execPath,args.slice(index+1),{stdio:'ignore'});});await expect(verifyAlarmTerminalLaunch({platform:'win32',env:{WT_SESSION:'test'},nodePath:process.execPath,spawn:launch,timeoutMs:2_000})).resolves.toBe('win32:windows-terminal');expect(launch).toHaveBeenCalledOnce();}); + it('requires the launched terminal process to complete an authenticated callback',async()=>{const launch=vi.fn((_command:string,args:readonly string[])=>{const invocation=powershellInvocation(args);expect(invocation.command).toBe(process.execPath);expect(args).not.toContain('-NoExit');return nodeSpawn(invocation.command,invocation.args,{stdio:'ignore'});});await expect(verifyAlarmTerminalLaunch({platform:'win32',env:{WT_SESSION:'test'},nodePath:process.execPath,spawn:launch,resolve:command=>command,timeoutMs:2_000})).resolves.toBe('win32:windows-terminal');expect(launch).toHaveBeenCalledOnce();}); + + it('bounds and closes a callback connection that never sends its token', async () => { + let socket: Socket | undefined; + const launch = vi.fn((_command: string, args: readonly string[]) => { + const child = new EventEmitter() as ChildProcess; + child.unref = vi.fn(); + const probeArgs = bootstrapPayload(powershellInvocation(args).args).args; + const port = Number(probeArgs.at(-3)); + const host = probeArgs.at(-1)!; + socket = connect(port, host); + socket.on('error', () => undefined); + queueMicrotask(() => child.emit('spawn')); + return child; + }); + try { + await expect(verifyAlarmTerminalLaunch({platform: 'win32', env: {WT_SESSION: 'test'}, spawn: launch, resolve: command => command, timeoutMs: 30})).rejects.toThrow(/did not connect back/i); + } finally { socket?.destroy(); } + }); }); diff --git a/src/alarms/terminal-launcher.ts b/src/alarms/terminal-launcher.ts index c123004..e4a96e1 100644 --- a/src/alarms/terminal-launcher.ts +++ b/src/alarms/terminal-launcher.ts @@ -1,76 +1,45 @@ import {spawn, type ChildProcess} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {createServer} from 'node:net'; +import {createServer, type Socket} from 'node:net'; import {randomBytes} from 'node:crypto'; -import {basename, delimiter, join} from 'node:path'; +import {listenLoopback} from '../platform/loopback.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; +import {detectGraphicalTerminal, launchTerminalCommand, type TerminalOptions} from '../platform/terminals.js'; -export type AlarmTerminalLaunchResult = {opened: boolean; terminal: string; message: string}; -type Spawn = (command: string, args: readonly string[]) => ChildProcess; -type LaunchOptions = { - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; +export type AlarmTerminalLaunchResult = {opened: boolean; requested?: boolean; terminal: string; message: string}; +type LaunchOptions = TerminalOptions & { nodePath: string; cliPath: string; - spawn?: Spawn; hasLiveTui?: () => boolean; + timeoutMs?: number; }; -type PermissionOptions={platform?:NodeJS.Platform;env?:NodeJS.ProcessEnv;spawn?:Spawn;permissionTimeoutMs?:number}; -type ProbeOptions=PermissionOptions&{nodePath?:string;timeoutMs?:number}; +type PermissionOptions = TerminalOptions & {permissionTimeoutMs?: number}; +type ProbeOptions = PermissionOptions & {nodePath?: string; timeoutMs?: number}; -const linuxTerminals = new Set(['alacritty','foot','ghostty','gnome-terminal','kitty','konsole','mate-terminal','qterminal','terminator','tilix','wezterm','xfce4-terminal','x-terminal-emulator']); - -export function detectAlarmTerminal(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): string { - const configured = env.RADIOCLI_ALARM_TERMINAL?.trim(); - if (configured && validDescriptor(configured, platform)) return configured; - if (platform === 'darwin') { - const program = `${env.TERM_PROGRAM ?? ''} ${env.__CFBundleIdentifier ?? ''}`.toLowerCase(); - if (program.includes('iterm')) return 'darwin:iterm'; - if (program.includes('wezterm')) return 'darwin:wezterm'; - if (program.includes('ghostty')) return 'darwin:ghostty'; - if (program.includes('kitty')) return 'darwin:kitty'; - return 'darwin:apple-terminal'; - } - if (platform === 'win32') return env.WT_SESSION ? 'win32:windows-terminal' : 'win32:console'; - if (platform === 'linux') { - const requested = env.TERMINAL?.trim() || env.TERM_PROGRAM?.trim(); - const resolved = requested ? resolveExecutable(requested, env) : undefined; - if (resolved && linuxTerminals.has(basename(resolved))) return `linux:${resolved}`; - for (const command of ['ghostty','wezterm','kitty','gnome-terminal','konsole','xfce4-terminal','x-terminal-emulator']) { - const path = resolveExecutable(command, env); if (path) return `linux:${path}`; - } +export async function openAlarmControls(options: LaunchOptions): Promise { + if (options.hasLiveTui?.()) return {opened: false, terminal: 'existing-tui', message: 'An existing RadioCLI TUI will show the ringing controls.'}; + const terminal = await launchTerminalCommand({...options, args: [options.cliPath], title: 'RadioCLI Alarm'}); + if (!options.hasLiveTui) return {opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls; TUI startup is unverified.'}; + const deadline = Date.now() + (options.timeoutMs ?? 8_000); + while (true) { + if (options.hasLiveTui()) return {opened: true, requested: true, terminal, message: 'RadioCLI alarm controls are ready in the saved terminal.'}; + const remaining = deadline - Date.now(); + if (remaining <= 0) return {opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls, but the TUI did not become ready before verification timed out. It may still be starting.'}; + await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining))); } - return `${platform}:unsupported`; } -export async function openAlarmControls(options: LaunchOptions): Promise { - if (options.hasLiveTui?.()) return {opened:false,terminal:'existing-tui',message:'An existing RadioCLI TUI will show the ringing controls.'}; +/** Ask for macOS Automation access while the user is configuring the alarm. */ +export async function prepareAlarmTerminalAccess(options: PermissionOptions = {}): Promise { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; - const terminal = detectAlarmTerminal(platform, env); - const command = shellCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME); - const directArgs = environmentCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME); - const launch = options.spawn ?? spawnDetached; - if (terminal === 'darwin:apple-terminal') await launched(launch('/usr/bin/osascript', appleTerminalScript(command))); - else if (terminal === 'darwin:iterm') await launched(launch('/usr/bin/osascript', iTermScript(command))); - else if (terminal === 'darwin:wezterm') await launched(launch('/usr/bin/open', ['-na','WezTerm','--args','start','--always-new-process','--',...directArgs])); - else if (terminal === 'darwin:ghostty') await launched(launch('/usr/bin/open', ['-na','Ghostty','--args','-e',...directArgs])); - else if (terminal === 'darwin:kitty') await launched(launch('/usr/bin/open', ['-na','kitty','--args','--detach',...directArgs])); - else if (terminal === 'win32:windows-terminal') await launched(launch('wt.exe', ['-w','new','new-tab','--title','RadioCLI Alarm',options.nodePath,options.cliPath])); - else if (terminal === 'win32:console') await launched(launch('cmd.exe', ['/d','/c','start','RadioCLI Alarm','cmd.exe','/k',windowsCommand(options.nodePath,options.cliPath,env.RADIOCLI_HOME)])); - else if (terminal.startsWith('linux:')) { - const executable = terminal.slice('linux:'.length); if (!linuxTerminals.has(basename(executable))) throw new Error('Saved Linux terminal is not supported.'); - const name = basename(executable); const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' ? ['--'] : name === 'wezterm' ? ['start','--always-new-process','--'] : ['-e']; - await launched(launch(executable,[...prefix,...directArgs])); - } else throw new Error('No supported graphical terminal was found. Open radiocli manually and press ! for alarm controls.'); - return {opened:true,terminal,message:'Opened RadioCLI alarm controls in the saved terminal.'}; -} - -/** Ask for macOS Automation access while the user is configuring the alarm. */ -export async function prepareAlarmTerminalAccess(options:PermissionOptions={}):Promise{ - const platform=options.platform??process.platform;if(platform!=='darwin')return; - const terminal=detectAlarmTerminal(platform,options.env??process.env);const application=terminal==='darwin:apple-terminal'?'Terminal':terminal==='darwin:iterm'?'iTerm':undefined;if(!application)return; - const launch=options.spawn??spawnAttached;const child=launch('/usr/bin/osascript',['-e',`tell application "${application}" to count windows`]); - const code=await completed(child,options.permissionTimeoutMs??60_000);if(code!==0)throw new Error(`macOS did not grant RadioCLI permission to control ${application}. Audio can still play, but automatic ringing controls cannot open. Enable Node under System Settings > Privacy & Security > Automation, then press Repair.`); + if (nativeAdapters(identifyPlatform({platform, env})).terminal !== 'macos') return; + const terminal = detectGraphicalTerminal(platform, env, options.resolve); + const application = terminal === 'darwin:apple-terminal' ? 'Terminal' : terminal === 'darwin:iterm' ? 'iTerm' : undefined; + if (!application) return; + const launch = options.spawn ?? spawnAttached; + const child = launch('/usr/bin/osascript', ['-e', `tell application "${application}" to count windows`]); + const code = await completed(child, options.permissionTimeoutMs ?? 60_000); + if (code !== 0) throw new Error(`macOS did not grant RadioCLI permission to control ${application}. Audio can still play, but automatic ringing controls cannot open. Enable Node under System Settings > Privacy & Security > Automation, then press Repair.`); } /** @@ -78,54 +47,78 @@ export async function prepareAlarmTerminalAccess(options:PermissionOptions={}):P * terminal exits immediately after proving that a native background job can * expose the ringing TUI on this desktop session. */ -export async function verifyAlarmTerminalLaunch(options:ProbeOptions={}):Promise{ - const platform=options.platform??process.platform;const env=options.env??process.env; +export async function verifyAlarmTerminalLaunch(options: ProbeOptions = {}): Promise { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; await prepareAlarmTerminalAccess(options); - const terminal=detectAlarmTerminal(platform,env);if(terminal.endsWith(':unsupported'))throw new Error('No supported graphical terminal was found for automatic alarm controls.'); - const token=randomBytes(24).toString('base64url');const server=createServer(); - await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>resolve());}); - const address=server.address();if(!address||typeof address==='string'){server.close();throw new Error('Unable to create the terminal verification channel.');} - const script="const n=require('node:net');const s=n.connect(Number(process.argv[1]),'127.0.0.1',()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));"; - const direct=[options.nodePath??process.execPath,'-e',script,String(address.port),token]; - const command=`${direct.map(shellQuote).join(' ')}; exit`; - const launch=options.spawn??spawnDetached; - const received=new Promise((resolve,reject)=>{server.once('connection',socket=>{let value='';socket.setEncoding('utf8');socket.on('data',chunk=>value+=chunk);socket.on('end',()=>value===token?resolve():reject(new Error('The terminal verification response was not authentic.')));socket.on('error',reject);});}); - try{ - if(terminal==='darwin:apple-terminal')await launched(launch('/usr/bin/osascript',appleTerminalScript(command))); - else if(terminal==='darwin:iterm')await launched(launch('/usr/bin/osascript',iTermScript(command))); - else if(terminal==='darwin:wezterm')await launched(launch('/usr/bin/open',['-na','WezTerm','--args','start','--always-new-process','--',...direct])); - else if(terminal==='darwin:ghostty')await launched(launch('/usr/bin/open',['-na','Ghostty','--args','-e',...direct])); - else if(terminal==='darwin:kitty')await launched(launch('/usr/bin/open',['-na','kitty','--args','--detach',...direct])); - else if(terminal==='win32:windows-terminal')await launched(launch('wt.exe',['-w','new','new-tab','--title','RadioCLI Alarm Verification',...direct])); - else if(terminal==='win32:console')await launched(launch('cmd.exe',['/d','/c','start','RadioCLI Alarm Verification','cmd.exe','/c',windowsCommandArgs(direct)])); - else if(terminal.startsWith('linux:')){const executable=terminal.slice(6);const name=basename(executable);const prefix=name==='gnome-terminal'||name==='mate-terminal'||name==='xfce4-terminal'?['--']:name==='wezterm'?['start','--always-new-process','--']:['-e'];await launched(launch(executable,[...prefix,...direct]));} - else throw new Error('The saved terminal is not supported for alarm controls.'); - await withTimeout(received,options.timeoutMs??8_000,'The terminal opened but did not connect back to RadioCLI.'); + const terminal = detectGraphicalTerminal(platform, env, options.resolve); + if (terminal.endsWith(':unsupported')) throw new Error('No supported graphical terminal was found for automatic alarm controls.'); + const token = randomBytes(24).toString('base64url'); + const server = createServer(); + const address = await listenLoopback(server); + const script = "const n=require('node:net');const s=n.connect(Number(process.argv[1]),process.argv[3],()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));"; + const nodePath = options.nodePath ?? process.execPath; + const args = ['-e', script, String(address.port), token, address.host]; + const sockets = new Set(); + const received = new Promise((resolve, reject) => { + server.on('connection', socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + let value = ''; + socket.setEncoding('utf8'); + socket.on('data', chunk => { + value += chunk; + if (value.length > token.length) { + socket.destroy(); + reject(new Error('The terminal verification response was not authentic.')); + } + }); + socket.on('end', () => value === token ? resolve() : reject(new Error('The terminal verification response was not authentic.'))); + socket.on('error', reject); + }); + }); + // A callback can fail while the launcher is still reporting its own result. + // Keep the rejection observed until it is awaited below. + void received.catch(() => undefined); + try { + await launchTerminalCommand({...options, nodePath, args, title: 'RadioCLI Alarm Verification', closeOnExit: true}); + await withTimeout(received, options.timeoutMs ?? 8_000, 'The terminal opened but did not connect back to RadioCLI.'); return terminal; - }finally{await new Promise(resolve=>server.close(()=>resolve()));} + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } } -function validDescriptor(value:string,platform:NodeJS.Platform):boolean { - if(platform==='darwin')return['darwin:apple-terminal','darwin:iterm','darwin:wezterm','darwin:ghostty','darwin:kitty'].includes(value); - if(platform==='win32')return value==='win32:windows-terminal'||value==='win32:console'; - if(platform==='linux'&&value.startsWith('linux:'))return linuxTerminals.has(basename(value.slice(6))); - return false; +function spawnAttached(command: string, args: readonly string[]): ChildProcess { + return spawn(command, [...args], {stdio: 'ignore', windowsHide: true}); } -function resolveExecutable(input:string,env:NodeJS.ProcessEnv):string|undefined { - if(input.includes('/')&&existsSync(input))return input; - for(const directory of (env.PATH??'').split(delimiter)){const path=join(directory,input);if(existsSync(path))return path;} - return undefined; + +function completed(child: ChildProcess, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (work: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + work(); + }; + const timer = setTimeout(() => { + child.kill('SIGTERM'); + finish(() => reject(new Error('Timed out waiting for the macOS Automation permission response.'))); + }, timeoutMs); + child.once('error', error => finish(() => reject(error))); + child.once('close', code => finish(() => resolve(code ?? 1))); + }); +} + +async function withTimeout(promise: Promise, milliseconds: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), milliseconds); + })]); + } finally { + if (timer) clearTimeout(timer); + } } -function shellQuote(value:string):string{return `'${value.replaceAll("'",`'\\''`)}'`;} -function shellCommand(nodePath:string,cliPath:string,home?:string):string{return `${home?`RADIOCLI_HOME=${shellQuote(home)} `:''}${shellQuote(nodePath)} ${shellQuote(cliPath)}`;} -function environmentCommand(nodePath:string,cliPath:string,home?:string):string[]{return home?['/usr/bin/env',`RADIOCLI_HOME=${home}`,nodePath,cliPath]:[nodePath,cliPath];} -function windowsCommand(nodePath:string,cliPath:string,home?:string):string{return `${home?`set "RADIOCLI_HOME=${cmdEscape(home)}" && `:''}"${nodePath.replaceAll('"','""')}" "${cliPath.replaceAll('"','""')}"`;} -function windowsCommandArgs(values:string[]):string{return values.map(value=>`"${value.replaceAll('"','""')}"`).join(' ');} -function cmdEscape(value:string){return value.replaceAll('%','%%').replaceAll('"','""').replaceAll('^','^^').replaceAll('&','^&').replaceAll('|','^|').replaceAll('<','^<').replaceAll('>','^>');} -function appleTerminalScript(command:string):string[]{return['-e','on run argv','-e','tell application "Terminal"','-e','activate','-e','do script (item 1 of argv)','-e','end tell','-e','end run',command];} -function iTermScript(command:string):string[]{return['-e','on run argv','-e','tell application "iTerm"','-e','activate','-e','set w to (create window with default profile)','-e','tell current session of w to write text (item 1 of argv)','-e','end tell','-e','end run',command];} -function spawnDetached(command:string,args:readonly string[]):ChildProcess{return spawn(command,[...args],{detached:true,stdio:'ignore',windowsHide:false});} -function spawnAttached(command:string,args:readonly string[]):ChildProcess{return spawn(command,[...args],{stdio:'ignore',windowsHide:true});} -function launched(child:ChildProcess):Promise{return new Promise((resolve,reject)=>{child.once('error',reject);child.once('spawn',()=>{child.unref();resolve();});});} -function completed(child:ChildProcess,timeoutMs:number):Promise{return new Promise((resolve,reject)=>{let settled=false;const finish=(work:()=>void)=>{if(settled)return;settled=true;clearTimeout(timer);work();};const timer=setTimeout(()=>{child.kill('SIGTERM');finish(()=>reject(new Error('Timed out waiting for the macOS Automation permission response.')));},timeoutMs);child.once('error',error=>finish(()=>reject(error)));child.once('close',code=>finish(()=>resolve(code??1)));});} -async function withTimeout(promise:Promise,milliseconds:number,message:string):Promise{let timer:NodeJS.Timeout|undefined;try{return await Promise.race([promise,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(message)),milliseconds);})]);}finally{if(timer)clearTimeout(timer);}} diff --git a/src/alarms/tui-presence.ts b/src/alarms/tui-presence.ts index 5166550..bad05d2 100644 --- a/src/alarms/tui-presence.ts +++ b/src/alarms/tui-presence.ts @@ -1,5 +1,5 @@ import {existsSync,mkdirSync,readFileSync,readdirSync,rmSync,writeFileSync} from 'node:fs'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {join} from 'node:path'; export function registerTuiPresence(root=runtimeDirectory(),pid=process.pid):()=>void { @@ -11,5 +11,5 @@ export function hasLiveTui(root=runtimeDirectory(),alive=processAlive):boolean { for(const name of readdirSync(directory)){const path=join(directory,name);try{const value=JSON.parse(readFileSync(path,'utf8')) as {pid?:unknown};if(typeof value.pid==='number'&&Number.isInteger(value.pid)&&alive(value.pid))return true;rmSync(path,{force:true});}catch{rmSync(path,{force:true});}} return false; } -function runtimeDirectory():string{if(process.env.RADIOCLI_HOME)return join(process.env.RADIOCLI_HOME,'runtime');if(process.platform==='win32')return join(process.env.LOCALAPPDATA??join(homedir(),'AppData','Local'),'RadioCLI','runtime');return join(process.env.XDG_RUNTIME_DIR??join(homedir(),'.local','state'),'radiocli');} +function runtimeDirectory():string{return platformPaths().runtime;} function processAlive(pid:number):boolean{try{process.kill(pid,0);return true;}catch{return false;}} diff --git a/src/cli-terminal.test.tsx b/src/cli-terminal.test.tsx new file mode 100644 index 0000000..4acebe7 --- /dev/null +++ b/src/cli-terminal.test.tsx @@ -0,0 +1,60 @@ +import {fileURLToPath} from 'node:url'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {resolveTerminalCapabilities} from './platform/terminal.js'; + +const {render, configure} = vi.hoisted(() => ({render: vi.fn(), configure: vi.fn()})); +vi.mock('ink', () => ({render})); +vi.mock('./ui/App.js', () => ({App: () => null})); +vi.mock('./ui/terminal-renderer.js', () => ({configureTerminalRenderer: configure})); + +const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); +const originalArgv = process.argv; +const originalExitCode = process.exitCode; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubEnv('TERM', 'xterm-256color'); + for (const key of ['NO_COLOR', 'FORCE_COLOR', 'RADIOCLI_SCREEN_READER', 'INK_SCREEN_READER']) vi.stubEnv(key, undefined); + configure.mockImplementation(async (env, evidence) => resolveTerminalCapabilities(env, evidence)); + process.argv = [process.execPath, fileURLToPath(new URL('./cli.tsx', import.meta.url))]; +}); + +afterEach(() => { + process.argv = originalArgv; + process.exitCode = originalExitCode; + if (stdinDescriptor) Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor); + else Reflect.deleteProperty(process.stdin, 'isTTY'); + if (stdoutDescriptor) Object.defineProperty(process.stdout, 'isTTY', stdoutDescriptor); + else Reflect.deleteProperty(process.stdout, 'isTTY'); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('CLI terminal entry point', () => { + it.each([true, undefined])('normalizes stdout.isTTY=%s before configuring Ink', async stdoutTty => { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: true}); + Object.defineProperty(process.stdout, 'isTTY', {configurable: true, value: stdoutTty}); + await import('./cli.js'); + expect(configure).toHaveBeenCalledOnce(); + expect(configure.mock.calls[0]?.[1]).toMatchObject({isTTY: Boolean(stdoutTty)}); + expect(render).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + interactive: stdoutTty ? undefined : false, + debug: !stdoutTty, + kittyKeyboard: expect.objectContaining({mode: stdoutTty ? 'auto' : 'disabled'}) + })); + }); + + it('prints actionable help and exits before rendering when terminal input is absent', async () => { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: undefined}); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await import('./cli.js'); + expect(error).toHaveBeenCalledWith(expect.stringContaining('interactive TUI needs terminal input')); + expect(log).toHaveBeenCalledWith(expect.stringContaining('Usage:')); + expect(process.exitCode).toBe(1); + expect(render).not.toHaveBeenCalled(); + expect(configure).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli.test.ts b/src/cli.test.ts index 4110225..db82003 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -7,6 +7,10 @@ import {isDirectRun, runCommand} from './cli.js'; import {detectPlaybackBackends} from './player/backend-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; +import * as platformPaths from './platform/paths.js'; +import * as schedulers from './alarms/scheduler.js'; +import * as support from './platform/support.js'; +import {identifyPlatform} from './platform/runtime.js'; vi.mock('./player/backend-install.js', async importOriginal => { const actual = await importOriginal(); @@ -92,6 +96,8 @@ describe('CLI command dispatch', () => { backends: string[]; commands: Record; mpv: {path: string | null; discovery: string; launchable: boolean}; + host: {id: string; arch: string}; + capabilities: Record; guidance: string[]; }; expect(report.radioCliVersion).toMatch(/^\d+\.\d+\.\d+/); @@ -100,9 +106,96 @@ describe('CLI command dispatch', () => { expect(report.commands).toHaveProperty('mpv'); expect(report.mpv).toMatchObject({discovery: expect.any(String), launchable: expect.any(Boolean)}); expect(report.guidance).toContain('playback=missing'); + expect(report.host).toMatchObject({id: expect.any(String), arch: process.arch}); + expect(report.capabilities.playback?.status).toBe('unavailable'); + expect(report.capabilities.storage?.status).toBe('available'); + expect(report.capabilities).toHaveProperty('backgroundScheduling'); + expect(report.capabilities).toHaveProperty('terminalReopening'); + expect(report.capabilities).toHaveProperty('screenReader'); + expect(JSON.stringify(report)).not.toContain(radioCliHome); expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); }); + it('diagnoses an inaccessible data destination without creating or replacing it', async () => { + const blockedHome = join(radioCliHome, 'not-a-directory'); + writeFileSync(blockedHome, 'preserve me'); + process.env.RADIOCLI_HOME = blockedHome; + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.storage.status).toBe('unavailable'); + expect(report.capabilities.atomicWrites.status).toBe('unavailable'); + expect(report.capabilities.storage.message).toContain('RADIOCLI_HOME'); + expect(JSON.stringify(report)).not.toContain(blockedHome); + expect(readFileSync(blockedHome, 'utf8')).toBe('preserve me'); + }); + + it('checks the selected legacy library location without migrating data during doctor', async () => { + const paths = platformPaths.platformPaths(); + const spy = vi.spyOn(platformPaths, 'platformPaths').mockReturnValue({...paths, library: join(radioCliHome, 'absent.json'), legacyLibrary: radioCliHome}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.storage.status).toBe('unavailable'); + expect(existsSync(join(radioCliHome, 'absent.json'))).toBe(false); + } finally { spy.mockRestore(); } + }); + + it('reports an inaccessible user scheduler while preserving unrelated diagnostics', async () => { + const adapter = schedulers.createSchedulerAdapter(); + const spy = vi.spyOn(schedulers, 'createSchedulerAdapter').mockReturnValue({...adapter, + probeCapabilities: async () => ({supported: false, message: 'No active systemd user manager.', catchUpAfterWake: false, exactWake: false})}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.backgroundScheduling).toMatchObject({status: 'unavailable', message: 'No active systemd user manager.'}); + expect(report.capabilities.storage.status).toBe('available'); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + } finally { spy.mockRestore(); } + }); + + it('reports offline and constrained-terminal policy without writing state', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + vi.stubEnv('TERM', 'dumb'); + vi.stubEnv('FORCE_COLOR', '3'); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.network).toMatchObject({status: 'offline', offline: true}); + expect(report.terminal).toMatchObject({unicode: false, colorLevel: 0, reduceMotion: true, interactive: false}); + expect(report.capabilities.unicode.status).toBe('unavailable'); + expect(report.capabilities.color.status).toBe('unavailable'); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + } finally {vi.unstubAllEnvs();} + }); + + it('keeps doctor usable if an optional scheduler probe fails', async () => { + const adapter = schedulers.createSchedulerAdapter(); + const spy = vi.spyOn(schedulers, 'createSchedulerAdapter').mockReturnValue({...adapter, + probeCapabilities: async () => { throw new Error('probe failed'); }}); + try { + await runCommand(['doctor', '--json']); + expect(JSON.parse(logs.join('\n')).capabilities.backgroundScheduling.status).toBe('unavailable'); + } finally { spy.mockRestore(); } + }); + + it.each(['first-class', 'supported', 'experimental', 'unsupported'] as const)('preserves the %s support assessment in JSON and text doctor output', async tier => { + const assessment = support.assessPlatformSupport(identifyPlatform()); + const spy = vi.spyOn(support, 'assessPlatformSupport').mockReturnValue({...assessment, tier}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.support.tier).toBe(tier); + expect(report.support.scope).toBe('current-installation'); + expect(report.support.evidence).toEqual(assessment.evidence); + expect(report.backends).toEqual([]); + logs.length = 0; + await runCommand(['doctor']); + expect(logs.join('\n')).toContain(`support_tier=${tier}`); + expect(logs.join('\n')).toContain('support_scope=current-installation'); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + } finally {spy.mockRestore();} + }); + it('rejects unknown commands with the help hint', async () => { await expect(runCommand(['wat'])).rejects.toThrow('Unknown command: wat\nRun radiocli help.'); }); diff --git a/src/cli.tsx b/src/cli.tsx index 7303a6b..d8a96d2 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -4,21 +4,30 @@ import {resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import {ProviderManager} from './providers/provider-manager.js'; import {PlayerController} from './player/player-controller.js'; -import {JsonLibraryStore} from './storage/store.js'; +import {defaultLibraryPath, JsonLibraryStore} from './storage/store.js'; import {parsePlaylistFile, stationFromUrl, writeM3u} from './playlists/playlist.js'; import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backend-install.js'; -import {resolveCommand} from './player/command.js'; +import {resolveCommand} from './platform/executables.js'; import {diagnoseCommand, type CommandDiagnostic} from './player/command-diagnostics.js'; import {airPlaySenderHealth} from './player/airplay-sender-health.js'; import {appVersion} from './version.js'; import {checkForUpdate, installUpdate, updateCommandForInstall} from './update-check.js'; import {runAlarmCommand} from './alarms/cli.js'; +import {createSchedulerAdapter, type SchedulerCapabilities} from './alarms/scheduler.js'; import {runSetup} from './setup.js'; import {runAgentCliCommand, runMcpCommand} from './agent/cli.js'; import {decodeAgentCommand} from './agent/service.js'; import {runHeadlessAgentHost} from './agent/headless-host.js'; import {configureMcpIntegrations} from './agent/mcp-install.js'; import {defaultAgentControlSettings} from './types.js'; +import {identifyPlatform} from './platform/runtime.js'; +import {platformCapabilities} from './platform/capabilities.js'; +import {hasGraphicalSession} from './platform/desktop.js'; +import {detectPackageManager} from './platform/packages.js'; +import {storageReadiness} from './platform/storage.js'; +import {resolveTerminalCapabilities} from './platform/terminal.js'; +import {networkDiagnostic} from './platform/network.js'; +import {assessPlatformSupport} from './platform/support.js'; const runtime = {nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url)}; @@ -28,11 +37,7 @@ if (isDirectRun(process.argv[1], import.meta.url)) { if (args[0] === 'agent-ui') { const encoded = args[1]; if (!encoded) throw new Error('Missing RadioCLI agent startup request.'); - const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); - render(, { - exitOnCtrlC: false, - kittyKeyboard: {mode: 'auto', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']} - }); + await renderTui(encoded); } else if (args[0] === 'agent-host') { await runHeadlessAgentHost(); } else if (args.length > 0) { @@ -41,16 +46,28 @@ if (isDirectRun(process.argv[1], import.meta.url)) { process.exitCode = 1; }); } else { - const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); - render(, { - // App owns Ctrl+C so it can confirm before performing a clean shutdown. - exitOnCtrlC: false, - kittyKeyboard: { - mode: 'auto', - flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] - } - }); + await renderTui(); + } +} + +async function renderTui(encodedCommand?: string): Promise { + if (!process.stdin.isTTY) { + console.error('The interactive TUI needs terminal input. Use radiocli commands in a headless session, or connect with a terminal (for example, ssh -t).'); + printHelp(); + process.exitCode = 1; + return; } + const {configureTerminalRenderer} = await import('./ui/terminal-renderer.js'); + const terminal = await configureTerminalRenderer(process.env, {isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.()}); + const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); + render(, { + // App owns Ctrl+C so it can confirm before performing a clean shutdown. + exitOnCtrlC: false, + isScreenReaderEnabled: terminal.screenReader, + interactive: terminal.interactive ? undefined : false, + debug: !terminal.interactive, + kittyKeyboard: {mode: terminal.interactive ? 'auto' : 'disabled', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']} + }); } export async function runCommand(args: string[]): Promise { @@ -97,13 +114,22 @@ export async function runCommand(args: string[]): Promise { if (command === 'doctor') { const backends = detectPlaybackBackends(); const mpvDiagnostic = diagnoseCommand('mpv'); + const report = await doctorReport(backends, mpvDiagnostic); if (rest.includes('--json')) { - console.log(JSON.stringify(doctorReport(backends, mpvDiagnostic), null, 2)); + console.log(JSON.stringify(report, null, 2)); return; } console.log(`backends=${backends.join(',') || 'none'}`); printMpvDiagnostic(mpvDiagnostic); printPlaybackBackendStatus(backends); + for (const [name, capability] of Object.entries(report.capabilities)) { + console.log(`capability_${name}=${capability.status} ${capability.message}`); + } + console.log(`network=${report.network.status} ${report.network.message}`); + console.log(`support_tier=${report.support.tier} runtime=${report.support.runtime.status}`); + console.log(`support_scope=${report.support.scope}`); + console.log('support_note=Doctor checks this installation without running packed-install or audio acceptance tests; reference CI results are documented in the platform matrix.'); + for (const reason of report.support.reasons) console.log(`support_note=${reason}`); return; } @@ -267,7 +293,9 @@ Usage: radiocli setup --mcp --headless-agent Opt out of external terminal windows radiocli setup --no-mcp Disable and remove agent MCP entries radiocli setup --package-manager Use brew, winget, scoop, choco, - apt, dnf, pacman, apk, or zypper + apt, dnf, pacman, apk, zypper, + pkg (FreeBSD), pkg_add, pkgin, + termux-pkg (Termux), or pkgman (Haiku) `); } @@ -277,16 +305,47 @@ function printPlaybackBackendStatus(backends: string[]): void { } } -function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic): Record { +async function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic) { const commands = Object.fromEntries( ['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]) ); const airPlay = airPlaySenderHealth(); + const host = identifyPlatform(); + const terminal = resolveTerminalCapabilities(process.env, {isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.()}); + let scheduler: SchedulerCapabilities | undefined; + try { + const adapter = createSchedulerAdapter(); + scheduler = await adapter.probeCapabilities?.(); + } catch { + scheduler = {supported: false, exactWake: false, catchUpAfterWake: false, message: 'The optional scheduler probe failed. Run radiocli alarm doctor for repair guidance.'}; + } + const integrationCommands = [ + 'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit', + 'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer', + 'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel', + 'termux-open-url', 'termux-clipboard-set' + ].filter(command => resolveCommand(command) !== null); + const capabilities = platformCapabilities(host, { + backends, + commands: integrationCommands, + graphicalSession: hasGraphicalSession(host), + packageManager: detectPackageManager(process.platform, host.osRelease), + storageWritable: libraryWritesAvailable(), + scheduler, + unicode: terminal.unicode, + color: terminal.colorLevel > 0, + screenReader: terminal.screenReader + }); return { radioCliVersion: appVersion(), nodeVersion: process.version, platform: process.platform, architecture: process.arch, + host: {id: host.id, arch: host.arch, armVersion: host.armVersion, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl}, + support: {scope: 'current-installation' as const, ...assessPlatformSupport(host)}, + capabilities, + terminal, + network: networkDiagnostic(), backends, commands, mpv: redactDiagnostic(mpvDiagnostic), @@ -302,6 +361,11 @@ function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic): Rec }; } +function libraryWritesAvailable(): boolean { + try { return storageReadiness(defaultLibraryPath()).status === 'available'; } + catch { return false; } +} + function printMpvDiagnostic(diagnostic: CommandDiagnostic): void { console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`); console.log(`mpv_discovery=${diagnostic.discovery}`); diff --git a/src/platform/capabilities.test.ts b/src/platform/capabilities.test.ts new file mode 100644 index 0000000..4714da1 --- /dev/null +++ b/src/platform/capabilities.test.ts @@ -0,0 +1,52 @@ +import {describe, expect, it} from 'vitest'; +import {identifyPlatform} from './runtime.js'; +import {platformCapabilities} from './capabilities.js'; + +describe('independent capabilities', () => { + it('keeps playback and private storage independent of an unavailable scheduler/desktop', () => { + const report = platformCapabilities(identifyPlatform({platform: 'freebsd', env: {}}), {backends: ['mpv'], commands: [], graphicalSession: false}); + expect(report.playback.status).toBe('available'); + expect(report.playbackControls.status).toBe('available'); + expect(report.backgroundScheduling.status).toBe('unavailable'); + expect(report.externalUrl.status).toBe('unavailable'); + expect(report.clipboard.status).toBe('unavailable'); + expect(report.filePermissions.status).toBe('available'); + expect(report.storage.status).toBe('unverified'); + expect(report.airPlay.status).toBe('unavailable'); + }); + + it('never turns executable discovery into scheduler or wake verification', () => { + const host = identifyPlatform({platform: 'linux', env: {}}); + const evidence = {backends: ['mpv'], commands: ['systemctl', 'systemd-inhibit'], graphicalSession: false}; + expect(platformCapabilities(host, evidence).backgroundScheduling.status).toBe('unverified'); + expect(platformCapabilities(host, {...evidence, scheduler: {supported: false, message: 'No user manager'}}).backgroundScheduling).toMatchObject({status: 'unavailable', message: 'No user manager'}); + expect(platformCapabilities(host, {...evidence, scheduler: {supported: true, message: 'User manager responded'}}).backgroundScheduling.status).toBe('available'); + expect(platformCapabilities(host, evidence).wakeRequests.status).toBe('unavailable'); + expect(platformCapabilities(host, evidence).sleepInhibition.status).toBe('unverified'); + }); + + it.each(['ffplay', 'vlc'])('reports %s playback with explicitly unavailable interactive controls', backend => { + const report = platformCapabilities(identifyPlatform({platform: 'linux', env: {}}), {backends: [backend], commands: [], graphicalSession: false}); + expect(report.playback.status).toBe('available'); + expect(report.playbackControls).toMatchObject({status: 'unavailable'}); + expect(report.playbackControls.message).toContain('mpv'); + }); + + it('reports an absent optional command instead of advertising a usable integration', () => { + const report = platformCapabilities(identifyPlatform({platform: 'darwin', env: {}}), {backends: [], commands: [], graphicalSession: true}); + expect(report.playback.status).toBe('unavailable'); + expect(report.externalUrl.status).toBe('unavailable'); + expect(report.clipboard.status).toBe('unavailable'); + expect(report.sleepInhibition.status).toBe('unavailable'); + expect(report.airPlay.status).toBe('unavailable'); + }); + + it('keeps read-only storage and missing tools useful to doctor', () => { + const report = platformCapabilities(identifyPlatform({platform: 'win32', env: {}}), {backends: ['mpv'], commands: [], graphicalSession: false, storageWritable: false}); + expect(report.storage).toMatchObject({status: 'unavailable'}); + expect(report.storage.message).toContain('RADIOCLI_HOME'); + expect(report.filePermissions.message).toContain('ACL'); + expect(report.atomicWrites.status).toBe('unavailable'); + expect(report.playback.status).toBe('available'); + }); +}); diff --git a/src/platform/capabilities.ts b/src/platform/capabilities.ts new file mode 100644 index 0000000..3e27a05 --- /dev/null +++ b/src/platform/capabilities.ts @@ -0,0 +1,68 @@ +import {browserCommands, clipboardCandidates} from './desktop.js'; +import {nativeAdapters, type PlatformProfile} from './runtime.js'; + +export type Capability = {status: 'available' | 'unavailable' | 'unverified'; message: string; adapter?: string}; +type CapabilityEvidence = { + backends: readonly string[]; + commands: readonly string[]; + graphicalSession: boolean; + packageManager?: string | null; + storageWritable?: boolean; + scheduler?: {supported: boolean; message: string; catchUpAfterWake?: boolean}; + unicode?: boolean; + color?: boolean; + screenReader?: boolean; +}; + +const available = (message: string, adapter?: string): Capability => ({status: 'available', message, ...(adapter ? {adapter} : {})}); +const unavailable = (message: string): Capability => ({status: 'unavailable', message}); +const unverified = (message: string, adapter?: string): Capability => ({status: 'unverified', message, ...(adapter ? {adapter} : {})}); + +/** + * Availability is independent per feature. Tool discovery never substitutes for + * desktop, scheduler, audio-device, permission, or upstream runtime verification. + */ +export function platformCapabilities(host: PlatformProfile, evidence: CapabilityEvidence) { + const adapters = nativeAdapters(host); + const commands = new Set(evidence.commands); + const has = (name: string) => commands.has(name); + const backend = ['mpv', 'ffplay', 'vlc', 'airplay'].find(name => evidence.backends.includes(name)); + const desktop = evidence.graphicalSession; + const opener = browserCommands(host).find(item => has(item.command)); + const clipboard = clipboardCandidates(host).find(item => has(item.command)); + const schedulerTool = adapters.scheduler === 'launchd' ? 'launchctl' + : adapters.scheduler === 'task-scheduler' ? 'schtasks.exe' : adapters.scheduler === 'systemd' ? 'systemctl' : null; + const backgroundScheduling = evidence.scheduler + ? evidence.scheduler.supported ? available(evidence.scheduler.message, adapters.scheduler ?? undefined) : unavailable(evidence.scheduler.message) + : schedulerTool && has(schedulerTool) ? unverified('Native scheduler command found; verify the per-user session with radiocli alarm doctor.', adapters.scheduler ?? undefined) + : unavailable(adapters.scheduler ? `The ${schedulerTool} scheduler command is unavailable.` : `Reliable background scheduling has no verified adapter on ${host.id}.`); + const sleepTool = adapters.inhibitor === 'caffeinate' ? 'caffeinate' : adapters.inhibitor === 'logind' ? 'systemd-inhibit' : adapters.inhibitor === 'windows' ? 'powershell.exe' : null; + const volumeTool = adapters.volume === 'macos' ? has('osascript') ? 'osascript' : null + : adapters.volume === 'windows' ? ['powershell.exe', 'pwsh.exe'].find(has) + : adapters.volume === 'unix-audio' ? ['wpctl', 'pactl', 'amixer'].find(has) : null; + const storage = evidence.storageWritable === true ? available('The local data directory is writable; operations still verify each write.') + : evidence.storageWritable === false ? unavailable('Local data is not writable. Set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.') + : unverified('Local JSON data uses the existing private user paths; writability is checked when used.'); + return { + playback: backend ? available(`${backend} executable detected; stream and audio-device readiness are checked when tuning.`, backend) : unavailable('No playback backend detected. Run radiocli setup or configure a native player path.'), + playbackControls: backend === 'mpv' ? available('mpv IPC provides pause, mute, volume, metadata, and media keys.', 'mpv') + : unavailable(backend === 'airplay' ? 'AirPlay provides volume and mute; pause and media-key playback controls require local mpv.' : 'Pause, mute, volume, metadata, and media-key playback controls require mpv.'), + packageInstallation: evidence.packageManager ? available('Explicit setup plans require confirmation or --yes; --dry-run never installs.', evidence.packageManager) : unavailable('No supported package manager detected; install playback prerequisites manually.'), + storage, + filePermissions: adapters.posixPermissions ? available('Private files use mode 0600 and private directories use mode 0700; the filesystem must enforce POSIX permissions.') : available('Private user directories inherit Windows ACLs; POSIX modes do not configure Windows ACLs.'), + atomicWrites: evidence.storageWritable === false ? unavailable('Atomic replacement requires a writable destination directory.') : available('Temporary files are renamed within the destination directory; write failures are reported.'), + externalUrl: desktop && opener ? available('A desktop URL handler is available; execution is checked when used.', opener.command) : unavailable('No usable desktop URL handler/session detected. Open the station homepage manually.'), + clipboard: desktop && clipboard ? available('A clipboard command is available; execution is checked when used.', clipboard.command) : unavailable('No usable clipboard command/session detected. RadioCLI can display the text for manual copying.'), + terminalReopening: desktop && adapters.terminal ? unverified('A supported installed terminal must be found and its launch verified.', adapters.terminal) : unavailable('No supported graphical terminal session. Use the current TUI or explicitly select headless agent playback.'), + backgroundScheduling, + catchUp: backgroundScheduling.status === 'available' && evidence.scheduler?.catchUpAfterWake ? available('The native scheduler can deliver a missed occurrence; RadioCLI still enforces the configured grace window.') + : backgroundScheduling.status === 'unverified' ? unverified('Catch-up depends on an accessible native user scheduler and its session.') : unavailable('Catch-up delivery is not verified in this session.'), + wakeRequests: adapters.scheduler === 'task-scheduler' ? unverified('Task Scheduler can request WakeToRun; hardware, login state, and power policy decide delivery.') : unavailable('No privileged or exact wake request is made on this platform. Catch-up and idle-sleep inhibition are separate capabilities.'), + sleepInhibition: sleepTool && has(sleepTool) ? unverified('A sleep-inhibitor tool is installed; its lease must remain alive to provide protection.', sleepTool) : unavailable('No usable per-process sleep inhibitor. Normal playback remains available; sleep may interrupt it.'), + systemVolume: volumeTool ? unverified('The system mixer must respond before alarm volume can be raised and restored.', volumeTool) : unavailable('System output volume cannot be adjusted. Set the device volume manually; player volume remains independent.'), + unicode: evidence.unicode === false ? unavailable('ASCII decoration mode is selected; international station data is preserved.') : available('Unicode decoration is enabled; terminal font coverage determines glyph appearance.'), + color: evidence.color === false ? unavailable('Color is disabled for the terminal or by user preference.') : available('Color is enabled at the terminal supported depth.'), + screenReader: available(evidence.screenReader ? 'Screen-reader mode is enabled; decorative animation is disabled.' : 'Screen-reader mode is available through INK_SCREEN_READER=true.'), + airPlay: adapters.airPlay && evidence.backends.includes('airplay') ? available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay') : unavailable(adapters.airPlay ? 'Experimental AirPlay requires FFmpeg, Bonjour, and the safe optional sender.' : 'AirPlay output is available only on macOS.') + }; +} diff --git a/src/platform/desktop.test.ts b/src/platform/desktop.test.ts new file mode 100644 index 0000000..5046942 --- /dev/null +++ b/src/platform/desktop.test.ts @@ -0,0 +1,36 @@ +import {describe, expect, it} from 'vitest'; +import {browserCommands, clipboardCandidates, hasGraphicalSession} from './desktop.js'; +import {identifyPlatform} from './runtime.js'; + +describe('portable desktop command plans', () => { + it.each(['linux', 'freebsd', 'openbsd', 'netbsd'])('uses explicitly supported Unix desktop tools on %s', platform => { + const host = identifyPlatform({platform, env: {}}); + expect(browserCommands(host)).toEqual([{command: 'xdg-open', args: []}]); + expect(clipboardCandidates(host).map(item => item.command)).toEqual(['wl-copy', 'xclip', 'xsel']); + expect(hasGraphicalSession(host, {})).toBe(false); + expect(hasGraphicalSession(host, {DISPLAY: ':0'})).toBe(true); + }); + it('requires a deliberate adapter on an unknown Unix system', () => { + const host = identifyPlatform({platform: 'futureos', env: {}}); + expect(browserCommands(host)).toEqual([]); + expect(clipboardCandidates(host)).toEqual([]); + expect(hasGraphicalSession(host, {DISPLAY: ':0'})).toBe(false); + }); + + it.each(['android', 'linux'])('plans native Termux browser and stdin clipboard helpers on %s', platform => { + const env = {TERMUX_VERSION: '0.118.3'}; + const host = identifyPlatform({platform, env}); + expect(browserCommands(host)).toEqual([{command: 'termux-open-url', args: []}]); + expect(clipboardCandidates(host)).toEqual([{command: 'termux-clipboard-set', args: []}]); + expect(hasGraphicalSession(host, env)).toBe(true); + expect(hasGraphicalSession(host, {...env, SSH_CONNECTION: 'remote'})).toBe(false); + expect(hasGraphicalSession(host, {...env, SSH_TTY: '/dev/pts/1', DISPLAY: ':0'})).toBe(false); + }); + + it.each(['android', 'haiku', 'sunos', 'aix'])('does not infer desktop integration for %s from a DISPLAY value', platform => { + const host = identifyPlatform({platform, env: {}}); + expect(browserCommands(host)).toEqual([]); + expect(clipboardCandidates(host)).toEqual([]); + expect(hasGraphicalSession(host, {DISPLAY: ':0'})).toBe(false); + }); +}); diff --git a/src/platform/desktop.ts b/src/platform/desktop.ts new file mode 100644 index 0000000..284d69a --- /dev/null +++ b/src/platform/desktop.ts @@ -0,0 +1,32 @@ +import {identifyPlatform, type PlatformProfile} from './runtime.js'; + +export type SystemCommand = {command: string; args: string[]}; + +/** Command plans only. Callers must check the session and observe execution. */ +export function browserCommands(host: PlatformProfile = identifyPlatform()): SystemCommand[] { + // termux-tools opens one URL through Android's VIEW intent. + if (host.id === 'termux') return [{command: 'termux-open-url', args: []}]; + if (host.id === 'darwin') return [{command: 'open', args: []}]; + if (host.id === 'win32') return [{command: 'explorer', args: []}]; + if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id)) return [{command: 'xdg-open', args: []}]; + return []; +} + +export function clipboardCandidates(host: PlatformProfile = identifyPlatform()): SystemCommand[] { + // No arguments makes termux-clipboard-set read stdin. Its matching Android + // API app and permissions are still required; the caller checks execution. + if (host.id === 'termux') return [{command: 'termux-clipboard-set', args: []}]; + if (host.id === 'darwin') return [{command: 'pbcopy', args: []}]; + if (host.id === 'win32') return [{command: 'clip', args: []}]; + if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id)) return [ + {command: 'wl-copy', args: []}, + {command: 'xclip', args: ['-selection', 'clipboard']}, + {command: 'xsel', args: ['--clipboard', '--input']} + ]; + return []; +} + +export function hasGraphicalSession(host: PlatformProfile, env: NodeJS.ProcessEnv = process.env): boolean { + if (host.id === 'darwin' || host.id === 'win32' || host.id === 'termux') return !env.SSH_CONNECTION && !env.SSH_TTY; + return ['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id) && Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); +} diff --git a/src/player/command.test.ts b/src/platform/executable-locations.test.ts similarity index 97% rename from src/player/command.test.ts rename to src/platform/executable-locations.test.ts index 8a5589e..778a5ea 100644 --- a/src/player/command.test.ts +++ b/src/platform/executable-locations.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {resolveCommandDetails} from './command.js'; +import {resolveCommandDetails} from './executables.js'; describe('command resolution', () => { it('finds the WinGet shinchiro mpv installer location without PATH', () => { diff --git a/src/platform/executables.test.ts b/src/platform/executables.test.ts new file mode 100644 index 0000000..08f414d --- /dev/null +++ b/src/platform/executables.test.ts @@ -0,0 +1,92 @@ +import {chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {clearCommandCache, resolveCommandDetails} from './executables.js'; + +const roots: string[] = []; +afterEach(() => { + vi.unstubAllEnvs(); + clearCommandCache(); + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); + +describe('safe executable configuration and permissions', () => { + it.each([ + ['freebsd', '/usr/local/bin/mpv'], ['openbsd', '/usr/local/bin/mpv'], ['netbsd', '/usr/pkg/bin/mpv'], + ['sunos', '/opt/local/bin/mpv'], ['sunos', '/usr/pkg/bin/mpv'], ['haiku', '/boot/system/bin/mpv'], + ['haiku', '/boot/home/config/bin/mpv'], ['haiku', '/boot/home/config/non-packaged/bin/mpv'] + ] as const)('finds the %s package prefix when an inherited PATH is incomplete', (platform, path) => { + expect(resolveCommandDetails('mpv', {platform, env: {PATH: '/bin'}, home: '/boot/home', isRunnable: candidate => candidate === path})).toMatchObject({path, discovery: 'package-manager-shim'}); + }); + + it.each(['android', 'linux'] as const)('finds native Termux commands using PREFIX on %s', platform => { + const prefix = '/data/user/10/com.termux/files/usr'; + const path = `${prefix}/bin/mpv`; + expect(resolveCommandDetails('mpv', {platform, env: {PATH: '/system/bin', PREFIX: prefix}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'package-manager-shim'}); + }); + + it('prefers the inherited PATH to the Termux prefix', () => { + const path = '/private/player/bin/mpv'; + expect(resolveCommandDetails('mpv', {platform: 'android', env: {PATH: '/private/player/bin', TERMUX_VERSION: '0.118.3', PREFIX: '/data/data/com.termux/files/usr'}, + isRunnable: candidate => [path, '/data/data/com.termux/files/usr/bin/mpv'].includes(candidate)})).toEqual({path, discovery: 'path'}); + }); + + it.each([undefined, 'relative/usr', '/data/data/com.termux/files/usr\n'])('does not guess a Termux prefix from %s or fall back to glibc paths', prefix => { + expect(resolveCommandDetails('mpv', {platform: 'android', env: {TERMUX_VERSION: '0.118.3', PREFIX: prefix}, isRunnable: () => true})).toEqual({path: null, discovery: 'missing'}); + }); + + it('ignores an unrelated PREFIX on ordinary Linux', () => { + expect(resolveCommandDetails('mpv', {platform: 'linux', env: {PREFIX: '/opt/custom'}, isRunnable: candidate => candidate === '/opt/custom/bin/mpv'})).toEqual({path: null, discovery: 'missing'}); + }); + + it('finds an installed AIX Toolbox executable without inferring playback package availability', () => { + const path = '/opt/freeware/bin/python3'; + expect(resolveCommandDetails('python3', {platform: 'aix', env: {PATH: '/usr/bin'}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'package-manager-shim'}); + }); + it('treats a configured path with spaces and Unicode as a single executable', () => { + const path = '/opt/Radio 日本/player mpv'; + expect(resolveCommandDetails('mpv', {platform: 'linux', env: {PATH: '/usr/bin', RADIOCLI_MPV_PATH: path}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'configured-path'}); + }); + + it('never falls back silently from a broken explicit player configuration', () => { + const result = resolveCommandDetails('mpv', {platform: 'linux', env: {RADIOCLI_MPV_PATH: '/missing/mpv'}, isRunnable: path => path === '/usr/bin/mpv'}); + expect(result).toMatchObject({path: null, discovery: 'configured-path', error: expect.stringContaining('RADIOCLI_MPV_PATH')}); + expect(resolveCommandDetails('mpv', {platform: 'linux', env: {RADIOCLI_MPV_PATH: 'mpv --script=untrusted'}, isRunnable: () => true}).path).toBeNull(); + }); + + it('rejects shell scripts as configured Windows player commands', () => { + expect(resolveCommandDetails('mpv', {platform: 'win32', env: {RADIOCLI_MPV_PATH: 'C:\\Tools\\mpv.cmd'}, isRunnable: () => true}).path).toBeNull(); + expect(resolveCommandDetails('mpv', {platform: 'win32', env: {RADIOCLI_MPV_PATH: 'D:\\Tools 日本\\mpv.exe'}, isRunnable: () => true})).toMatchObject({path: 'D:\\Tools 日本\\mpv.exe', discovery: 'configured-path'}); + }); + + it('does not call an executable directory a runnable file', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); + roots.push(root); + const directory = join(root, 'mpv'); + mkdirSync(directory); + expect(resolveCommandDetails(directory).path).toBeNull(); + }); + + it('finds a runnable agent command on the supplied PATH', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); + roots.push(root); + const path = join(root, `radiocli-test-command${process.platform === 'win32' ? '.exe' : ''}`); + writeFileSync(path, 'test', {mode: 0o700}); + expect(resolveCommandDetails('radiocli-test-command', {env: {PATH: root}, platform: process.platform}).path).toBe(path); + }); + + it.skipIf(process.platform === 'win32')('honors POSIX executable permission and environment changes without a stale cache', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); + roots.push(root); + const path = join(root, 'player 日本'); + writeFileSync(path, '#!/bin/sh\nexit 0\n', {mode: 0o600}); + vi.stubEnv('RADIOCLI_MPV_PATH', path); + expect(resolveCommandDetails('mpv').path).toBeNull(); + chmodSync(path, 0o700); + clearCommandCache(); + expect(resolveCommandDetails('mpv').path).toBe(path); + vi.stubEnv('RADIOCLI_MPV_PATH', `${path}-missing`); + expect(resolveCommandDetails('mpv').path).toBeNull(); + }); +}); diff --git a/src/player/command.ts b/src/platform/executables.ts similarity index 73% rename from src/player/command.ts rename to src/platform/executables.ts index 4843b17..622f7ab 100644 --- a/src/player/command.ts +++ b/src/platform/executables.ts @@ -1,13 +1,15 @@ import {spawnSync} from 'node:child_process'; -import {accessSync, constants, existsSync} from 'node:fs'; +import {accessSync, constants, statSync} from 'node:fs'; import {homedir} from 'node:os'; import {posix, win32} from 'node:path'; +import {identifyPlatform} from './runtime.js'; -export type CommandDiscovery = 'path' | 'package-manager-shim' | 'application-directory' | 'windows-registry' | 'missing'; +export type CommandDiscovery = 'path' | 'configured-path' | 'package-manager-shim' | 'application-directory' | 'windows-registry' | 'missing'; export type CommandResolution = { path: string | null; discovery: CommandDiscovery; + error?: string; }; type CommandResolutionOptions = { @@ -33,8 +35,10 @@ export function resolveCommandDetails( command: string, overrides?: Partial ): CommandResolution { + const variable = overrideVariable(command); + const cacheKey = [command, process.env.PATH, process.env.PATHEXT, process.env.PREFIX, process.env.TERMUX_VERSION, variable ? process.env[variable] : undefined].join('\0'); if (!overrides) { - const cached = commandCache.get(command); + const cached = commandCache.get(cacheKey); if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs) return cached.resolution; } @@ -48,11 +52,24 @@ export function resolveCommandDetails( }; const resolution = resolveUncached(command, options); - if (!overrides) commandCache.set(command, {resolution, checkedAt: Date.now()}); + if (!overrides) { + if (commandCache.size >= 256) commandCache.clear(); + commandCache.set(cacheKey, {resolution, checkedAt: Date.now()}); + } return resolution; } function resolveUncached(command: string, options: CommandResolutionOptions): CommandResolution { + const variable = overrideVariable(command); + const configured = variable ? options.env[variable] : undefined; + if (configured !== undefined) { + const pathApi = options.platform === 'win32' ? win32 : posix; + if (!configured || /[\0\r\n]/.test(configured) || !pathApi.isAbsolute(configured) || (options.platform === 'win32' && !/\.(exe|com)$/i.test(configured))) { + return {path: null, discovery: 'configured-path', error: `${variable} must be an absolute native executable path, without shell arguments.`}; + } + return options.isRunnable(configured) ? {path: configured, discovery: 'configured-path'} + : {path: null, discovery: 'configured-path', error: `${variable} does not name a runnable file.`}; + } const pathMatch = lookupOnPath(command, options); if (pathMatch) return {path: pathMatch, discovery: 'path'}; @@ -70,6 +87,10 @@ function resolveUncached(command: string, options: CommandResolutionOptions): Co return {path: null, discovery: 'missing'}; } +function overrideVariable(command: string): string | undefined { + return ({mpv: 'RADIOCLI_MPV_PATH', ffplay: 'RADIOCLI_FFPLAY_PATH', vlc: 'RADIOCLI_VLC_PATH', cvlc: 'RADIOCLI_VLC_PATH', ffmpeg: 'RADIOCLI_FFMPEG_PATH'} as Record)[command]; +} + function lookupOnPath(command: string, options: CommandResolutionOptions): string | null { if (command.includes('/') || command.includes('\\')) return options.isRunnable(command) ? command : null; @@ -112,10 +133,9 @@ export function clearCommandCache(): void { } function isRunnable(path: string, platform: NodeJS.Platform): boolean { - if (!existsSync(path)) return false; - if (platform === 'win32') return true; try { - accessSync(path, constants.X_OK); + if (!statSync(path).isFile()) return false; + if (platform !== 'win32') accessSync(path, constants.X_OK); return true; } catch { return false; @@ -123,10 +143,22 @@ function isRunnable(path: string, platform: NodeJS.Platform): boolean { } function knownBinaryDirs({platform, env, home}: CommandResolutionOptions): string[] { + if (identifyPlatform({platform, env}).id === 'termux') { + // Use the running app's prefix, including alternate Android user installs. + // Generic /usr paths can refer to a different libc inside a proot environment. + const prefix = env.PREFIX; + return prefix && posix.isAbsolute(prefix) && !/[\0\r\n]/.test(prefix) ? [posix.join(prefix, 'bin')] : []; + } if (platform === 'darwin') { return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', posix.join(home, '.local', 'bin')]; } + if (platform === 'freebsd' || platform === 'openbsd') return ['/usr/local/bin', '/usr/bin']; + if (platform === 'netbsd') return ['/usr/pkg/bin', '/usr/local/bin', '/usr/bin']; + if (platform === 'sunos') return ['/opt/local/bin', '/usr/pkg/bin', '/usr/local/bin', '/usr/bin']; + if (platform === 'haiku') return [posix.join(home, 'config', 'non-packaged', 'bin'), posix.join(home, 'config', 'bin'), '/boot/system/non-packaged/bin', '/boot/system/bin']; + if (platform === 'aix') return ['/opt/freeware/bin', '/usr/local/bin', '/usr/bin']; + if (platform === 'win32') { const dirs: string[] = []; if (env.LOCALAPPDATA) dirs.push(win32.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links')); diff --git a/src/platform/ipc.test.ts b/src/platform/ipc.test.ts new file mode 100644 index 0000000..3ec797c --- /dev/null +++ b/src/platform/ipc.test.ts @@ -0,0 +1,17 @@ +import {describe, expect, it} from 'vitest'; +import {mpvIpcPath} from './ipc.js'; + +describe('native mpv IPC endpoints', () => { + it('uses the Windows pipe namespace and does not interpret the temp directory', () => { + expect(mpvIpcPath({platform: 'win32', pid: 42, timestamp: 123, directory: 'D:\\Long temp 日本'})).toBe('\\\\.\\pipe\\radiocli-42-123'); + }); + it.each(['linux', 'darwin', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'])( + 'keeps %s on a Unix socket with spaces and non-ASCII preserved', platform => { + expect(mpvIpcPath({platform, pid: 42, timestamp: 123, directory: '/tmp/日本 space'})).toBe('/tmp/日本 space/radiocli-42-123.sock'); + } + ); + it('checks the portable socket limit in bytes, before a player is launched', () => { + expect(() => mpvIpcPath({platform: 'freebsd', directory: `/tmp/${'é'.repeat(42)}`, pid: 42, timestamp: 123})).toThrow(/socket.*shorter.*TMPDIR/i); + expect(() => mpvIpcPath({platform: 'linux', directory: `/tmp/${'a'.repeat(150)}`})).toThrow(/socket/i); + }); +}); diff --git a/src/platform/ipc.ts b/src/platform/ipc.ts new file mode 100644 index 0000000..56c62e4 --- /dev/null +++ b/src/platform/ipc.ts @@ -0,0 +1,16 @@ +import {tmpdir} from 'node:os'; +import {posix} from 'node:path'; +import {identifyPlatform, nativeAdapters} from './runtime.js'; + +type IpcOptions = {platform?: string; directory?: string; pid?: number; timestamp?: number}; + +export function mpvIpcPath({platform = process.platform, directory = tmpdir(), pid = process.pid, timestamp = Date.now()}: IpcOptions = {}): string { + if (nativeAdapters(identifyPlatform({platform})).ipc === 'named-pipe') return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`; + const path = posix.join(directory, `radiocli-${pid}-${timestamp}.sock`); + // BSD/macOS sockaddr_un is smaller than Linux's. Count encoded bytes and + // leave room for the terminator; JavaScript string length is not sufficient. + if (Buffer.byteLength(path) > 100) { + throw new Error('The mpv socket path is too long. Select a shorter private TMPDIR and restart RadioCLI.'); + } + return path; +} diff --git a/src/platform/launch-command.test.ts b/src/platform/launch-command.test.ts new file mode 100644 index 0000000..5e628a1 --- /dev/null +++ b/src/platform/launch-command.test.ts @@ -0,0 +1,164 @@ +import {execFileSync, spawn, spawnSync} from 'node:child_process'; +import {once} from 'node:events'; +import {mkdirSync, mkdtempSync, realpathSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {describe, expect, it, vi} from 'vitest'; +import {launchEnvironment, nodeLaunchCommand, waitForLaunch} from './launch-command.js'; + +const pathsModule = new URL('./paths.ts', import.meta.url).href; +const tsxModule = import.meta.resolve('tsx'); +function probeArgs(platform: string, absolute = false): string[] { + return ['--import', tsxModule, '--input-type=module', '-e', ` + import {platformPaths} from ${JSON.stringify(pathsModule)}; + import {posix,win32} from 'node:path'; + const paths=platformPaths({platform: ${JSON.stringify(platform)}}); + const path=${platform === 'win32' ? 'win32' : 'posix'}; + process.stdout.write(JSON.stringify(${absolute ? 'Object.fromEntries(Object.entries(paths).map(([key,value])=>[key,path.resolve(value)]))' : 'paths'})); + `]; +} +function run(command: readonly string[], env: NodeJS.ProcessEnv, cwd?: string) { + return JSON.parse(execFileSync(command[0]!, command.slice(1), {env, cwd, encoding: 'utf8'})); +} + +it('waits for an actual stubborn bootstrap to terminate before reporting its timeout', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + const child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});setInterval(()=>{},1000);process.send('ready');"], {stdio: ['ignore', 'ignore', 'ignore', 'ipc']}); + let closed = false; + child.once('close', () => { closed = true; }); + const result = waitForLaunch(child, {waitForExit: true}).catch(error => error as Error); + try { + await once(child, 'message'); + await vi.advanceTimersByTimeAsync(10_250); + expect(await result).toMatchObject({message: 'Launch bootstrap did not complete.'}); + expect(closed).toBe(true); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + } finally { + vi.useRealTimers(); + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'close'); + child.kill('SIGKILL'); + await exited; + } + } +}); + +describe('launch path identity', () => { + const literal = "Radio Data ' \" $() ; & %PATH%! 单播"; + function compareWorkingDirectories(platform: string, env: NodeJS.ProcessEnv) { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'radiocli-launch-cwd-'))); + try { + const parentCwd = join(root, 'parent workspace');const childCwd = join(root, 'unrelated session'); + mkdirSync(parentCwd);mkdirSync(childCwd); + const args = probeArgs(platform, true); + const parent = run([process.execPath, ...args], env, parentCwd); + const snapshot = launchEnvironment(env, {platform, cwd: parentCwd}); + const child = run(nodeLaunchCommand(process.execPath, args, snapshot), {}, childCwd); + expect(child).toEqual(parent); + } finally { rmSync(root, {recursive: true, force: true}); } + } + + it.skipIf(process.platform === 'win32').each([ + {HOME: './listener', RADIOCLI_HOME: `./${literal}`}, + {HOME: './listener', RADIO_ATLAS_HOME: `./${literal}`, XDG_RUNTIME_DIR: './run'}, + {HOME: './listener', XDG_DATA_HOME: `../${literal}`, XDG_CACHE_HOME: './cache', XDG_RUNTIME_DIR: './run'}, + {HOME: './listener', RADIOCLI_HOME: '', RADIO_ATLAS_HOME: `./${literal}`}, + {HOME: './listener', RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '', XDG_DATA_HOME: '', XDG_CACHE_HOME: '', XDG_RUNTIME_DIR: ''}, + {HOME: ''} + ])('keeps POSIX storage identity across different working directories: %j', env => compareWorkingDirectories('linux', env), 10_000); + + it.skipIf(process.platform !== 'win32').each([ + {RADIOCLI_HOME: '.\\Radio Data', USERPROFILE: '.\\listener'}, + {RADIO_ATLAS_HOME: '.\\Radio Data', USERPROFILE: '.\\listener', LOCALAPPDATA: '.\\local'}, + {USERPROFILE: '.\\listener', APPDATA: '.\\roaming', LOCALAPPDATA: '.\\local'}, + {RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '.\\Radio Data', USERPROFILE: '.\\listener'}, + {APPDATA: '', LOCALAPPDATA: '', XDG_DATA_HOME: '', XDG_CACHE_HOME: ''} + ])('keeps native Windows storage identity across different working directories: %j', env => compareWorkingDirectories('win32', env)); + + it.each([ + ['linux', {HOME: `/home/${literal}`, RADIO_ATLAS_HOME: `/legacy/${literal}`, XDG_RUNTIME_DIR: `/run/${literal}`}], + ['linux', {HOME: `/home/${literal}`, XDG_DATA_HOME: `/data/${literal}`, XDG_CACHE_HOME: `/cache/${literal}`, XDG_RUNTIME_DIR: `/run/${literal}`}], + ['freebsd', {HOME: `/home/${literal}`, XDG_DATA_HOME: `/data/${literal}`, XDG_CACHE_HOME: `/cache/${literal}`}], + ['darwin', {HOME: `/Users/${literal}`, XDG_RUNTIME_DIR: `/run/${literal}`}], + ['win32', {USERPROFILE: `C:\\Users\\${literal}`, APPDATA: `D:\\Roaming\\${literal}`, LOCALAPPDATA: `D:\\Local\\${literal}`}], + ['linux', {HOME: `/home/${literal}`, RADIOCLI_HOME: `/current/${literal}`, RADIO_ATLAS_HOME: `/legacy/${literal}`}] + ] as const)('preserves %s parent library, cache, and runtime in a clean child with %j', (platform, env) => { + const args = probeArgs(platform); + const parent = run([process.execPath, ...args], env); + // Both processes receive only the fixture environment. In particular, + // neither can recover a dropped selector from the test runner's env. + const child = run(nodeLaunchCommand(process.execPath, args, launchEnvironment(env, {platform})), {}); + expect(child).toEqual(parent); + if ('RADIO_ATLAS_HOME' in env && !('RADIOCLI_HOME' in env)) { + expect(child.library).toBe(`${env.RADIO_ATLAS_HOME}/radio-atlas.json`); + expect(child.cache).toBe(`${env.RADIO_ATLAS_HOME}/radio-atlas-cache.json`); + } + }); + + // A Windows process cannot emulate POSIX's HOME-based os.homedir fallback: + // libuv uses the native Windows profile even when the fixture sets HOME. + it.skipIf(process.platform === 'win32').each([ + {}, + {HOME: '/home/saved'}, + {HOME: '/home/saved', RADIO_ATLAS_HOME: '/saved legacy'}, + {HOME: '/home/saved', RADIOCLI_HOME: '', XDG_DATA_HOME: '', XDG_CACHE_HOME: '', XDG_RUNTIME_DIR: ''} + ])('preserves absent and empty selectors despite inherited launcher contamination: %j', env => { + const args = probeArgs('linux', true); + const expected = run([process.execPath, ...args], env); + const inherited = { + HOME: '/home/unrelated', USERPROFILE: 'C:\\Unrelated', APPDATA: '/unrelated/roaming', LOCALAPPDATA: '/unrelated/local', + RADIOCLI_HOME: '/unrelated/current', RADIO_ATLAS_HOME: '/unrelated/legacy', + XDG_DATA_HOME: '/unrelated/data', XDG_CACHE_HOME: '/unrelated/cache', XDG_RUNTIME_DIR: '/unrelated/runtime' + }; + expect(run(nodeLaunchCommand(process.execPath, args, launchEnvironment(env, {platform: 'linux'})), inherited)).toEqual(expected); + }); + + it('persists only approved application, path, and optional desktop settings', () => { + const env = {RADIO_ATLAS_HOME: '/legacy', XDG_RUNTIME_DIR: '/run/user/1000', RADIOCLI_OFFLINE: '1', DISPLAY: ':0', API_TOKEN: 'secret', NODE_OPTIONS: '--require unsafe.cjs', PATH: '/shell/tools'}; + expect(launchEnvironment(env, {platform: 'linux'})).toEqual({RADIO_ATLAS_HOME: '/legacy', XDG_RUNTIME_DIR: '/run/user/1000', RADIOCLI_OFFLINE: '1'}); + expect(launchEnvironment(env, {platform: 'linux', includeDesktop: true, terminal: 'linux:/bin/kitty'})).toEqual({RADIO_ATLAS_HOME: '/legacy', XDG_RUNTIME_DIR: '/run/user/1000', RADIOCLI_OFFLINE: '1', DISPLAY: ':0', RADIOCLI_ALARM_TERMINAL: 'linux:/bin/kitty'}); + }); + + it('resolves Windows relative, drive-relative, and rooted selectors against the launch directory', () => { + expect(launchEnvironment({RADIOCLI_HOME: 'Radio Data', RADIO_ATLAS_HOME: 'C:legacy', APPDATA: '\\roaming', LOCALAPPDATA: '', USERPROFILE: '.\\listener'}, {platform: 'win32', cwd: 'C:\\Project Workspace'})).toEqual({ + RADIOCLI_HOME: 'C:\\Project Workspace\\Radio Data', RADIO_ATLAS_HOME: 'C:\\Project Workspace\\legacy', APPDATA: 'C:\\roaming', LOCALAPPDATA: 'C:\\Project Workspace', USERPROFILE: 'C:\\Project Workspace\\listener' + }); + }); + + it('preserves empty fallback overrides and the invalid empty Windows user profile', () => { + expect(launchEnvironment({RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '', USERPROFILE: ''}, {platform: 'win32', cwd: 'C:\\Project'})).toEqual({RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '', USERPROFILE: ''}); + }); + + it('validates the captured directory before it becomes a persisted path selector', () => { + expect(() => launchEnvironment({XDG_DATA_HOME: 'data'}, {platform: 'linux', cwd: '/parent\nworkspace'})).toThrow(/control characters/); + }); + + it.skipIf(process.platform !== 'win32')('clears inherited Windows path selectors regardless of environment-key casing', () => { + const args = probeArgs('win32'); + const inherited = {UserProfile: 'C:\\Unrelated', AppData: 'C:\\Unrelated\\Roaming', LocalAppData: 'C:\\Unrelated\\Local', radiocli_home: 'C:\\Unrelated\\RadioCLI'}; + expect(run(nodeLaunchCommand(process.execPath, args, launchEnvironment({})), inherited)).toEqual(run([process.execPath, ...args], {})); + }); + + it('applies Windows case-insensitive identity cleanup at the bootstrap boundary', () => { + const args = ['-e', "process.stdout.write(JSON.stringify(Object.keys(process.env).filter(key=>/^(appdata|radiocli_home)$/i.test(key))))"]; + const command = nodeLaunchCommand(process.execPath, args, {}); + // Inject only the native platform boundary; the generated bootstrap runs + // unchanged and launches a real child with the resulting environment. + const probe = `const [program,payload]=JSON.parse(process.argv[1]);require('node:vm').runInNewContext(program,{Buffer,require,console,process:{...process,platform:'win32',argv:[process.execPath,payload],env:{AppData:'C:\\\\Unrelated',radiocli_home:'C:\\\\Other'}}});`; + expect(run([process.execPath, '-e', probe, JSON.stringify(command.slice(2))], {})).toEqual([]); + }); + + it.each([0, 7])('returns the actual application exit status %s through the bootstrap', code => { + const command = nodeLaunchCommand(process.execPath, ['-e', `process.exit(${code})`], {}); + expect(spawnSync(command[0]!, command.slice(1), {env: {}}).status).toBe(code); + }); + + it.skipIf(process.platform === 'win32')('reports application signal termination as a launch failure', () => { + const command = nodeLaunchCommand(process.execPath, ['-e', "process.kill(process.pid,'SIGTERM')"], {}); + expect(spawnSync(command[0]!, command.slice(1), {env: {}}).status).toBe(1); + }); + + it.each(['RADIO_ATLAS_HOME', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'])('rejects control characters in the persisted %s selector', key => { + expect(() => launchEnvironment({[key]: 'bad\nvalue'})).toThrow(/control characters/); + }); +}); diff --git a/src/platform/launch-command.ts b/src/platform/launch-command.ts new file mode 100644 index 0000000..bdfbfae --- /dev/null +++ b/src/platform/launch-command.ts @@ -0,0 +1,118 @@ +import {pathEnvironmentKeys} from './paths.js'; +import type {ChildProcess} from 'node:child_process'; +import {homedir} from 'node:os'; +import {posix, win32} from 'node:path'; + +const applicationEnvironmentKeys = [ + 'RADIOCLI_MPV_PATH', + 'RADIOCLI_FFPLAY_PATH', + 'RADIOCLI_VLC_PATH', + 'RADIOCLI_FFMPEG_PATH', + 'RADIOCLI_OFFLINE', + 'RADIOCLI_LOW_BANDWIDTH' +] as const; + +const desktopEnvironmentKeys = ['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS'] as const; + +/** Persist only application settings and the desktop connection a job needs. */ +export function launchEnvironment( + env: NodeJS.ProcessEnv, + options: {includeDesktop?: boolean; terminal?: string; platform?: string; cwd?: string} = {} +): Record { + const result: Record = {}; + const platform = options.platform ?? process.platform; + const path = platform === 'win32' ? win32 : posix; + const add = (key: string, value: string | undefined) => { + if (value === undefined) return; + if (/[\r\n\0]/.test(value)) throw new Error(`${key} cannot contain control characters in a launch environment.`); + result[key] = value; + }; + const capturePath = (key: string, value: string | undefined) => { + add(key, value); + if (value === undefined) return; + // Empty overrides fall back; empty roots select the invoking directory. + // Windows rejects an empty USERPROFILE, while HOME is unused there. + if (value === '' && (key === 'RADIOCLI_HOME' || key === 'RADIO_ATLAS_HOME' || key === 'USERPROFILE' || key === 'HOME' && platform === 'win32')) return; + // Windows root-relative paths also depend on the invoking drive. Fully + // qualified values need no cwd lookup (which can fail after cwd removal). + const absolute = path.isAbsolute(value) && (platform !== 'win32' || /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(value)); + if (!absolute) add(key, path.resolve(options.cwd ?? process.cwd(), value)); + }; + for (const key of pathEnvironmentKeys) capturePath(key, env[key]); + // On Windows, removing USERPROFILE from a child environment does not + // reliably erase a differently-cased inherited value. Persist the effective + // account home so an absent selector cannot be replaced by the launcher. + if (platform === 'win32' && process.platform === 'win32' && env.USERPROFILE === undefined) { + capturePath('USERPROFILE', homedir()); + } + for (const key of applicationEnvironmentKeys) add(key, env[key]); + if (options.includeDesktop) for (const key of desktopEnvironmentKeys) add(key, env[key]); + add('RADIOCLI_ALARM_TERMINAL', options.terminal); + return result; +} + +/** Keep application argv and approved environment out of native shell syntax. */ +export function nodeLaunchCommand(nodePath: string, args: readonly string[], environment: Record): string[] { + if ([nodePath, ...args, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) { + throw new Error('Launch command values cannot contain NUL bytes.'); + } + // This fixed program contains no double quotes, including after expansion. + // PowerShell 5 and Task Scheduler only receive this program and encoded data. + // A terminal server or scheduler may have unrelated path overrides. Clear the + // complete identity before restoring this launch's snapshot, preserving absent + // selectors and the path layer's existing empty-value/default semantics. + const keys = pathEnvironmentKeys.map(key => `'${key}'`).join(','); + const program = `const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const s=new Set([${keys}]);const e=Object.fromEntries(Object.entries(process.env).filter(([k])=>!s.has(process.platform==='win32'?k.toUpperCase():k)));const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...e,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);`; + return [nodePath, '-e', program, Buffer.from(JSON.stringify({args, environment}), 'utf8').toString('base64url')]; +} + +/** Process acceptance is separate from application/session readiness. */ +export function waitForLaunch(child: ChildProcess, options: {waitForExit?: boolean} = {}): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let acceptedTimer: NodeJS.Timeout | undefined; + let forceTimer: NodeJS.Timeout | undefined; + let cleanupTimer: NodeJS.Timeout | undefined; + let stopError: Error | undefined; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(startupTimer); + if (acceptedTimer) clearTimeout(acceptedTimer); + clearTimeout(forceTimer); + clearTimeout(cleanupTimer); + if (error) reject(error); else resolve(); + }; + const signal = (value: NodeJS.Signals) => { + try { child.kill(value); } catch { /* Retain the launch failure and bounded cleanup. */ } + }; + const stop = (error: Error) => { + if (settled || stopError) return; + stopError = error; + clearTimeout(startupTimer); + if (acceptedTimer) clearTimeout(acceptedTimer); + forceTimer = setTimeout(() => signal('SIGKILL'), 250); + cleanupTimer = setTimeout(() => { child.unref(); finish(error); }, 1_000); + signal('SIGTERM'); + }; + const startupTimer = setTimeout(() => { + const error = new Error('Process launcher did not report process startup.'); + if (child.pid) stop(error); else finish(error); + }, 3_000); + // Keep a listener after acceptance: a late native error must not become an + // unhandled EventEmitter error in the application that requested the launch. + child.on('error', error => { + if (!settled && child.pid) stop(error); else finish(error); + }); + child.once('close', (code, signal) => finish(stopError ?? (code === 0 ? undefined : new Error(`Process launcher exited with ${code ?? signal ?? 'unknown status'}.`)))); + child.once('spawn', () => { + if (options.waitForExit) { + clearTimeout(startupTimer); + acceptedTimer = setTimeout(() => stop(new Error('Launch bootstrap did not complete.')), 10_000); + } else { + child.unref(); + acceptedTimer = setTimeout(() => finish(), 100); + } + }); + }); +} diff --git a/src/platform/loopback.test.ts b/src/platform/loopback.test.ts new file mode 100644 index 0000000..de9583a --- /dev/null +++ b/src/platform/loopback.test.ts @@ -0,0 +1,70 @@ +import {createServer, type Server} from 'node:net'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {isLoopbackHost, listenLoopback} from './loopback.js'; + +const servers: Server[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(() => resolve())))); +}); + +describe('private loopback listeners', () => { + it('accepts only the two literal loopback addresses', () => { + expect(isLoopbackHost('127.0.0.1')).toBe(true); + expect(isLoopbackHost('::1')).toBe(true); + for (const host of ['localhost', '0.0.0.0', '::', '127.0.0.2', '[::1]', '::ffff:127.0.0.1', 'example.com', undefined]) { + expect(isLoopbackHost(host), String(host)).toBe(false); + } + }); + + it('binds an ephemeral IPv4 loopback port without exposing a wildcard listener', async () => { + const server = createServer(); + servers.push(server); + const address = await listenLoopback(server); + expect(address.host).toBe('127.0.0.1'); + expect(address.port).toBeGreaterThan(0); + expect(server.address()).toMatchObject({address: '127.0.0.1', port: address.port}); + expect(server.listenerCount('error')).toBe(0); + expect(server.listenerCount('listening')).toBe(0); + }); + + for (const code of ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL']) { + it(`falls back to a real IPv6 loopback listener after ${code} on IPv4`, async context => { + const server = createServer(); + servers.push(server); + const listen = vi.spyOn(server, 'listen').mockImplementationOnce(() => { + queueMicrotask(() => server.emit('error', Object.assign(new Error('IPv4 unavailable'), {code}))); + return server; + }); + let address; + try { + address = await listenLoopback(server); + } catch (error) { + expect(listen).toHaveBeenCalledTimes(2); + if (['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip('The host does not provide IPv6 loopback.'); + } + throw error; + } + expect(address).toMatchObject({host: '::1'}); + expect(server.address()).toMatchObject({address: '::1', port: address.port}); + expect(listen.mock.calls.map(call => call[1])).toEqual(['127.0.0.1', '::1']); + expect(server.listenerCount('error')).toBe(0); + }); + } + + it('does not hide permission errors by retrying another address family', async () => { + const server = createServer(); + servers.push(server); + const denied = Object.assign(new Error('permission denied'), {code: 'EACCES'}); + const listen = vi.spyOn(server, 'listen').mockImplementationOnce(() => { + queueMicrotask(() => server.emit('error', denied)); + return server; + }); + await expect(listenLoopback(server)).rejects.toBe(denied); + expect(listen).toHaveBeenCalledOnce(); + expect(server.listenerCount('error')).toBe(0); + expect(server.listenerCount('listening')).toBe(0); + }); +}); diff --git a/src/platform/loopback.ts b/src/platform/loopback.ts new file mode 100644 index 0000000..8c1b51d --- /dev/null +++ b/src/platform/loopback.ts @@ -0,0 +1,47 @@ +import type {Server} from 'node:net'; + +export type LoopbackHost = '127.0.0.1' | '::1'; + +export function isLoopbackHost(value: unknown): value is LoopbackHost { + return value === '127.0.0.1' || value === '::1'; +} + +/** Bind only literal loopback addresses, including hosts without an IPv4 stack. */ +export async function listenLoopback(server: Server): Promise<{host: LoopbackHost; port: number}> { + try { + return await listen(server, '127.0.0.1'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EAFNOSUPPORT' && code !== 'EPROTONOSUPPORT' && code !== 'EADDRNOTAVAIL') throw error; + return listen(server, '::1'); + } +} + +function listen(server: Server, host: LoopbackHost): Promise<{host: LoopbackHost; port: number}> { + return new Promise((resolve, reject) => { + const cleanup = () => { + server.removeListener('error', failed); + server.removeListener('listening', ready); + }; + const failed = (error: Error) => { + cleanup(); + reject(error); + }; + const ready = () => { + cleanup(); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Unable to create a local control endpoint.')); + return; + } + resolve({host, port: address.port}); + }; + server.once('error', failed); + server.once('listening', ready); + try { + server.listen(0, host); + } catch (error) { + failed(error as Error); + } + }); +} diff --git a/src/platform/network.test.ts b/src/platform/network.test.ts new file mode 100644 index 0000000..6dcd9d8 --- /dev/null +++ b/src/platform/network.test.ts @@ -0,0 +1,343 @@ +import {createServer, request as requestHttp, type Server} from 'node:http'; +import {createServer as createHttpsServer} from 'node:https'; +import {connect} from 'node:net'; +import {spawn} from 'node:child_process'; +import type {Duplex} from 'node:stream'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + allowsDirectDnsFallback, + fetchHttpsWithSystemResolver, + networkDiagnostic, + networkPolicy, + withExternalResponse +} from './network.js'; + +const servers: Server[] = []; +const sockets = new Set(); +const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, 'node')!; +const execArgv = [...process.execArgv]; +const proxyVariables = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy']; + +beforeEach(() => { + for (const key of [...proxyVariables, 'NODE_USE_ENV_PROXY', 'NODE_OPTIONS', 'NODE_TLS_REJECT_UNAUTHORIZED', 'RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH']) vi.stubEnv(key, undefined); +}); + +afterEach(async () => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + Object.defineProperty(process.versions, 'node', nodeVersion); + process.execArgv.splice(0, process.execArgv.length, ...execArgv); + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await Promise.all(servers.splice(0).map(server => { + server.closeAllConnections(); + return new Promise(resolve => server.close(() => resolve())); + })); +}); + +describe('public network policy', () => { + it('reports configuration separately from connectivity and redacts proxy credentials', () => { + expect(networkDiagnostic()).toMatchObject({status: 'configured', offline: false}); + vi.stubEnv('HTTPS_PROXY', 'socks5://private-user:private-password@proxy.invalid:1080'); + expect(networkDiagnostic()).toMatchObject({status: 'unavailable'}); + expect(JSON.stringify(networkDiagnostic())).not.toMatch(/private-user|private-password|proxy.invalid/); + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + expect(networkDiagnostic()).toMatchObject({status: 'offline', offline: true}); + }); + + it('keeps offline and low-bandwidth preferences independent and explicitly opt in', () => { + expect(networkPolicy({})).toEqual({offline: false, lowBandwidth: false}); + expect(networkPolicy({RADIOCLI_OFFLINE: '1'})).toEqual({offline: true, lowBandwidth: false}); + expect(networkPolicy({RADIOCLI_LOW_BANDWIDTH: '1'})).toEqual({offline: false, lowBandwidth: true}); + expect(networkPolicy({RADIOCLI_OFFLINE: '0', RADIOCLI_LOW_BANDWIDTH: 'true'})).toEqual({offline: false, lowBandwidth: false}); + }); + + it('permits direct DNS recovery only when no proxy route is configured', () => { + expect(allowsDirectDnsFallback({})).toBe(true); + expect(allowsDirectDnsFallback({NO_PROXY: '*'})).toBe(true); + for (const name of ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy']) { + expect(allowsDirectDnsFallback({[name]: 'http://proxy.example:8080'})).toBe(false); + } + }); + + it('does not invoke fetch in offline mode', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/offline/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + for (const variable of ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy']) { + it(`rejects unsupported ${variable} without contacting the destination or exposing credentials`, async () => { + let destinationRequests = 0; + const server = createServer((_request, response) => {destinationRequests += 1;response.end('{}');}); + const url = await start(server); + vi.stubEnv(variable, 'socks5://private-user:private-password@127.0.0.1:9'); + const error = await withExternalResponse(url, {timeoutMs: 100}, response => response.json()).then(() => null, value => value as Error); + expect(error).toBeInstanceOf(Error); + expect(error!.message).toMatch(/SOCKS|ALL_PROXY|all_proxy/); + expect(error!.message).not.toMatch(/private-user|private-password|127\.0\.0\.1:9/); + expect(error!.cause).toBeUndefined(); + expect(destinationRequests).toBe(0); + }); + } + + it('explains that ALL_PROXY alone is not implemented even for HTTP proxy URLs', async () => { + vi.stubEnv('ALL_PROXY', 'http://proxy.invalid:8080'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/ALL_PROXY/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('redacts invalid proxy URLs and invalid percent-encoded credentials', async () => { + const fetchImpl = vi.fn(async () => new Response('{}')); + for (const value of ['not-a-url-private-password', 'http://private-user:private-password%ZZ@proxy.invalid']) { + vi.stubEnv('HTTPS_PROXY', value); + const error = await withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json()).then(() => null, value => value as Error); + expect(error!.message).toMatch(/HTTPS_PROXY/i); + expect(error!.message).not.toMatch(/private-user|private-password|proxy\.invalid/); + expect(error!.cause).toBeUndefined(); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('requires native proxy opt-in instead of silently sending configured traffic directly', async () => { + vi.stubEnv('HTTPS_PROXY', 'http://proxy.invalid:8080'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/NODE_USE_ENV_PROXY=1/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('honors lowercase proxy precedence and the fetch HTTPS fallback to HTTP_PROXY', async () => { + vi.stubEnv('NODE_USE_ENV_PROXY', '1'); + vi.stubEnv('HTTP_PROXY', 'socks5://ignored.invalid:1'); + vi.stubEnv('http_proxy', 'http://proxy.invalid:8080'); + vi.stubEnv('ALL_PROXY', 'socks5://also-ignored.invalid:1'); + const fetchImpl = vi.fn(async () => new Response('{"ok":true}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).resolves.toEqual({ok: true}); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('recognizes a launch flag but respects an explicit command-line override', async () => { + vi.stubEnv('HTTPS_PROXY', 'http://proxy.invalid:8080'); + vi.stubEnv('NODE_OPTIONS', '--use-env-proxy'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).resolves.toEqual({}); + process.execArgv.push('--no-use-env-proxy'); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/NODE_USE_ENV_PROXY=1/); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('reports unsupported Node versions before attempting a configured proxy', async () => { + vi.stubEnv('NODE_USE_ENV_PROXY', '1'); + vi.stubEnv('HTTPS_PROXY', 'http://proxy.invalid:8080'); + const fetchImpl = vi.fn(async () => new Response('{}')); + for (const version of ['22.0.0', '22.20.0', '23.11.0']) { + Object.defineProperty(process.versions, 'node', {...nodeVersion, value: version}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/22\.21|24/); + } + expect(fetchImpl).not.toHaveBeenCalled(); + for (const version of ['22.21.0', '24.0.0', '24.5.0']) { + Object.defineProperty(process.versions, 'node', {...nodeVersion, value: version}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).resolves.toEqual({}); + } + }); +}); + +describe('external response lifetime', () => { + it('uses resolved IP addresses while preserving the HTTPS hostname and Host header', async () => { + let host: string | undefined; + const server = createHttpsServer({key: fixtureKey, cert: fixtureCertificate}, (request, response) => { + host = request.headers.host; + response.end('{"route":"resolved"}'); + }); + const bound = new URL(await start(server)); + const resolve4 = vi.fn(async () => ['127.0.0.1']); + const resolve6 = vi.fn(async () => []); + + const response = await fetchHttpsWithSystemResolver( + `https://localhost:${bound.port}/json/stats`, + {headers: {Accept: 'application/json'}}, + {resolve4, resolve6, ca: fixtureCertificate} + ); + + await expect(response.json()).resolves.toEqual({route: 'resolved'}); + expect(response.status).toBe(200); + expect(host).toBe(`localhost:${bound.port}`); + expect(resolve4).toHaveBeenCalledWith('localhost'); + expect(resolve6).toHaveBeenCalledWith('localhost'); + }); + + it('keeps certificate verification enabled for resolved-address HTTPS requests', async () => { + let requests = 0; + const server = createHttpsServer({key: fixtureKey, cert: fixtureCertificate}, (_request, response) => { + requests += 1; + response.end('{}'); + }); + const bound = new URL(await start(server)); + const runtime = {resolve4: async () => ['127.0.0.1'], resolve6: async () => []}; + + await expect(fetchHttpsWithSystemResolver(`https://localhost:${bound.port}`, {}, runtime)) + .rejects.toMatchObject({code: 'DEPTH_ZERO_SELF_SIGNED_CERT'}); + expect(requests).toBe(0); + }); + + it('keeps the deadline alive after real response headers while the body is stalled', async () => { + const server = createServer((_request, response) => {response.writeHead(200, {'content-type': 'application/json'});response.flushHeaders();response.write('{');}); + const url = await start(server); + let receivedHeaders = false; + const started = Date.now(); + await expect(withExternalResponse(url, {timeoutMs: 1000}, response => { + receivedHeaders = true; + return response.json(); + })).rejects.toThrow(/timed out/i); + expect(receivedHeaders).toBe(true); + expect(Date.now() - started).toBeLessThan(4500); + }); + + it('cancels an unused body after a status-only callback', async () => { + const cancel = vi.fn(); + const fetchImpl = async () => new Response(new ReadableStream({cancel}), {status: 503}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.status)).resolves.toBe(503); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('cancels an unused body when the consumer rejects the response', async () => { + const cancel = vi.fn(); + const fetchImpl = async () => new Response(new ReadableStream({cancel}), {status: 503}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, () => {throw new Error('bad status');})).rejects.toThrow('bad status'); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('bounds the callback even when a custom fetch implementation does not react to abort', async () => { + vi.useFakeTimers(); + const cancel = vi.fn(); + const fetchImpl = async () => new Response(new ReadableStream({cancel})); + const outcome = withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, () => new Promise(() => {})).then(() => null, error => error as Error); + await vi.advanceTimersByTimeAsync(100); + expect((await outcome)?.message).toMatch(/timed out/i); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('preserves caller cancellation', async () => { + const controller = new AbortController(); + const reason = new Error('caller cancelled'); + controller.abort(reason); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl, init: {signal: controller.signal}}, response => response.json())).rejects.toBe(reason); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('cancels a late response without invoking its consumer after the deadline', async () => { + vi.useFakeTimers(); + let deliver!: (response: Response) => void; + const fetchImpl = () => new Promise(resolve => {deliver = resolve;}); + const consume = vi.fn((response: Response) => response.status); + const outcome = withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, consume).catch(error => error as Error); + await vi.advanceTimersByTimeAsync(100); + expect(await outcome).toMatchObject({message: expect.stringMatching(/timed out/i)}); + const cancel = vi.fn(); + deliver(new Response(new ReadableStream({cancel}))); + await vi.advanceTimersByTimeAsync(0); + expect(consume).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('preserves TLS certificate verification against a real self-signed server', async () => { + let requests = 0; + const server = createHttpsServer({key: fixtureKey, cert: fixtureCertificate}, (_request, response) => {requests += 1;response.end('{}');}); + const url = (await start(server)).replace('http:', 'https:'); + await expect(withExternalResponse(url, {timeoutMs: 1000}, response => response.json())).rejects.toMatchObject({cause: {code: 'DEPTH_ZERO_SELF_SIGNED_CERT'}}); + expect(requests).toBe(0); + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined(); + }); + + it.skipIf(!process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))('uses the native HTTP proxy in a fresh Node process', async () => { + let originProxyAuthorization: string | undefined; + const origin = createServer((request, response) => {originProxyAuthorization = request.headers['proxy-authorization'];response.end('{"route":"origin"}');}); + const destination = await start(origin); + let proxyConnections = 0; + let authenticatedProxy = false; + let proxyTarget: string | undefined; + // Native fetch may use absolute-form HTTP requests or a CONNECT tunnel. + const proxy = createServer((request, response) => { + proxyConnections += 1; + authenticatedProxy = request.headers['proxy-authorization'] === `Basic ${Buffer.from('test-user:test-password').toString('base64')}`; + proxyTarget = new URL(request.url!).origin; + const headers = {...request.headers}; + delete headers['proxy-authorization']; + const upstream = requestHttp(request.url!, {method: request.method, headers, agent: false}, upstreamResponse => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }); + upstream.on('socket', socket => sockets.add(socket)); + upstream.on('error', error => response.destroy(error)); + request.on('error', error => upstream.destroy(error)); + request.pipe(upstream); + }); + proxy.on('connect', (request, socket, head) => { + proxyConnections += 1; + authenticatedProxy = request.headers['proxy-authorization'] === `Basic ${Buffer.from('test-user:test-password').toString('base64')}`; + proxyTarget = `http://${request.url}`; + sockets.add(socket); + const upstream = connect(Number(new URL(destination).port), '127.0.0.1', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length) upstream.write(head); + upstream.pipe(socket); + socket.pipe(upstream); + }); + sockets.add(upstream); + socket.on('error', () => upstream.destroy()); + upstream.on('error', () => socket.destroy()); + }); + const proxyUrl = new URL(await start(proxy)); + proxyUrl.username = 'test-user'; + proxyUrl.password = 'test-password'; + const env = {...process.env}; + for (const key of Object.keys(env)) if (/^(?:https?|all|no)_proxy$/i.test(key) || key === 'NODE_OPTIONS') delete env[key]; + env.NODE_USE_ENV_PROXY = '1'; + env.HTTP_PROXY = proxyUrl.href; + env.NO_PROXY = ''; + const source = `import {withExternalResponse} from ${JSON.stringify(new URL('./network.ts', import.meta.url).href)};console.log(JSON.stringify(await withExternalResponse(${JSON.stringify(destination)}, {timeoutMs: 2000}, response => response.json())));`; + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', source], {env, timeout: 5000}); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => {stdout += String(chunk);}); + child.stderr.on('data', chunk => {stderr += String(chunk);}); + child.once('error', reject); + child.once('close', code => code === 0 ? resolve(stdout) : reject(new Error(`Proxy probe exited ${code}: ${stderr}`))); + }); + expect(JSON.parse(result)).toEqual({route: 'origin'}); + expect(proxyConnections).toBe(1); + expect(proxyTarget).toBe(destination); + expect(authenticatedProxy).toBe(true); + expect(originProxyAuthorization).toBeUndefined(); + }); +}); + +async function start(server: Server): Promise { + servers.push(server); + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, '127.0.0.1', resolve);}); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + return `http://127.0.0.1:${address.port}`; +} + +// Public test-only key and self-signed certificate; never used outside this listener. +const fixtureKey = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdlaXqgyzfUEvYbyF +RQfTLHuSl6q9hS1Xei3+nXI+RaqhRANCAASEdb1PI+/xQa9NAqepSjOLpJTpWDyV +Bhw6kqUXtZJVSTPyFFo5z9DrziH6UnxATozGgJv27wWRJoboTqzHTHr+ +-----END PRIVATE KEY-----`; +const fixtureCertificate = `-----BEGIN CERTIFICATE----- +MIIBfTCCASOgAwIBAgIUCUcRiI7iLHzUheIbTonNEDx6QO8wCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkwNzIzMTYyNVoXDTM2MDkwNDIz +MTYyNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEhHW9TyPv8UGvTQKnqUozi6SU6Vg8lQYcOpKlF7WSVUkz8hRaOc/Q684h ++lJ8QE6MxoCb9u8FkSaG6E6sx0x6/qNTMFEwHQYDVR0OBBYEFCWgV7aRyaZ6Tf19 +5SYahE4kmBRlMB8GA1UdIwQYMBaAFCWgV7aRyaZ6Tf195SYahE4kmBRlMA8GA1Ud +EwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIhAIJO7DSRzV+s7lDMftcC7CwU +tZVZiikVcdL2DYCLTu1TAiBjJblbBFao0A6d/BrkpMbn6M/JdApiza4cxQW520VZ +vQ== +-----END CERTIFICATE-----`; diff --git a/src/platform/network.ts b/src/platform/network.ts new file mode 100644 index 0000000..c7c2a48 --- /dev/null +++ b/src/platform/network.ts @@ -0,0 +1,296 @@ +import {resolve4, resolve6} from 'node:dns/promises'; +import {request as requestHttps, type RequestOptions as HttpsRequestOptions} from 'node:https'; + +type FetchLike = (input: string | URL, init?: RequestInit) => Promise; +type ExternalRequestOptions = {timeoutMs: number; init?: RequestInit; fetchImpl?: FetchLike}; +type ResolvedAddress = {address: string; family: 4 | 6}; +type ResolverRuntime = { + resolve4?: (hostname: string) => Promise; + resolve6?: (hostname: string) => Promise; + request?: typeof requestHttps; + ca?: HttpsRequestOptions['ca']; +}; + +const maxResolvedResponseBytes = 128 * 1024 * 1024; + +export function networkPolicy(env: NodeJS.ProcessEnv = process.env): {offline: boolean; lowBandwidth: boolean} { + return {offline: env.RADIOCLI_OFFLINE === '1', lowBandwidth: env.RADIOCLI_LOW_BANDWIDTH === '1'}; +} + +/** A direct connection must never silently bypass an operator-configured proxy. */ +export function allowsDirectDnsFallback(env: NodeJS.ProcessEnv = process.env): boolean { + return !['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY'] + .some(name => Boolean(env[name]?.trim())); +} + +/** Configuration preflight only: no network request or proxy credentials. */ +export function networkDiagnostic() { + const policy = networkPolicy(); + if (policy.offline) return {...policy, status: 'offline', message: 'Public directory and update requests are disabled; cached data and direct stream URLs remain usable.'}; + try { + validateProxyConfiguration(new URL('http://example.invalid')); + validateProxyConfiguration(new URL('https://example.invalid')); + return {...policy, status: 'configured', message: 'Network configuration accepted; reachability and TLS are checked per request. External player networking is separate.'}; + } catch (error) { + return {...policy, status: 'unavailable', message: error instanceof Error ? error.message : 'Network configuration is invalid.'}; + } +} + +/** Keep the request deadline active through body consumption, including errors. */ +export async function withExternalResponse( + url: string | URL, + {timeoutMs, init = {}, fetchImpl = fetch}: ExternalRequestOptions, + consume: (response: Response) => T | Promise +): Promise { + if (networkPolicy().offline) throw new Error('RadioCLI is offline (RADIOCLI_OFFLINE=1).'); + validateProxyConfiguration(new URL(url)); + init.signal?.throwIfAborted(); + + const controller = new AbortController(); + const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal; + let onAbort: () => void; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, {once: true}); + }); + const timeout = setTimeout(() => controller.abort(new Error(`Network request timed out after ${timeoutMs} ms.`)), timeoutMs); + let response: Response | undefined; + const work = async () => { + response = await fetchImpl(url, {...init, signal}); + try { + // A custom fetch can resolve after the deadline without observing abort. + signal.throwIfAborted(); + return await consume(response); + } finally { + cancelBody(response); + } + }; + try { + return await Promise.race([work(), aborted]); + } finally { + clearTimeout(timeout); + signal.removeEventListener('abort', onAbort!); + controller.abort(); + if (response) cancelBody(response); + } +} + +/** + * HTTPS fetch fallback for systems where libc getaddrinfo() reports EAI_AGAIN + * but Node's DNS-protocol resolver can still reach the configured nameserver. + * The original hostname remains the HTTP Host and TLS SNI identity, so normal + * certificate-chain and hostname verification remain enabled. + */ +export async function fetchHttpsWithSystemResolver( + input: string | URL, + init: RequestInit = {}, + runtime: ResolverRuntime = {} +): Promise { + const url = new URL(input); + if (url.protocol !== 'https:') throw new Error('The DNS fallback only supports HTTPS requests.'); + if (url.username || url.password) throw new Error('The DNS fallback does not accept URL credentials.'); + const method = (init.method ?? 'GET').toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') throw new Error('The DNS fallback only supports GET and HEAD requests.'); + if (init.body) throw new Error('The DNS fallback does not accept a request body.'); + init.signal?.throwIfAborted(); + + const addresses = await resolveHttpsAddresses(url.hostname, init.signal, runtime); + let lastError: Error | null = null; + for (const address of addresses) { + try { + return await requestResolvedHttps(url, address, init, runtime, method); + } catch (error) { + if (init.signal?.aborted) throw init.signal.reason; + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + throw lastError ?? new Error(`DNS did not return an address for ${url.hostname}.`); +} + +async function resolveHttpsAddresses( + hostname: string, + signal: AbortSignal | null | undefined, + runtime: ResolverRuntime +): Promise { + let lastErrors: unknown[] = []; + for (let attempt = 0; attempt < 2; attempt += 1) { + const results = await raceAbort( + Promise.allSettled([ + (runtime.resolve4 ?? resolve4)(hostname), + (runtime.resolve6 ?? resolve6)(hostname) + ]), + signal + ); + const addresses = [ + ...(results[0].status === 'fulfilled' ? results[0].value.map(address => ({address, family: 4 as const})) : []), + ...(results[1].status === 'fulfilled' ? results[1].value.map(address => ({address, family: 6 as const})) : []) + ]; + if (addresses.length > 0) return dedupeAddresses(addresses); + + lastErrors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []); + if (attempt === 1 || !lastErrors.some(isTemporaryDnsError)) break; + await abortableDelay(100, signal); + } + + const lastError = lastErrors.find(value => value instanceof Error); + throw lastError instanceof Error ? lastError : new Error(`DNS did not return an address for ${hostname}.`); +} + +function requestResolvedHttps( + url: URL, + address: ResolvedAddress, + init: RequestInit, + runtime: ResolverRuntime, + method: string +): Promise { + return new Promise((resolve, reject) => { + const headers = new Headers(init.headers); + if (!headers.has('host')) headers.set('host', url.host); + if (!headers.has('accept-encoding')) headers.set('accept-encoding', 'identity'); + const request = (runtime.request ?? requestHttps)({ + hostname: address.address, + family: address.family, + port: url.port ? Number(url.port) : 443, + path: `${url.pathname}${url.search}`, + method, + headers: Object.fromEntries(headers.entries()), + servername: url.hostname, + signal: init.signal ?? undefined, + ca: runtime.ca, + agent: false + }, response => { + const chunks: Buffer[] = []; + let bytes = 0; + response.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > maxResolvedResponseBytes) { + response.destroy(new Error('HTTPS response exceeded the 128 MiB safety limit.')); + return; + } + chunks.push(buffer); + }); + response.once('error', reject); + response.once('aborted', () => reject(new Error('HTTPS response ended before completion.'))); + response.once('end', () => { + const responseHeaders = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) { + for (const item of value) responseHeaders.append(name, item); + } else if (value !== undefined) { + responseHeaders.set(name, value); + } + } + const status = response.statusCode ?? 500; + const body = method === 'HEAD' || status === 204 || status === 205 || status === 304 + ? null + : Buffer.concat(chunks); + resolve(new Response(body, { + status, + statusText: response.statusMessage, + headers: responseHeaders + })); + }); + }); + request.once('error', reject); + request.end(); + }); +} + +function dedupeAddresses(addresses: ResolvedAddress[]): ResolvedAddress[] { + const seen = new Set(); + return addresses.filter(({address, family}) => { + const key = `${family}:${address}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function isTemporaryDnsError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EAI_AGAIN'; +} + +function raceAbort(promise: Promise, signal: AbortSignal | null | undefined): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, {once: true}); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + +function abortableDelay(ms: number, signal: AbortSignal | null | undefined): Promise { + if (!signal) return new Promise(resolve => setTimeout(resolve, ms)); + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(done, ms); + const onAbort = () => done(signal.reason); + signal.addEventListener('abort', onAbort, {once: true}); + function done(error?: unknown) { + clearTimeout(timeout); + signal!.removeEventListener('abort', onAbort); + if (error) reject(error); + else resolve(); + } + }); +} + +function cancelBody(response: Response): void { + // Cancellation is best-effort and must not extend the request deadline. A + // locked native body is interrupted by the request's AbortController instead. + if (response.body && !response.body.locked) void response.body.cancel().catch(() => {}); +} + +function validateProxyConfiguration(url: URL): void { + const http = proxyVariable('http_proxy', 'HTTP_PROXY'); + const https = proxyVariable('https_proxy', 'HTTPS_PROXY'); + for (const proxy of [http, https]) { + if (!proxy.value) continue; + let parsed: URL; + try { + if (/[\r\n]/.test(proxy.value)) throw new Error(); + parsed = new URL(proxy.value); + decodeURIComponent(parsed.username); + decodeURIComponent(parsed.password); + } catch { + // URL parse errors can contain credentials. Do not attach the raw cause. + throw new Error(`${proxy.name} contains an invalid proxy URL.`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`${proxy.name} uses an unsupported proxy protocol. This Node HTTP client supports HTTP(S) proxies; SOCKS proxies are unavailable.`); + } + } + + // Native fetch falls back to HTTP_PROXY for HTTPS when HTTPS_PROXY is unset. + const selected = url.protocol === 'https:' && https.value ? https : http; + if (!selected.value) { + const all = proxyVariable('all_proxy', 'ALL_PROXY'); + if (all.value) throw new Error(`${all.name} is not supported by this Node HTTP client. Configure HTTP_PROXY or HTTPS_PROXY with an HTTP(S) proxy; SOCKS proxies are unavailable.`); + return; + } + + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number); + if (major < 24 && !(major === 22 && minor >= 21)) { + throw new Error('Native HTTP(S) proxy support requires Node 22.21+ or Node 24+.'); + } + let enabled = process.env.NODE_USE_ENV_PROXY === '1'; + const environmentFlags = (process.env.NODE_OPTIONS ?? '').match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + for (const raw of [...environmentFlags, ...process.execArgv]) { + const flag = raw.replace(/^["']|["']$/g, ''); + if (flag === '--use-env-proxy') enabled = true; + if (flag === '--no-use-env-proxy') enabled = false; + } + if (!enabled) { + throw new Error(`${selected.name} is configured. Start RadioCLI with NODE_USE_ENV_PROXY=1 to enable the native HTTP(S) proxy.`); + } + // Node configures its dispatcher at startup. Do not mutate proxy environment + // variables or install a global dispatcher here; local IPC has a direct agent. +} + +function proxyVariable(lower: string, upper: string): {name: string; value: string | undefined} { + const name = process.env[lower] !== undefined ? lower : upper; + return {name, value: process.env[name]}; +} diff --git a/src/platform/packages.test.ts b/src/platform/packages.test.ts new file mode 100644 index 0000000..50779e1 --- /dev/null +++ b/src/platform/packages.test.ts @@ -0,0 +1,126 @@ +import {describe, expect, it} from 'vitest'; +import {detectPackageManager, ffplayInstallCommand, mpvInstallCommand, packageCommandInvocation, packageInstallCommand, packageManagerNeedsRoot, packageManagerNotes, platformLabel} from './packages.js'; +import {identifyPlatform} from './runtime.js'; + +describe('native package plans', () => { + it.each(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'sunos', 'haiku', 'android'] as const)( + 'gives manual guidance instead of an unavailable installer on %s', platform => { + const options = {hasCommand: () => false, getUid: () => 1000}; + for (const hint of [mpvInstallCommand(platform, 'ID=ubuntu', {}, options), ffplayInstallCommand(platform, 'ID=ubuntu', {}, options)]) { + expect(hint).toMatch(/manual|manually/); + expect(hint).not.toMatch(/(?:brew|winget|sudo|doas|pkg|pkgin|pkgman) (?:install|add|-I|-y)/); + } + } + ); + + it('does not suggest a privilege helper that is missing or needed by root', () => { + const hasCommand = (command: string): boolean => command === 'apt'; + expect(mpvInstallCommand('linux', 'ID=debian', {}, {hasCommand, getUid: () => 1000})).toBe('apt-get install -y mpv (run as root; sudo/doas unavailable)'); + expect(mpvInstallCommand('linux', 'ID=debian', {}, {hasCommand, getUid: () => 0})).toBe('apt-get install -y mpv'); + }); + + it('keeps Termux prerequisites explicit when pkg is absent or the caller is root', () => { + const env = {TERMUX_VERSION: '0.118.3'}; + expect(mpvInstallCommand('android', '', env, {hasCommand: () => false})).toContain('manually'); + const ffplay = ffplayInstallCommand('android', '', env, {hasCommand: () => false}); + expect(ffplay).toContain('x11-repo'); + expect(ffplay).toContain('configured display/audio'); + expect(ffplay).not.toContain('pkg install'); + expect(mpvInstallCommand('android', '', env, {hasCommand: () => true, getUid: () => 0})).toContain('normal Termux app user, without root'); + }); + + it.each([ + ['freebsd', 'pkg'], ['openbsd', 'pkg_add'], ['netbsd', 'pkgin'], ['sunos', 'pkgin'], ['haiku', 'pkgman'] + ] as const)('selects %s package tools without confusing other Unix commands', (platform, expected) => { + expect(detectPackageManager(platform, '', () => true)).toBe(expected); + expect(detectPackageManager(platform, '', () => false)).toBeNull(); + }); + + it('does not treat the illumos IPS pkg command as FreeBSD pkg', () => { + expect(detectPackageManager('sunos', '', command => command === 'pkg')).toBeNull(); + }); + + it.each(['android', 'linux'] as const)('keeps Termux pkg distinct on %s and never falls back to a Linux installer', platform => { + const env = {TERMUX_VERSION: '0.118.3', PREFIX: '/data/data/com.termux/files/usr'}; + expect(detectPackageManager(platform, 'ID=debian', () => true, env)).toBe('termux-pkg'); + expect(detectPackageManager(platform, 'ID=debian', command => command === 'apt', env)).toBeNull(); + expect(platformLabel(platform, '', env)).toBe('Android / Termux'); + }); + + it('recognizes the shared Termux prefix without accepting pkg on ordinary Linux or Android', () => { + expect(detectPackageManager('linux', '', command => command === 'pkg', {PREFIX: '/data/user/10/com.termux/files/usr'})).toBe('termux-pkg'); + expect(detectPackageManager('linux', '', command => command === 'pkg', {})).toBeNull(); + expect(detectPackageManager('android', '', () => true, {})).toBeNull(); + expect(detectPackageManager('freebsd', '', () => true, {TERMUX_VERSION: '0.118.3'})).toBe('pkg'); + }); + + it('requires a manual AIX playback installation even when DNF is installed', () => { + expect(detectPackageManager('aix', '', () => true, {})).toBeNull(); + expect(mpvInstallCommand('aix', '', {})).toContain('no verified AIX'); + expect(ffplayInstallCommand('aix', '', {})).toContain('no verified AIX'); + expect(packageManagerNotes(null, identifyPlatform({platform: 'aix', env: {}})).join(' ')).toContain('RADIOCLI_MPV_PATH'); + }); + + it.each(['sudo', 'doas', null] as const)('does not elevate Termux or Haiku package commands with %s', elevation => { + expect(packageInstallCommand('termux-pkg', 'mpv', {elevation})).toMatchObject({program: 'pkg', args: ['install', '-y', 'mpv']}); + expect(packageInstallCommand('pkgman', 'mpv', {elevation})).toMatchObject({program: 'pkgman', args: ['install', '-y', 'mpv']}); + expect(packageManagerNeedsRoot('termux-pkg')).toBe(false); + expect(packageManagerNeedsRoot('pkgman')).toBe(false); + }); + + it('plans only verified Termux and Haiku components', () => { + expect(packageInstallCommand('termux-pkg', 'ffmpeg')).toBeNull(); + expect(packageInstallCommand('termux-pkg', 'vlc')).toBeNull(); + expect(packageInstallCommand('pkgman', 'ffmpeg')).toMatchObject({program: 'pkgman', args: ['install', '-y', 'ffmpeg8_tools']}); + expect(packageInstallCommand('pkgman', 'vlc')).toBeNull(); + expect(packageManagerNotes('termux-pkg').join(' ')).toContain('x11-repo'); + expect(packageManagerNotes('pkgman').join(' ')).toMatch(/Node 20.*Node 22/); + }); + + it('keeps the printed installation hints consistent with each verified route', () => { + const termux = {TERMUX_VERSION: '0.118.3'}; + const options = {hasCommand: () => true, getUid: () => 1000}; + expect(mpvInstallCommand('android', '', termux, options)).toBe('pkg install -y mpv'); + expect(ffplayInstallCommand('linux', '', termux, options)).toContain('x11-repo'); + expect(mpvInstallCommand('haiku', '', {}, options)).toBe('pkgman install -y mpv'); + expect(ffplayInstallCommand('haiku', '', {}, options)).toBe('pkgman install -y ffmpeg8_tools'); + expect(mpvInstallCommand('sunos', '', {}, options)).toContain('pkgin -y install mpv'); + expect(ffplayInstallCommand('sunos', '', {}, options)).toContain('matching pkgsrc'); + }); + + it('uses verified noninteractive BSD syntax', () => { + expect(packageInstallCommand('pkg', 'mpv')).toMatchObject({program: 'sudo', args: ['pkg', 'install', '-y', 'mpv']}); + expect(packageInstallCommand('pkg_add', 'mpv', {elevation: 'doas'})).toMatchObject({program: 'doas', args: ['pkg_add', '-I', 'mpv']}); + expect(packageInstallCommand('pkgin', 'mpv', {elevation: null})).toMatchObject({program: 'pkgin', args: ['-y', 'install', 'mpv']}); + expect(packageInstallCommand('pkgin', 'ffmpeg')).toBeNull(); + }); + + it('uses the available privilege boundary for non-systemd Linux', () => { + expect(packageInstallCommand('apk', 'mpv', {elevation: 'doas'})).toMatchObject({program: 'doas', args: ['apk', 'add', 'mpv']}); + expect(packageInstallCommand('apt', 'mpv', {elevation: null})).toMatchObject({program: 'apt-get', args: ['install', '-y', 'mpv']}); + }); + + it('executes a discovered package manager path through the selected privilege helper', () => { + const plan = packageInstallCommand('pkgin', 'mpv', {elevation: 'doas'})!; + const paths: Record = {pkgin: '/opt/local/bin/pkgin', doas: '/usr/local/bin/doas'}; + expect(packageCommandInvocation(plan, 'sunos', command => paths[command] ?? null)).toEqual({ + program: '/usr/local/bin/doas', args: ['/opt/local/bin/pkgin', '-y', 'install', 'mpv'] + }); + expect(plan.display).toBe('doas pkgin -y install mpv'); + expect(packageCommandInvocation(packageInstallCommand('termux-pkg', 'mpv')!, 'android', command => command === 'pkg' ? '/data/data/com.termux/files/usr/bin/pkg' : null)).toEqual({ + program: '/data/data/com.termux/files/usr/bin/pkg', args: ['install', '-y', 'mpv'] + }); + }); + + it('uses an encoded PowerShell transport for Windows script shims', () => { + const plan = packageInstallCommand('scoop', 'mpv')!; + const windows = packageCommandInvocation(plan, 'win32'); + expect(windows.program).toBe('powershell.exe'); + expect(windows.args).toContain('-EncodedCommand'); + expect(windows.args).not.toContain('/c'); + const script = Buffer.from(windows.args.at(-1)!, 'base64').toString('utf16le'); + const payload = JSON.parse(Buffer.from(/FromBase64String\('([^']+)'\)/.exec(script)![1]!, 'base64').toString('utf8')); + expect(payload).toEqual({command: 'scoop', args: ['install', 'mpv'], environment: {}}); + expect(packageCommandInvocation(plan, 'linux')).toEqual({program: 'scoop', args: ['install', 'mpv']}); + }); +}); diff --git a/src/platform/packages.ts b/src/platform/packages.ts new file mode 100644 index 0000000..a38e51f --- /dev/null +++ b/src/platform/packages.ts @@ -0,0 +1,215 @@ +import {commandExists} from './executables.js'; +import {powershellCommand} from './shell.js'; +import {identifyPlatform, readLinuxOsRelease, type PlatformProfile} from './runtime.js'; + +export type SetupComponent = 'mpv' | 'ffmpeg' | 'vlc'; +export const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper', 'pkg', 'pkg_add', 'pkgin', 'termux-pkg', 'pkgman'] as const; +export type SetupPackageManager = typeof packageManagers[number]; + +export type SetupCommand = { + component: SetupComponent | null; + label: string; + program: string; + args: string[]; + display: string; +}; + +/** FreeBSD pkg and Termux pkg have distinct recipes and privilege models. */ +export function packageManagerProgram(manager: SetupPackageManager): string { + return manager === 'termux-pkg' ? 'pkg' : manager; +} + +/** Package names/options are selected from the recipes below, never shell input. */ +export function packageCommandInvocation( + command: SetupCommand, + platform: NodeJS.Platform, + resolve: (command: string) => string | null = () => null +): {program: string; args: string[]} { + const program = resolve(command.program) ?? command.program; + const args = (command.program === 'sudo' || command.program === 'doas') && command.args[0] + ? [resolve(command.args[0]) ?? command.args[0], ...command.args.slice(1)] + : command.args; + return platform === 'win32' + ? {program: resolve('powershell.exe') ?? 'powershell.exe', args: powershellCommand([program, ...args])} + : {program, args}; +} + +export function detectPackageManager( + platform: NodeJS.Platform, + osRelease: string, + hasCommand: (command: string) => boolean = commandExists, + env: NodeJS.ProcessEnv = process.env +): SetupPackageManager | null { + const host = identifyPlatform({platform, osRelease, env}); + if (host.id === 'termux') return hasCommand('pkg') ? 'termux-pkg' : null; + if (platform === 'darwin') return hasCommand('brew') ? 'brew' : null; + if (platform === 'win32') return firstAvailable(['winget', 'scoop', 'choco'], hasCommand); + if (platform === 'freebsd') return hasCommand('pkg') ? 'pkg' : null; + if (platform === 'openbsd') return hasCommand('pkg_add') ? 'pkg_add' : null; + // pkg on Solaris/illumos is IPS, not the FreeBSD installer. + if (platform === 'netbsd' || platform === 'sunos') return hasCommand('pkgin') ? 'pkgin' : null; + if (platform === 'haiku') return hasCommand('pkgman') ? 'pkgman' : null; + if (platform !== 'linux') return null; + + const ids = linuxReleaseIds(osRelease); + const preferred: SetupPackageManager[] = hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop']) + ? ['apt'] + : hasAny(ids, ['fedora', 'rhel', 'centos']) + ? ['dnf'] + : hasAny(ids, ['arch', 'manjaro']) + ? ['pacman'] + : hasAny(ids, ['alpine']) + ? ['apk'] + : hasAny(ids, ['opensuse', 'suse']) + ? ['zypper'] + : ['apt', 'dnf', 'pacman', 'apk', 'zypper']; + return firstAvailable(preferred, hasCommand); +} + +export function packageInstallCommand( + manager: SetupPackageManager, + component: SetupComponent, + {elevation = 'sudo'}: {elevation?: 'sudo' | 'doas' | null} = {} +): SetupCommand | null { + const packages: Partial>>> = { + winget: {mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC'}, + // pkgsrc FFmpeg/ffplay packages and executables carry a major version. + // Choose an available matching pair manually instead of guessing a catalog. + pkgin: {mpv: 'mpv', vlc: 'vlc'}, + // Termux's main ffmpeg package omits ffplay. Enabling x11-repo and its + // display/audio services is an explicit manual step, not a setup side effect. + 'termux-pkg': {mpv: 'mpv'}, + pkgman: {mpv: 'mpv', ffmpeg: 'ffmpeg8_tools'} + }; + const packageName = packages[manager] ? packages[manager][component] : component; + if (!packageName) return null; + let program = packageManagerProgram(manager); + let args: string[]; + + if (manager === 'brew') args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])]; + else if (manager === 'winget') args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements']; + else if (manager === 'scoop') args = ['install', packageName]; + else if (manager === 'choco') args = ['install', packageName, '-y']; + else if (manager === 'apt') { program = 'apt-get'; args = ['install', '-y', packageName]; } + else if (manager === 'pacman') args = ['-S', '--needed', '--noconfirm', packageName]; + else if (manager === 'apk') args = ['add', packageName]; + else if (manager === 'zypper') args = ['--non-interactive', 'install', packageName]; + else if (manager === 'dnf' || manager === 'pkg' || manager === 'termux-pkg' || manager === 'pkgman') args = ['install', '-y', packageName]; + else if (manager === 'pkg_add') args = ['-I', packageName]; + else args = ['-y', 'install', packageName]; + + if (packageManagerNeedsRoot(manager) && elevation) { + args = [program, ...args]; + program = elevation; + } + return {component, label: componentLabel(component), program, args, display: [program, ...args].join(' ')}; +} + +export function packageManagerNeedsRoot(manager: SetupPackageManager): boolean { + return !['brew', 'winget', 'scoop', 'choco', 'termux-pkg', 'pkgman'].includes(manager); +} + +export function packageManagerNotes(manager: SetupPackageManager | null, host: PlatformProfile = identifyPlatform()): string[] { + if (manager === 'termux-pkg' || (!manager && host.id === 'termux')) return [ + 'Termux pkg runs as the normal app user, without sudo or root. Use --package-manager=termux-pkg to select this recipe explicitly.', + 'The Termux ffmpeg package does not include ffplay. The separate ffplay package needs x11-repo and a configured display/audio environment; mpv is the recommended audio player.', + 'Android clipboard integration requires the termux-api package and a compatible Termux:API app; helper execution can still fail if Android denies access.' + ]; + if (manager === 'pkgman' || (!manager && host.id === 'haiku')) return [ + 'The verified HaikuPorts runtime recipe provides Node 20, below RadioCLI\'s Node 22 minimum. These playback recipes do not resolve that runtime blocker; a separate working Node 22+ port is required.', + 'Haiku ffmpeg8_tools provides ffmpeg and ffplay. A secondary-architecture install may require explicit RADIOCLI_FFMPEG_PATH and RADIOCLI_FFPLAY_PATH values.' + ]; + if (host.id === 'aix') return ['There is no verified AIX mpv or ffplay package recipe. Provision a working native player manually and set RADIOCLI_MPV_PATH or RADIOCLI_FFPLAY_PATH; IBM Toolbox DNF availability does not establish a playback package.']; + if (manager === 'pkg') return ['FreeBSD ffmpeg provides ffplay only when built with SDL; verify the executable separately.']; + if (manager === 'pkgin' || host.id === 'sunos') return [ + ...(host.id === 'sunos' ? ['Solaris/illumos setup uses an existing pkgsrc/pkgin installation. IPS pkg is a different tool and is not used by this recipe.'] : []), + 'pkgsrc uses versioned FFmpeg/ffplay packages. Choose a matching available pair and set RADIOCLI_FFPLAY_PATH to the absolute ffplay executable.' + ]; + return []; +} + +export function platformLabel(platform: NodeJS.Platform, osRelease: string, env: NodeJS.ProcessEnv = process.env): string { + if (identifyPlatform({platform, osRelease, env}).id === 'termux') return 'Android / Termux'; + if (platform === 'darwin') return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`; + if (platform === 'win32') return 'Windows'; + if (platform === 'haiku') return 'Haiku'; + if (platform === 'sunos') return 'Solaris / illumos'; + if (platform === 'aix') return 'AIX'; + if (platform === 'android') return 'Android'; + if (platform === 'linux') return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux'; + return platform; +} + +export function componentLabel(component: SetupComponent): string { + if (component === 'ffmpeg') return 'FFmpeg / ffplay'; + if (component === 'vlc') return 'VLC fallback'; + return 'mpv'; +} + +function firstAvailable(values: T[], hasCommand: (command: string) => boolean): T | null { + return values.find(hasCommand) ?? null; +} + +function linuxReleaseIds(osRelease: string): Set { + const ids = new Set(); + for (const line of osRelease.split('\n')) { + const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); + if (!match) continue; + for (const value of match[2]!.replaceAll('"', '').split(/\s+/)) if (value.trim()) ids.add(value.trim().toLowerCase()); + } + return ids; +} + +function osReleaseValue(osRelease: string, key: string): string { + const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`)); + return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? ''; +} + +function hasAny(values: Set, candidates: string[]): boolean { + return candidates.some(candidate => values.has(candidate)); +} + +export type PackageHintOptions = { + hasCommand?: (command: string) => boolean; + getUid?: () => number | undefined; +}; + +export function packageManagerElevation(hasCommand: (command: string) => boolean, isRoot: boolean): 'sudo' | 'doas' | null { + return isRoot ? null : firstAvailable(['sudo', 'doas'], hasCommand); +} + +export function packageInstallPrerequisites(manager: SetupPackageManager, components: SetupComponent[]): SetupCommand[] { + if (manager !== 'scoop' || !components.some(component => component === 'mpv' || component === 'vlc')) return []; + return [{component: null, label: 'Scoop extras bucket', program: 'scoop', args: ['bucket', 'add', 'extras'], display: 'scoop bucket add extras'}]; +} + +export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env, options: PackageHintOptions = {}): string { + return playbackInstallHint('mpv', platform, osRelease, env, options); +} + +export function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env, options: PackageHintOptions = {}): string { + return playbackInstallHint('ffmpeg', platform, osRelease, env, options); +} + +function playbackInstallHint(component: 'mpv' | 'ffmpeg', platform: NodeJS.Platform, osRelease: string, env: NodeJS.ProcessEnv, {hasCommand = commandExists, getUid = process.getuid}: PackageHintOptions): string { + const host = identifyPlatform({platform, osRelease, env}); + const player = component === 'mpv' ? 'mpv' : 'ffplay'; + if (host.id === 'aix') return `manual native player required; no verified AIX ${player} package recipe (set RADIOCLI_${player.toUpperCase()}_PATH)`; + const manager = detectPackageManager(platform, osRelease, hasCommand, env); + if (component === 'ffmpeg' && host.id === 'termux') return `enable x11-repo manually, then ${manager ? 'pkg install -y ffplay' : 'install the separate ffplay package manually'} (requires configured display/audio); use mpv for audio`; + if (component === 'ffmpeg' && (platform === 'netbsd' || platform === 'sunos')) return 'manually install a matching pkgsrc ffmpegN/ffplayN pair; set RADIOCLI_FFPLAY_PATH'; + + const notes: string[] = []; + if (component === 'ffmpeg' && platform === 'freebsd') notes.push('ffplay requires the SDL build option'); + if (!manager) return [`install ${component === 'mpv' ? 'mpv' : 'FFmpeg'} manually; no supported package manager was found`, ...notes].join('; '); + const isRoot = getUid?.() === 0; + const elevation = packageManagerElevation(hasCommand, isRoot); + const command = packageInstallCommand(manager, component, {elevation}); + if (!command) return `install ${componentLabel(component)} manually`; + if (packageManagerNeedsRoot(manager) && !isRoot && !elevation) notes.unshift('run as root; sudo/doas unavailable'); + if (manager === 'pkgin') notes.push('requires configured pkgsrc'); + if (manager === 'termux-pkg' && isRoot) notes.push('run as the normal Termux app user, without root'); + const prerequisites = packageInstallPrerequisites(manager, [component]); + for (const prerequisite of prerequisites) notes.push(`first run ${prerequisite.display}`); + return command.display + (notes.length ? ` (${notes.join('; ')})` : ''); +} diff --git a/src/platform/paths.test.ts b/src/platform/paths.test.ts new file mode 100644 index 0000000..9d579c5 --- /dev/null +++ b/src/platform/paths.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, it} from 'vitest'; +import {platformPaths} from './paths.js'; + +describe('portable paths and historical compatibility', () => { + it('preserves macOS data/cache paths and the two existing runtime namespaces', () => { + const paths = platformPaths({platform: 'darwin', home: '/Users/Listener', env: {}}); + expect(paths).toMatchObject({ + library: '/Users/Listener/Library/Application Support/radiocli/radiocli.json', + legacyLibrary: '/Users/Listener/Library/Application Support/radio-atlas/radio-atlas.json', + cache: '/Users/Listener/Library/Caches/radiocli/radiocli-cache.json', + legacyCache: '/Users/Listener/Library/Application Support/radio-atlas/radio-atlas-cache.json', + runtime: '/Users/Listener/.local/state/radiocli', + alarmRuntime: '/Users/Listener/Library/Application Support/radiocli/runtime' + }); + }); + + it('uses Windows path semantics even in a mocked host test', () => { + expect(platformPaths({platform: 'win32', home: 'C:\\Users\\Zoë', env: {APPDATA: 'D:\\Roaming Data', LOCALAPPDATA: 'D:\\Local Data'}})).toMatchObject({ + library: 'D:\\Roaming Data\\RadioCLI\\radiocli.json', + cache: 'D:\\Local Data\\RadioCLI\\radiocli-cache.json', + runtime: 'D:\\Local Data\\RadioCLI\\runtime', + alarmRuntime: 'D:\\Local Data\\RadioCLI\\runtime' + }); + }); + + it.each(['linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const)( + 'retains the existing XDG paths on %s', platform => { + expect(platformPaths({platform, home: '/home/Zoë', env: {XDG_DATA_HOME: '/data space', XDG_CACHE_HOME: '/cache space', XDG_RUNTIME_DIR: '/runtime space'}})).toMatchObject({ + library: '/data space/radiocli/radiocli.json', + cache: '/cache space/radiocli/radiocli-cache.json', + runtime: '/runtime space/radiocli', + alarmRuntime: '/runtime space/radiocli' + }); + } + ); + + it('keeps explicit current and legacy home overrides in their original precedence', () => { + expect(platformPaths({platform: 'linux', home: '/home/test', env: {RADIOCLI_HOME: '/radio 新', RADIO_ATLAS_HOME: '/old'}})).toMatchObject({ + library: '/radio 新/radiocli.json', cache: '/radio 新/radiocli-cache.json', runtime: '/radio 新/runtime', alarmRuntime: '/radio 新/runtime' + }); + expect(platformPaths({platform: 'linux', home: '/home/test', env: {RADIO_ATLAS_HOME: '/old'}})).toMatchObject({ + library: '/old/radio-atlas.json', cache: '/old/radio-atlas-cache.json', runtime: '/home/test/.local/state/radiocli' + }); + }); +}); diff --git a/src/platform/paths.ts b/src/platform/paths.ts new file mode 100644 index 0000000..c0e687d --- /dev/null +++ b/src/platform/paths.ts @@ -0,0 +1,44 @@ +import {homedir} from 'node:os'; +import {posix, win32} from 'node:path'; + +type PathInput = {platform?: string; home?: string; env?: NodeJS.ProcessEnv}; + +/** Path selectors form one launch identity, including selectors that are absent. */ +export const pathEnvironmentKeys = [ + 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', + 'RADIOCLI_HOME', 'RADIO_ATLAS_HOME', + 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR' +] as const; + +/** + * Keep historical namespaces stable. In particular, macOS alarm-control/Guard + * files and general agent/occurrence runtime files already use different roots. + * Moving either would strand active authenticated sessions during an upgrade. + */ +export function platformPaths({platform = process.platform, home = homedir(), env = process.env}: PathInput = {}) { + const path = platform === 'win32' ? win32 : posix; + const dataRoot = platform === 'darwin' ? path.join(home, 'Library', 'Application Support') + : platform === 'win32' ? env.APPDATA ?? path.join(home, 'AppData', 'Roaming') + : env.XDG_DATA_HOME ?? path.join(home, '.local', 'share'); + const cacheRoot = platform === 'darwin' ? path.join(home, 'Library', 'Caches') + : platform === 'win32' ? env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local') + : env.XDG_CACHE_HOME ?? path.join(home, '.cache'); + const appName = platform === 'win32' ? 'RadioCLI' : 'radiocli'; + const legacyRoot = platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : env.XDG_DATA_HOME ?? path.join(home, '.local', 'share'); + const legacyCacheRoot = platform === 'darwin' ? legacyRoot : env.XDG_CACHE_HOME ?? path.join(home, '.cache'); + const override = env.RADIOCLI_HOME || env.RADIO_ATLAS_HOME; + const overrideName = env.RADIOCLI_HOME ? 'radiocli' : 'radio-atlas'; + const library = override ? path.join(override, `${overrideName}.json`) : path.join(dataRoot, appName, 'radiocli.json'); + const cache = override ? path.join(override, `${overrideName}-cache.json`) : path.join(cacheRoot, appName, 'radiocli-cache.json'); + const runtime = env.RADIOCLI_HOME ? path.join(env.RADIOCLI_HOME, 'runtime') + : platform === 'win32' ? path.join(cacheRoot, appName, 'runtime') + : path.join(env.XDG_RUNTIME_DIR ?? path.join(home, '.local', 'state'), 'radiocli'); + return { + library, + legacyLibrary: override ? library : path.join(legacyRoot, 'radio-atlas', 'radio-atlas.json'), + cache, + legacyCache: override ? cache : path.join(legacyCacheRoot, 'radio-atlas', 'radio-atlas-cache.json'), + runtime, + alarmRuntime: !env.RADIOCLI_HOME && platform === 'darwin' ? path.join(dataRoot, 'radiocli', 'runtime') : runtime + }; +} diff --git a/src/platform/runtime.test.ts b/src/platform/runtime.test.ts new file mode 100644 index 0000000..1daad8f --- /dev/null +++ b/src/platform/runtime.test.ts @@ -0,0 +1,61 @@ +import {describe, expect, it} from 'vitest'; +import {identifyPlatform, nativeAdapters} from './runtime.js'; + +describe('platform identity and adapter policy', () => { + it.each(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'sunos', 'aix', 'haiku'] as const)( + 'identifies %s without borrowing another operating system identity', platform => { + const host = identifyPlatform({platform, arch: 'x64', env: {}, release: '1', nodeVersion: '22.23.2'}); + expect(host.id).toBe(platform); + expect(host.platform).toBe(platform); + expect(host.isWsl).toBe(false); + } + ); + + it('distinguishes Android/Termux from ordinary Linux and musl', () => { + expect(identifyPlatform({platform: 'android', env: {TERMUX_VERSION: '0.118.3'}})).toMatchObject({id: 'termux', libc: 'bionic'}); + expect(identifyPlatform({platform: 'linux', env: {TERMUX_VERSION: '0.118.3', PREFIX: '/data/data/com.termux/files/usr'}})).toMatchObject({id: 'termux', libc: 'bionic'}); + expect(identifyPlatform({platform: 'android', env: {}})).toMatchObject({id: 'android', libc: 'bionic'}); + expect(identifyPlatform({platform: 'darwin', env: {TERMUX_VERSION: '0.118.3'}}).id).toBe('darwin'); + }); + + it('records WSL and libc independently of Linux adapters', () => { + const host = identifyPlatform({platform: 'linux', release: '6.6.87.2-microsoft-standard-WSL2', libc: 'glibc', env: {}}); + expect(host).toMatchObject({id: 'linux', isWsl: true, libc: 'glibc'}); + expect(nativeAdapters(host).scheduler).toBe('systemd'); + expect(identifyPlatform({platform: 'linux', release: '6.6', libc: 'musl', env: {}})).toMatchObject({isWsl: false, libc: 'musl'}); + }); + + it('records explicit ARM ISA evidence without assuming every arm runtime is ARMv7', () => { + expect(identifyPlatform({platform: 'linux', arch: 'arm', armVersion: 7, env: {}})).toMatchObject({arch: 'arm', armVersion: 7}); + expect(identifyPlatform({platform: 'linux', arch: 'arm', armVersion: null, env: {}}).armVersion).toBeNull(); + expect(identifyPlatform({platform: 'linux', arch: 'arm64', armVersion: 7, env: {}}).armVersion).toBeNull(); + }); + + it('does not invent a native service for BSD, Termux, Haiku, illumos, or AIX', () => { + for (const platform of ['freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const) { + const policy = nativeAdapters(identifyPlatform({platform, env: {}})); + expect(policy.scheduler, platform).toBeNull(); + expect(policy.inhibitor, platform).toBeNull(); + expect(policy.airPlay, platform).toBe(false); + expect(policy.ipc, platform).toBe('unix-socket'); + expect(policy.posixPermissions, platform).toBe(true); + } + }); + + it('preserves the existing macOS, Linux, and Windows adapter selections', () => { + const host = (platform: NodeJS.Platform) => identifyPlatform({platform, env: {}}); + expect(nativeAdapters(host('darwin'))).toMatchObject({scheduler: 'launchd', inhibitor: 'caffeinate', volume: 'macos', terminal: 'macos', airPlay: true, ipc: 'unix-socket'}); + expect(nativeAdapters(host('linux'))).toMatchObject({scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix', airPlay: false, ipc: 'unix-socket'}); + expect(nativeAdapters(host('win32'))).toMatchObject({scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', airPlay: false, ipc: 'named-pipe', posixPermissions: false}); + }); + + it.each(['freebsd', 'openbsd', 'netbsd'])('selects a graphical terminal family independently of scheduling on %s', platform => { + expect(nativeAdapters(identifyPlatform({platform, env: {}}))).toMatchObject({terminal: 'unix', scheduler: null, inhibitor: null}); + }); + + it('keeps unknown runtimes explicit rather than calling them Linux', () => { + const host = identifyPlatform({platform: 'future-os', arch: 'future-cpu', nodeVersion: '24.20.0', env: {}}); + expect(host).toMatchObject({id: 'unknown', platform: 'future-os', arch: 'future-cpu', nodeMajor: 24}); + expect(nativeAdapters(host)).toMatchObject({scheduler: null, inhibitor: null, volume: null, terminal: null, airPlay: false}); + }); +}); diff --git a/src/platform/runtime.ts b/src/platform/runtime.ts new file mode 100644 index 0000000..e35a05a --- /dev/null +++ b/src/platform/runtime.ts @@ -0,0 +1,97 @@ +import {readFileSync} from 'node:fs'; +import {endianness, release} from 'node:os'; + +type PlatformId = 'darwin' | 'win32' | 'linux' | 'freebsd' | 'openbsd' | 'netbsd' | 'android' | 'termux' | 'haiku' | 'sunos' | 'aix' | 'unknown'; +type Libc = 'glibc' | 'musl' | 'bionic' | 'unknown' | 'none'; + +export type PlatformProfile = { + id: PlatformId; + platform: string; + arch: string; + armVersion: number | null; + endianness: 'LE' | 'BE'; + release: string; + nodeVersion: string; + nodeMajor: number; + libc: Libc; + isWsl: boolean; + osRelease: string; +}; + +type PlatformInput = { + platform?: string; + arch?: string; + armVersion?: number | null; + endianness?: 'LE' | 'BE'; + release?: string; + nodeVersion?: string; + libc?: Libc; + env?: NodeJS.ProcessEnv; + osRelease?: string; +}; + +const knownPlatforms = new Set(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix']); + +/** Host facts are independent from service readiness and project support tiers. */ +export function identifyPlatform(input: PlatformInput = {}): PlatformProfile { + const platform = input.platform ?? process.platform; + const env = input.env ?? process.env; + const termux = (platform === 'android' || platform === 'linux') && Boolean(env.TERMUX_VERSION || env.PREFIX?.match(/^\/data\/(?:data|user\/\d+)\/com\.termux(?:\.[^/]+)?\/files\/usr\/?$/)); + const id: PlatformId = termux ? 'termux' : knownPlatforms.has(platform) ? platform as PlatformId : 'unknown'; + const kernelRelease = input.release ?? release(); + const nodeVersion = (input.nodeVersion ?? process.versions.node).replace(/^v/, ''); + const arch = input.arch ?? process.arch; + const armVersion = input.armVersion !== undefined ? input.armVersion + : platform === process.platform && arch === process.arch ? Number(Reflect.get(process.config.variables, 'arm_version')) : null; + return { + id, + platform, + arch, + armVersion: arch === 'arm' && armVersion !== null && Number.isInteger(armVersion) && armVersion > 0 ? armVersion : null, + endianness: input.endianness ?? endianness(), + release: kernelRelease, + nodeVersion, + nodeMajor: Number.parseInt(nodeVersion, 10), + libc: platform === 'android' || termux ? 'bionic' : input.libc ?? (platform === 'linux' ? nativeLibc(platform) : 'none'), + isWsl: id === 'linux' && Boolean(env.WSL_INTEROP || env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(kernelRelease)), + osRelease: input.osRelease ?? (platform === 'linux' && platform === process.platform ? readLinuxOsRelease() : '') + }; +} + +export type NativeAdapterPolicy = { + scheduler: 'launchd' | 'task-scheduler' | 'systemd' | null; + inhibitor: 'caffeinate' | 'logind' | 'windows' | null; + volume: 'macos' | 'windows' | 'unix-audio' | null; + terminal: 'macos' | 'windows' | 'unix' | null; + ipc: 'named-pipe' | 'unix-socket'; + posixPermissions: boolean; + airPlay: boolean; +}; + +/** Selecting a native adapter does not establish that its service is running. */ +export function nativeAdapters(host: PlatformProfile = identifyPlatform()): NativeAdapterPolicy { + const portable: NativeAdapterPolicy = {scheduler: null, inhibitor: null, volume: null, terminal: null, ipc: 'unix-socket', posixPermissions: true, airPlay: false}; + if (host.id === 'darwin') return {...portable, scheduler: 'launchd', inhibitor: 'caffeinate', volume: 'macos', terminal: 'macos', airPlay: true}; + if (host.id === 'win32') return {...portable, scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', ipc: 'named-pipe', posixPermissions: false}; + if (host.id === 'linux') return {...portable, scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix'}; + if (['freebsd', 'openbsd', 'netbsd'].includes(host.id)) return {...portable, terminal: 'unix'}; + return portable; +} + +export function readLinuxOsRelease(platform: string = process.platform): string { + if (platform !== 'linux') return ''; + try { return readFileSync('/etc/os-release', 'utf8'); } + catch { return ''; } +} + +let detectedLibc: Libc | undefined; +function nativeLibc(platform: string): Libc { + if (platform !== process.platform) return 'unknown'; + if (detectedLibc) return detectedLibc; + try { + const report = process.report.getReport() as {header?: {glibcVersionRuntime?: string}; sharedObjects?: string[]}; + detectedLibc = report.header?.glibcVersionRuntime ? 'glibc' + : report.sharedObjects?.some(path => /(?:ld-musl|libc\.musl)/.test(path)) ? 'musl' : 'unknown'; + } catch { detectedLibc = 'unknown'; } + return detectedLibc; +} diff --git a/src/platform/shell.test.ts b/src/platform/shell.test.ts new file mode 100644 index 0000000..f2d7592 --- /dev/null +++ b/src/platform/shell.test.ts @@ -0,0 +1,43 @@ +import {spawn} from 'node:child_process'; +import {describe,expect,it} from 'vitest'; +import {powershellCommand} from './shell.js'; +import {resolveCommandDetails} from './executables.js'; +import {nodeLaunchCommand} from './launch-command.js'; + +describe('PowerShell command transport',()=>{ + it('transports paths, arguments, and environment as data rather than executable source',()=>{ + const values=['C:\\Tools %PATH%! & \' 单播\\scoop.cmd','install','mpv','text "quoted" ; $(throw boom)'];const environment={RADIOCLI_HOME:'C:\\Data %PATH%! & "quoted" \' 单播'}; + const args=powershellCommand(values,environment);expect(args.slice(0,-1)).toEqual(['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand']); + const script=Buffer.from(args.at(-1)!,'base64').toString('utf16le');const encoded=/FromBase64String\('([^']+)'\)/.exec(script)?.[1];expect(encoded).toBeDefined(); + expect(JSON.parse(Buffer.from(encoded!,'base64').toString('utf8'))).toEqual({command:values[0],args:values.slice(1),environment}); + expect(script).not.toContain(values[0]);expect(script).not.toContain(values.at(-1));expect(script).not.toContain(environment.RADIOCLI_HOME);expect(script).toContain('exit $LASTEXITCODE'); + }); + + it('can leave an interactive console open after the child command',()=>{const args=powershellCommand(['node','cli.js'],{}, {keepOpen:true});expect(args).toContain('-NoExit');expect(args).not.toContain('-NonInteractive');expect(Buffer.from(args.at(-1)!,'base64').toString('utf16le')).not.toContain('exit $LASTEXITCODE');}); + it('silences first-use module progress before invoking cmdlets while retaining errors',()=>{ + const script=Buffer.from(powershellCommand(['node','cli.js']).at(-1)!,'base64').toString('utf16le'); + expect(script).toMatch(/^\$ErrorActionPreference='Stop';\$ProgressPreference='SilentlyContinue';/); + }); + it('rejects empty commands and NUL bytes before starting a process',()=>{expect(()=>powershellCommand([])).toThrow(/command/i);expect(()=>powershellCommand(['node','bad\0value'])).toThrow(/NUL/i);}); + + it.skipIf(process.platform!=='win32')('preserves special data-home characters under native Windows PowerShell',async()=>{ + const powershell=resolveCommandDetails('powershell.exe').path;expect(powershell).not.toBeNull();const home='C:\\Data %PATH%! & "quoted" \' 单播'; + // Keep captured output ASCII so this checks environment transport without + // depending on the Windows console's separate legacy code-page behavior. + const args=powershellCommand([process.execPath,'-e',"process.stdout.write(Buffer.from(process.env.RADIOCLI_HOME,'utf8').toString('base64'))"],{RADIOCLI_HOME:home}); + const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{const child=spawn(powershell!,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',chunk=>stdout+=String(chunk));child.stderr.on('data',chunk=>stderr+=String(chunk));child.once('error',reject);child.once('close',code=>resolve({code:code??1,stdout,stderr}));}); + expect(result.code).toBe(0);expect(result.stderr).toBe('');expect(Buffer.from(result.stdout,'base64').toString('utf8')).toBe(home); + }); + + it.skipIf(process.platform!=='win32')('runs the complete Node bootstrap with literal argv and environment through native Windows PowerShell',async()=>{ + const powershell=resolveCommandDetails('powershell.exe').path;expect(powershell).not.toBeNull(); + const home='C:\\Data %PATH%! & "quoted" \' 单播';const mpv="C:\\Players %PATH%! & ' 单播\\mpv.exe"; + const values=['space value','%PATH%!','a&b','"quoted"',"'quoted'",'$(throw boom);','单播','']; + const script="process.stdout.write(Buffer.from(JSON.stringify({args:process.argv.slice(1),home:process.env.RADIOCLI_HOME,mpv:process.env.RADIOCLI_MPV_PATH}),'utf8').toString('base64'))"; + const bootstrap=nodeLaunchCommand(process.execPath,['-e',script,'--',...values],{RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv}); + const args=powershellCommand(bootstrap); + const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{const child=spawn(powershell!,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',chunk=>stdout+=String(chunk));child.stderr.on('data',chunk=>stderr+=String(chunk));child.once('error',reject);child.once('close',code=>resolve({code:code??1,stdout,stderr}));}); + expect(result.code,JSON.stringify(result)).toBe(0);expect(result.stderr).toBe(''); + expect(JSON.parse(Buffer.from(result.stdout,'base64').toString('utf8'))).toEqual({args:values,home,mpv}); + }); +}); diff --git a/src/platform/shell.ts b/src/platform/shell.ts new file mode 100644 index 0000000..4a26eaf --- /dev/null +++ b/src/platform/shell.ts @@ -0,0 +1,27 @@ +/** + * Keep values out of PowerShell source when a native tool or script shim must + * run there. Native argument conversion still belongs to the target command; + * terminal launches use an encoded Node bootstrap for arbitrary argv values. + */ +export function powershellCommand( + values: readonly string[], + environment: Record = {}, + options: {keepOpen?: boolean} = {} +): string[] { + if (!values[0]) throw new Error('A PowerShell command is required.'); + if ([...values, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) { + throw new Error('PowerShell command values cannot contain NUL bytes.'); + } + const payload = Buffer.from(JSON.stringify({command: values[0], args: values.slice(1), environment}), 'utf8').toString('base64'); + const script = [ + "$ErrorActionPreference='Stop'", + "$ProgressPreference='SilentlyContinue'", + `$radiocliInvocation=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json`, + "foreach($radiocliVariable in $radiocliInvocation.environment.PSObject.Properties){[Environment]::SetEnvironmentVariable($radiocliVariable.Name,[string]$radiocliVariable.Value,'Process')}", + '$radiocliArguments=@($radiocliInvocation.args | ForEach-Object {[string]$_})', + '$global:LASTEXITCODE=0', + '& ([string]$radiocliInvocation.command) @radiocliArguments', + ...(options.keepOpen ? [] : ['$radiocliSucceeded=$?', 'if(-not $radiocliSucceeded -and $LASTEXITCODE -eq 0){exit 1}', 'exit $LASTEXITCODE']) + ].join(';'); + return ['-NoLogo', '-NoProfile', ...(options.keepOpen ? ['-NoExit'] : ['-NonInteractive']), '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]; +} diff --git a/src/platform/storage.test.ts b/src/platform/storage.test.ts new file mode 100644 index 0000000..8bb342d --- /dev/null +++ b/src/platform/storage.test.ts @@ -0,0 +1,92 @@ +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it} from 'vitest'; +import {storageReadiness} from './storage.js'; + +const roots: string[] = []; +// Windows ACLs and a privileged POSIX user do not enforce these chmod fixtures. +const nativePermissions = process.platform !== 'win32' && process.getuid?.() !== 0; + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'radiocli storage café ')); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); + +describe('storageReadiness', () => { + it('checks the nearest existing parent without creating directories or files', () => { + const root = fixture(); + const nested = join(root, 'new data', '音楽'); + + expect(storageReadiness(join(nested, 'library.json')).status).toBe('available'); + expect(existsSync(nested)).toBe(false); + expect(readdirSync(root)).toEqual([]); + }); + + it('reports a file in the parent chain without exposing its path', () => { + const root = fixture(); + const obstacle = join(root, 'private account'); + writeFileSync(obstacle, 'not a directory'); + + const result = storageReadiness(join(obstacle, 'new', 'library.json')); + expect(result.status).toBe('unavailable'); + expect(result.message).toMatch(/RADIOCLI_HOME.*writable/i); + expect(result.message).not.toContain(root); + expect(readdirSync(root)).toEqual(['private account']); + }); + + it('does not consider an existing directory to be a writable library file', () => { + const root = fixture(); + const file = join(root, 'library.json'); + mkdirSync(file); + + expect(storageReadiness(file).status).toBe('unavailable'); + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it.skipIf(!nativePermissions)('reports a read-only nearest parent without creating a write probe', () => { + const root = fixture(); + chmodSync(root, 0o500); + try { + const result = storageReadiness(join(root, 'new data', 'library.json')); + expect(result.status).toBe('unavailable'); + expect(result.message).toMatch(/RADIOCLI_HOME.*writable/i); + expect(result.message).not.toContain(root); + expect(readdirSync(root)).toEqual([]); + } finally { + chmodSync(root, 0o700); + } + }); + + it.skipIf(!nativePermissions)('reports a read-only file even when its parent is writable', () => { + const root = fixture(); + const file = join(root, 'library.json'); + writeFileSync(file, '{}'); + chmodSync(file, 0o400); + try { + expect(storageReadiness(file).status).toBe('unavailable'); + expect(readdirSync(root)).toEqual(['library.json']); + } finally { + chmodSync(file, 0o600); + } + }); + + it.skipIf(!nativePermissions)('reports inaccessible ancestors without treating the destination as missing', () => { + const root = fixture(); + const parent = join(root, 'private'); + mkdirSync(parent); + chmodSync(parent, 0); + try { + const result = storageReadiness(join(parent, 'new', 'library.json')); + expect(result.status).toBe('unavailable'); + expect(result.message).not.toContain(root); + } finally { + chmodSync(parent, 0o700); + } + }); +}); diff --git a/src/platform/storage.ts b/src/platform/storage.ts new file mode 100644 index 0000000..245a0b8 --- /dev/null +++ b/src/platform/storage.ts @@ -0,0 +1,46 @@ +import {accessSync, constants, statSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; + +/** Access checks are advisory: ACLs, mounts, and permissions can change before a write. */ +export function storageReadiness(filePath: string): {status: 'available' | 'unavailable'; message: string} { + try { + assertStorageWritable(filePath); + return { + status: 'available', + message: 'Filesystem access checks passed for the library location; each operation still verifies its write.' + }; + } catch { + return { + status: 'unavailable', + message: 'The library location is not accessible for writes. Check permissions or set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.' + }; + } +} + +/** Does not create a probe file or missing directories. The actual write remains authoritative. */ +export function assertStorageWritable(filePath: string): void { + try { + if (!statSync(filePath).isFile()) { + throw Object.assign(new Error('The library destination is not a regular file.'), {code: 'EINVAL'}); + } + // An atomic rename could otherwise replace a deliberately read-only file on POSIX. + accessSync(filePath, constants.R_OK | constants.W_OK); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + let parent = dirname(resolve(filePath)); + while (true) { + try { + if (!statSync(parent).isDirectory()) { + throw Object.assign(new Error('The library parent is not a directory.'), {code: 'ENOTDIR'}); + } + accessSync(parent, constants.W_OK | constants.X_OK); + return; + } catch (error) { + const ancestor = dirname(parent); + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || ancestor === parent) throw error; + parent = ancestor; + } + } +} diff --git a/src/platform/support.test.ts b/src/platform/support.test.ts new file mode 100644 index 0000000..aa2366a --- /dev/null +++ b/src/platform/support.test.ts @@ -0,0 +1,274 @@ +import {describe, expect, it} from 'vitest'; +import {identifyPlatform, type PlatformProfile} from './runtime.js'; +import {assessPlatformSupport, type PlatformSupportEvidence} from './support.js'; + +function host(platform: string, arch = 'x64', overrides: Partial = {}): PlatformProfile { + const release = platform === 'darwin' ? '23.6.0' : platform === 'win32' ? '10.0.26100' : '6.12.0'; + return {...identifyPlatform({platform, arch, armVersion: null, release, nodeVersion: '22.23.2', endianness: arch === 's390x' ? 'BE' : 'LE', libc: platform === 'linux' ? 'glibc' : 'none', env: {}, osRelease: ''}), ...overrides}; +} + +const verified: PlatformSupportEvidence = { + nativeRuntimeVerified: true, + packedInstallVerified: true, + playbackVerified: true, + requiredCi: true +}; + +describe('platform runtime eligibility', () => { + it.each([ + ['darwin', 'x64', '20.0.0', 22, 'tier-1'], + ['darwin', 'arm64', '22.6.0', 24, 'tier-1'], + ['win32', 'x64', '10.0.19045', 22, 'tier-1'], + ['win32', 'x64', '10.0.26100', 24, 'tier-1'], + ['win32', 'arm64', '10.0.26100', 22, 'tier-2'], + ['win32', 'arm64', '10.0.26100', 24, 'tier-2'], + ['win32', 'ia32', '10.0.19045', 22, 'tier-1'] + ] as const)('records %s/%s kernel %s with Node %s independently of project evidence', (platform, arch, release, nodeMajor, upstream) => { + const result = assessPlatformSupport(host(platform, arch, {release, nodeVersion: `${nodeMajor}.20.0`, nodeMajor})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + }); + + it.each([ + ['darwin', 'x64', '19.6.0', 22, 'macOS 11'], + ['darwin', 'arm64', '22.5.0', 24, 'macOS 13.5'], + ['darwin', 'ia32', '23.6.0', 22, '32-bit'], + ['win32', 'ia32', '10.0.19045', 24, 'Node.js 22'], + ['win32', 'x64', '6.1.7601', 22, 'Windows 10'] + ] as const)('diagnoses a blocked %s/%s runtime', (platform, arch, release, nodeMajor, reason) => { + const result = assessPlatformSupport(host(platform, arch, {release, nodeVersion: `${nodeMajor}.20.0`, nodeMajor}), verified); + expect(result).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + expect(result.reasons.join(' ')).toContain(reason); + }); + + it.each(['', 'unknown', '22'])('does not turn an incomplete Darwin release %j into a blocked runtime', release => { + const result = assessPlatformSupport(host('darwin', 'arm64', {release, nodeVersion: '24.20.0', nodeMajor: 24})); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + expect(result.runtime.reasons.join(' ')).toContain('Darwin'); + }); + + it('reads Darwin kernel versions rather than comparing them directly to macOS versions', () => { + const old = host('darwin', 'x64', {release: '13.5.0', nodeVersion: '24.20.0', nodeMajor: 24}); + expect(assessPlatformSupport(old).runtime.status).toBe('unsupported'); + expect(assessPlatformSupport({...old, release: '22.6.0'}).runtime.status).toBe('eligible'); + }); + + it.each([ + ['win32', 'ia32', 24], ['darwin', 'ia32', 22], ['aix', 'ppc64', 22] + ] as const)('does not lose a known %s/%s runtime exclusion when the OS release is missing', (platform, arch, nodeMajor) => { + expect(assessPlatformSupport(host(platform, arch, {release: '', nodeVersion: `${nodeMajor}.20.0`, nodeMajor}), verified)).toMatchObject({ + tier: 'unsupported', runtime: {status: 'unsupported'} + }); + }); + + it.each(['linux', 'win32', 'freebsd', 'netbsd', 'openbsd'])( + 'keeps an unknown %s OS release unverified without an independent runtime blocker', platform => { + expect(assessPlatformSupport(host(platform, 'x64', {release: ''}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'unverified'} + }); + } + ); + + it.each([ + ['x64', 22, 'tier-1'], ['x64', 24, 'tier-1'], + ['arm64', 22, 'tier-1'], ['arm64', 24, 'tier-1'], + ['armv7l', 22, 'tier-1'], ['armv7l', 24, 'experimental'], + ['ppc64', 22, 'tier-2'], ['ppc64', 24, 'tier-2'], + ['s390x', 22, 'tier-2'], ['s390x', 24, 'tier-2'], + ['riscv64', 24, 'experimental'], ['ia32', 24, 'experimental'] + ] as const)('assesses glibc Linux %s on Node %s', (arch, nodeMajor, upstream) => { + const result = assessPlatformSupport(host('linux', arch, {nodeVersion: `${nodeMajor}.20.0`, nodeMajor})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + expect(result.runtime.reasons.join(' ')).toContain('glibc'); + }); + + it('requires the ARM ISA revision before calling generic arm an upstream ARMv7 target', () => { + const result = assessPlatformSupport(host('linux', 'arm')); + expect(result.runtime.status).toBe('unverified'); + expect(result.runtime.reasons.join(' ')).toContain('ARMv7'); + }); + + it('uses an explicitly reported ARMv7 ISA while leaving older ARM unverified', () => { + expect(assessPlatformSupport(host('linux', 'arm', {armVersion: 7})).runtime).toMatchObject({status: 'eligible', upstream: 'tier-1'}); + expect(assessPlatformSupport(host('linux', 'arm', {armVersion: 6})).runtime.status).toBe('unverified'); + expect(assessPlatformSupport(host('linux', 'arm', {armVersion: 7, nodeVersion: '24.20.0', nodeMajor: 24})).runtime.upstream).toBe('experimental'); + }); + + it.each(['amd64', 'x86_64', 'aarch64', 'armv7', 'ppc64le', 'i386', 'i686'])( + 'recognizes the explicit architecture spelling %s', arch => { + expect(assessPlatformSupport(host('linux', arch)).runtime.status).toBe('eligible'); + } + ); + + it.each([22, 24])('keeps big-endian Linux ppc64 separate from ppc64le on Node %s', nodeMajor => { + const base = host('linux', 'ppc64', {nodeVersion: `${nodeMajor}.20.0`, nodeMajor}); + expect(assessPlatformSupport(base).runtime.upstream).toBe('tier-2'); + const result = assessPlatformSupport({...base, endianness: 'BE'}); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + expect(result.runtime.reasons.join(' ')).toContain('big-endian'); + }); + + it('leaves conflicting POWER endianness and unknown 32-bit POWER ports unverified', () => { + expect(assessPlatformSupport(host('linux', 'ppc64le', {endianness: 'BE'})).runtime.status).toBe('unverified'); + expect(assessPlatformSupport(host('linux', 'ppc', {endianness: 'BE'})).runtime.status).toBe('unverified'); + }); + + it.each([ + ['x64', 22, 'experimental'], ['x64', 24, 'experimental'], + ['arm64', 22, 'community'], ['arm64', 24, 'community'], + ['armv7l', 22, 'community'], ['s390x', 24, 'community'] + ] as const)('does not borrow glibc support for musl %s on Node %s', (arch, nodeMajor, upstream) => { + const result = assessPlatformSupport(host('linux', arch, {libc: 'musl', nodeVersion: `${nodeMajor}.20.0`, nodeMajor})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + expect(result.runtime.reasons.join(' ')).toContain('musl'); + }); + + it.each([ + {libc: 'unknown'}, {libc: 'bionic'}, {release: ''}, {release: '3.10.0'}, + {arch: 'riscv64'}, {arch: 'future-cpu'}, {arch: 'armv7l', libc: 'musl', nodeVersion: '24.20.0', nodeMajor: 24} + ] satisfies Partial[])('leaves incomplete Linux runtime evidence unverified: %j', overrides => { + const result = assessPlatformSupport(host('linux', 'x64', overrides)); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + expect(result.runtime.reasons.length).toBeGreaterThan(0); + }); + + it('keeps WSL separate from upstream native Linux support', () => { + const result = assessPlatformSupport(host('linux', 'x64', {isWsl: true})); + expect(result.runtime).toMatchObject({status: 'unverified', upstream: 'community'}); + expect(result.runtime.reasons.join(' ')).toContain('WSL'); + }); + + it.each([ + ['freebsd', 'x64', 'experimental'], ['freebsd', 'arm64', 'community'], + ['freebsd', 'ppc64', 'community'], ['openbsd', 'x64', 'community'], + ['openbsd', 'arm64', 'community'], ['openbsd', 'riscv64', 'community'], + ['netbsd', 'x64', 'community'], ['netbsd', 'arm64', 'community'] + ] as const)('recognizes a community %s/%s route without claiming a native RadioCLI run', (platform, arch, upstream) => { + const result = assessPlatformSupport(host(platform, arch, {release: platform === 'freebsd' ? '14.4-RELEASE' : '11.0'})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + expect(result.evidence.nativeRuntimeVerified).toBe(false); + }); + + it('does not infer a FreeBSD RISC-V Node port from the availability of a VM image', () => { + expect(assessPlatformSupport(host('freebsd', 'riscv64', {release: '15.1-RELEASE'}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'unverified'} + }); + }); + + it('does not hard-block a community FreeBSD port below the upstream experimental baseline', () => { + expect(assessPlatformSupport(host('freebsd', 'x64', {release: '13.1-RELEASE'}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'unverified'} + }); + }); + + it.each(['freebsd', 'openbsd', 'netbsd'])('keeps the %s port on Node 24 eligible but experimentally supported without evidence', platform => { + expect(assessPlatformSupport(host(platform, 'x64', {release: '14.4', nodeVersion: '24.20.0', nodeMajor: 24}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'eligible'} + }); + }); + + it.each(['android', 'linux'])('recognizes Termux on %s as a separate Bionic community route', platform => { + const profile = identifyPlatform({platform, arch: 'arm64', nodeVersion: '24.20.0', release: '6.6.0', env: {TERMUX_VERSION: '0.118.3'}}); + const result = assessPlatformSupport(profile); + expect(result.runtime).toMatchObject({status: 'eligible', upstream: 'community'}); + expect(result.runtime.reasons.join(' ')).toContain('Bionic'); + expect(result.tier).toBe('experimental'); + }); + + it('leaves Android outside Termux unverified', () => { + expect(assessPlatformSupport(host('android', 'arm64'))).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + }); + + it('blocks Haiku with its usual Node 20 runtime but leaves a newer custom port experimental', () => { + const haiku = host('haiku', 'x64', {nodeVersion: '20.15.1', nodeMajor: 20}); + expect(assessPlatformSupport(haiku)).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + const custom = assessPlatformSupport({...haiku, nodeVersion: '22.23.2', nodeMajor: 22}); + expect(custom).toMatchObject({tier: 'experimental', runtime: {status: 'unverified', upstream: 'community'}}); + expect(custom.runtime.reasons.join(' ')).toContain('Haiku'); + }); + + it('does not identify generic SunOS release 5.11 as a verified SmartOS distribution', () => { + const result = assessPlatformSupport(host('sunos', 'x64', {release: '5.11'})); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified', upstream: 'community'}}); + expect(result.runtime.reasons.join(' ')).toContain('SmartOS'); + expect(result.runtime.reasons.join(' ')).toContain('illumos'); + }); + + it('recognizes AIX big-endian POWER without treating uname release as a full AIX version', () => { + const result = assessPlatformSupport(host('aix', 'ppc64', {endianness: 'BE', release: '3'})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream: 'tier-2'}); + expect(result.tier).toBe('experimental'); + expect(result.runtime.reasons.join(' ')).toContain('7.2 TL04'); + }); + + it('diagnoses a little-endian AIX architecture mismatch', () => { + const result = assessPlatformSupport(host('aix', 'ppc64', {endianness: 'LE', release: '3'})); + expect(result).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + expect(result.runtime.reasons.join(' ')).toContain('big-endian'); + }); + + it('keeps unknown operating systems and architectures visible', () => { + const result = assessPlatformSupport(host('future-os', 'future-cpu')); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified', upstream: 'unverified'}}); + expect(result.runtime.reasons.join(' ')).toContain('future-os/future-cpu'); + }); + + it.each([18, 20, 21])('blocks Node %s before considering any platform or evidence', nodeMajor => { + const result = assessPlatformSupport(host('linux', 'x64', {nodeVersion: `${nodeMajor}.0.0`, nodeMajor}), verified); + expect(result).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + expect(result.reasons.join(' ')).toContain('Node.js 22 or newer'); + }); + + it.each(['unknown', '22broken', '26.8.1'])('does not borrow a Node 22/24 policy for %s', nodeVersion => { + const result = assessPlatformSupport(host('linux', 'x64', {nodeVersion, nodeMajor: Number.parseInt(nodeVersion, 10)})); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + }); +}); + +describe('project support evidence', () => { + it('never promotes a familiar platform from its identity alone', () => { + const result = assessPlatformSupport(host('darwin', 'arm64')); + expect(result.tier).toBe('experimental'); + expect(result.evidence).toEqual({nativeRuntimeVerified: false, packedInstallVerified: false, playbackVerified: false, requiredCi: false}); + expect(result.reasons.join(' ')).toContain('not been verified'); + }); + + it.each(['nativeRuntimeVerified', 'packedInstallVerified', 'playbackVerified'] as const)('requires %s even when every other evidence item is supplied', missing => { + const result = assessPlatformSupport(host('linux'), {...verified, [missing]: false}); + expect(result.tier).toBe('experimental'); + }); + + it('promotes verified runtime, packed install, and playback to supported', () => { + const result = assessPlatformSupport(host('freebsd', 'x64', {release: '14.4-RELEASE'}), {...verified, requiredCi: false}); + expect(result.tier).toBe('supported'); + expect(result.runtime.upstream).toBe('experimental'); + expect(result.reasons.join(' ')).toContain('required CI'); + }); + + it('requires ongoing required CI for first-class support', () => { + const result = assessPlatformSupport(host('win32', 'arm64'), verified); + expect(result.tier).toBe('first-class'); + expect(result.runtime.upstream).toBe('tier-2'); + }); + + it('allows actual project verification to establish support for a custom port independently of its upstream status', () => { + const result = assessPlatformSupport(host('haiku'), {...verified, requiredCi: false}); + expect(result.tier).toBe('supported'); + expect(result.runtime).toMatchObject({status: 'unverified', upstream: 'community'}); + }); + + it('does not infer missing evidence from a required CI flag', () => { + expect(assessPlatformSupport(host('linux'), {requiredCi: true}).tier).toBe('experimental'); + }); + + it('copies supplied evidence without mutating the caller or retaining its object', () => { + const input = {...verified}; + const result = assessPlatformSupport(host('linux'), input); + expect(input).toEqual(verified); + input.playbackVerified = false; + expect(result.evidence.playbackVerified).toBe(true); + }); +}); diff --git a/src/platform/support.ts b/src/platform/support.ts new file mode 100644 index 0000000..1c72f58 --- /dev/null +++ b/src/platform/support.ts @@ -0,0 +1,222 @@ +import {identifyPlatform, type PlatformProfile} from './runtime.js'; + +type SupportTier = 'first-class' | 'supported' | 'experimental' | 'unsupported'; +type RuntimeStatus = 'eligible' | 'unverified' | 'unsupported'; +type UpstreamSupport = 'tier-1' | 'tier-2' | 'experimental' | 'community' | 'unverified' | 'unsupported'; + +/** Evidence must describe this OS, architecture, libc, and Node release line. */ +export type PlatformSupportEvidence = { + /** A real target runtime was exercised; mocks and foreign-CPU emulation do not establish this. */ + nativeRuntimeVerified: boolean; + /** The packed npm artifact was installed and exercised on the target. */ + packedInstallVerified: boolean; + /** Playback was exercised; discovering an executable or a release asset is insufficient. */ + playbackVerified: boolean; + /** The verified target is covered by required, maintained CI. */ + requiredCi: boolean; +}; + +type RuntimeAssessment = { + /** Eligibility for a known runtime route, not proof that RadioCLI was executed. */ + status: RuntimeStatus; + upstream: UpstreamSupport; + reasons: string[]; +}; + +export type PlatformSupportAssessment = { + tier: SupportTier; + runtime: RuntimeAssessment; + reasons: string[]; + evidence: PlatformSupportEvidence; +}; + +/** + * Runtime policy and project evidence are deliberately independent. Catalogs, + * platform mocks, containers, and downloaded artifacts never supply evidence. + * This checks the Node 22/24 reference matrix; other newer lines remain unverified. + * OS vendor lifecycle, CPU instruction levels, libc versions, and audio services + * require separate checks when the profile does not contain those facts. + * + * Reference policy (checked 2026-09-07): + * https://github.com/nodejs/node/blob/v22.x/BUILDING.md#platform-list + * https://github.com/nodejs/node/blob/v24.x/BUILDING.md#platform-list + */ +export function assessPlatformSupport( + host: PlatformProfile = identifyPlatform(), + suppliedEvidence: Partial = {} +): PlatformSupportAssessment { + const evidence: PlatformSupportEvidence = { + nativeRuntimeVerified: suppliedEvidence.nativeRuntimeVerified === true, + packedInstallVerified: suppliedEvidence.packedInstallVerified === true, + playbackVerified: suppliedEvidence.playbackVerified === true, + requiredCi: suppliedEvidence.requiredCi === true + }; + const runtime = assessRuntime(host); + const reasons = [...runtime.reasons]; + let tier: SupportTier = 'experimental'; + + if (runtime.status === 'unsupported') { + tier = 'unsupported'; + } else if (evidence.nativeRuntimeVerified && evidence.packedInstallVerified && evidence.playbackVerified) { + tier = evidence.requiredCi ? 'first-class' : 'supported'; + reasons.push(evidence.requiredCi + ? 'Native runtime, packed installation, and playback are verified with required CI coverage.' + : 'Native runtime, packed installation, and playback are verified; required CI coverage is not established.'); + } else { + if (!evidence.nativeRuntimeVerified) reasons.push('Native runtime execution has not been verified.'); + if (!evidence.packedInstallVerified) reasons.push('Packed npm installation has not been verified.'); + if (!evidence.playbackVerified) reasons.push('Playback has not been verified.'); + } + + return {tier, runtime, reasons, evidence}; +} + +function assessRuntime(host: PlatformProfile): RuntimeAssessment { + const node = /^(\d+)\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.exec(host.nodeVersion); + if (!node) return assessment('unverified', 'unverified', `Cannot assess the Node.js version ${JSON.stringify(host.nodeVersion)}.`); + const major = Number(node[1]); + if (!Number.isSafeInteger(major)) return assessment('unverified', 'unverified', 'The Node.js major version is not recognized.'); + if (major < 22) return assessment('unsupported', 'unsupported', `RadioCLI requires Node.js 22 or newer; this runtime is ${host.nodeVersion}.`); + if (major !== 22 && major !== 24) { + return assessment('unverified', 'unverified', `Node.js ${major} meets the package minimum, but its platform requirements are outside the verified Node.js 22/24 reference matrix.`); + } + + const arch = host.arch === 'arm' && host.armVersion === 7 ? 'armv7' : architecture(host.arch); + if (host.id === 'darwin' && arch === 'ia32') return assessment('unsupported', 'unsupported', 'Node.js 22/24 do not provide a 32-bit macOS runtime; use Intel x64 or Apple Silicon arm64.'); + if (host.id === 'win32' && major === 24 && arch === 'ia32') return assessment('unsupported', 'unsupported', 'Node.js 24 has no Windows x86 runtime; use Node.js 22 for ia32 or a native x64/arm64 runtime.'); + if (host.id === 'aix' && (arch === 'ppc64' || arch === 'ppc64le') && (host.endianness !== 'BE' || arch === 'ppc64le')) { + return assessment('unsupported', 'unsupported', 'The AIX Node.js runtime requires big-endian ppc64; a little-endian POWER profile is incompatible.'); + } + const version = releaseVersion(host.release); + if (!version) { + return assessment('unverified', 'unverified', `${host.id === 'darwin' ? 'Darwin' : host.platform} release is unavailable or unrecognized; runtime eligibility needs the OS version.`); + } + + switch (host.id) { + case 'darwin': + if (!['x64', 'arm64'].includes(arch)) return unknownArchitecture(host); + // os.release() is the Darwin kernel: Darwin 20 = macOS 11, + // Darwin 22.6 = macOS 13.5. It is not a macOS product-version string. + if (version.length < 2) return assessment('unverified', 'tier-1', 'A complete Darwin kernel release is needed to check the macOS runtime minimum.'); + if (olderThan(version, major === 22 ? [20, 0] : [22, 6])) { + return assessment('unsupported', 'unsupported', `Node.js ${major} requires ${major === 22 ? 'macOS 11 (Darwin 20)' : 'macOS 13.5 (Darwin 22.6)'} or newer.`); + } + return assessment('eligible', 'tier-1', `The Darwin kernel meets the Node.js ${major} macOS binary minimum for ${arch}.`); + case 'win32': + if (!['x64', 'arm64', 'ia32'].includes(arch)) return unknownArchitecture(host); + if (version.length < 2) return assessment('unverified', 'unverified', 'A complete Windows kernel version is needed to check the Windows 10 minimum.'); + if (olderThan(version, [10, 0])) return assessment('unsupported', 'unsupported', 'The standard Node.js 22/24 Windows runtime requires Windows 10 / Server 2016 or newer.'); + return assessment('eligible', arch === 'arm64' ? 'tier-2' : 'tier-1', arch === 'ia32' + ? 'Node.js 22 provides Windows x86 binaries; upstream tests run under WoW64, which does not establish native 32-bit Windows verification.' + : `Node.js ${major} provides a Windows ${arch} runtime; the kernel version alone does not distinguish Windows desktop and Server editions.`); + case 'linux': + return linuxRuntime(host, arch, major, version); + case 'freebsd': + // Port allowlists are not evidence that every release/CPU binary exists. + // https://github.com/freebsd/freebsd-ports/blob/main/www/node22/Makefile + // https://github.com/freebsd/freebsd-ports/blob/main/www/node24/Makefile + if (!['x64', 'arm64', 'ia32', 'armv7', 'ppc64', 'ppc64le'].includes(arch)) return unknownArchitecture(host); + if (olderThan(version, [13, 2])) return assessment('unverified', 'experimental', 'FreeBSD is below the upstream experimental 13.2 baseline; a compatible Node.js port needs separate verification.'); + return assessment('eligible', arch === 'x64' ? 'experimental' : 'community', 'FreeBSD has Node.js 22/24 ports; use the matching npm-node package and verify availability for this release and architecture.'); + case 'openbsd': + // https://github.com/openbsd/ports/blob/master/lang/node/Makefile + if (!['x64', 'arm64', 'ia32', 'ppc64', 'riscv64'].includes(arch)) return unknownArchitecture(host); + return assessment('eligible', 'community', 'OpenBSD uses its community Node.js port; the selected OS release and architecture must provide Node.js 22 or newer.'); + case 'netbsd': + // https://github.com/NetBSD/pkgsrc/tree/trunk/lang/nodejs22 + if (!['x64', 'arm64'].includes(arch)) return unknownArchitecture(host); + return assessment('eligible', 'community', 'NetBSD has pkgsrc Node.js 22/24 ports; binary packages are named nodejs and depend on the repository release and architecture.'); + case 'termux': + // https://github.com/termux/termux-packages/blob/master/packages/nodejs-lts/build.sh + if (!['x64', 'arm64', 'ia32', 'arm', 'armv7'].includes(arch)) return unknownArchitecture(host); + return assessment('eligible', 'community', 'Termux needs its Android Bionic Node.js package and Android 7 or newer; the Linux kernel release does not establish the Android API version.'); + case 'android': + return assessment('unverified', 'unverified', 'Android outside Termux has no established RadioCLI runtime installation route; verify a compatible Bionic Node.js build.'); + case 'haiku': + // https://github.com/haikuports/haikuports/tree/master/net-libs/nodejs + return assessment('unverified', 'community', 'HaikuPorts currently provides Node.js 20; this newer runtime requires a separately verified custom Node.js port.'); + case 'sunos': + return assessment('unverified', 'community', 'SmartOS x64 has upstream Tier 2 status; generic SunOS kernel 5.11 does not identify SmartOS, illumos distribution, or its version. Verify the selected Node.js port and playback backend.'); + case 'aix': + if (arch !== 'ppc64') return unknownArchitecture(host); + return assessment('eligible', 'tier-2', `AIX requires big-endian POWER8 or newer and AIX 7.2 TL04 or newer${major === 24 ? ', plus libstdc++12' : ''}; uname release alone does not establish these prerequisites. Playback backend availability needs separate verification.`); + case 'unknown': + return unknownArchitecture(host); + } +} + +function linuxRuntime(host: PlatformProfile, arch: string, major: number, version: number[]): RuntimeAssessment { + if (host.isWsl) return assessment('unverified', 'community', 'WSL uses a Linux Node.js runtime, but upstream support requires reproducing issues on native Linux; verify this WSL environment separately.'); + if (arch === 'arm') return assessment('unverified', 'unverified', 'The generic arm architecture does not identify its ISA revision. Node.js 22 Tier 1 applies to ARMv7; older ARM and Node.js 24 ARMv7 need separate community verification.'); + if ((arch === 'ppc64' || arch === 'ppc64le') && host.endianness !== 'LE') { + return assessment('unverified', 'unverified', 'Upstream Linux POWER support applies to ppc64le, not big-endian ppc64. A compatible big-endian Node.js 22+ community port must be verified separately.'); + } + if (host.libc === 'musl') { + // Node 24 now publishes x64-musl files while its matrix remains Experimental. + // Other known routes are distributor builds, not glibc binary compatibility. + // https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt + // https://github.com/nodejs/docker-node/blob/main/versions.json + const knownRoute = ['x64', 'arm64', 's390x'].includes(arch) || (arch === 'armv7' && major === 22); + if (!knownRoute) return assessment('unverified', 'community', `A Node.js ${major} musl build for ${host.arch} requires separate package and runtime verification.`); + if (olderThan(version, [3, 10])) return assessment('unverified', 'experimental', 'This kernel is below the Node.js musl reference baseline; compatibility is unverified.'); + return assessment('eligible', arch === 'x64' ? 'experimental' : 'community', `Use a musl-specific Node.js ${major} build and its libstdc++ dependencies; libc family detection does not verify the musl version.`); + } + if (host.libc !== 'glibc') return assessment('unverified', 'unverified', `Linux libc is ${host.libc}; select a compatible Node.js build before declaring runtime eligibility.`); + + let upstream: UpstreamSupport; + let minimumKernel = [4, 18]; + let minimumGlibc = '2.28'; + let detail = ''; + if (arch === 'x64' || arch === 'arm64') upstream = 'tier-1'; + else if (arch === 'armv7') { + upstream = major === 22 ? 'tier-1' : 'experimental'; + detail = major === 22 ? ' ARMv7 binaries also require GLIBCXX_3.4.28.' : ' Node.js 24 ARMv7 needs a community build; no upstream release binary is provided.'; + } else if (arch === 'ppc64' || arch === 'ppc64le' || arch === 's390x') { + upstream = 'tier-2'; + if (arch !== 's390x') detail = ' POWER8 or newer is required.'; + } else if (arch === 'riscv64' && major === 24) { + upstream = 'experimental'; + minimumKernel = [5, 19]; + minimumGlibc = '2.36'; + } else if (arch === 'ia32') { + upstream = 'experimental'; + minimumKernel = [3, 10]; + minimumGlibc = '2.17'; + } else return unknownArchitecture(host); + + if (version.length < 2 || olderThan(version, minimumKernel)) { + return assessment('unverified', upstream, `The glibc Linux kernel does not establish the ${minimumKernel.join('.')} reference minimum; older kernels may work but require separate verification.`); + } + return assessment('eligible', upstream, `Node.js ${major} requires glibc >= ${minimumGlibc} and a compatible libstdc++; the profile records only the libc family.${detail}`); +} + +function assessment(status: RuntimeStatus, upstream: UpstreamSupport, ...reasons: string[]): RuntimeAssessment { + return {status, upstream, reasons}; +} + +function unknownArchitecture(host: PlatformProfile): RuntimeAssessment { + return assessment('unverified', 'unverified', `No runtime eligibility is established for ${host.platform}/${host.arch}; verify a compatible Node.js port instead of inferring support from another target.`); +} + +function architecture(arch: string): string { + if (['amd64', 'x86_64'].includes(arch)) return 'x64'; + if (arch === 'aarch64') return 'arm64'; + if (['x86', 'i386', 'i686'].includes(arch)) return 'ia32'; + if (arch === 'armv7l') return 'armv7'; + return arch; +} + +function releaseVersion(release: string): number[] | null { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[.+_-][\w.+-]+)?$/.exec(release); + if (!match) return null; + const parts = match.slice(1).filter(part => part !== undefined).map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function olderThan(actual: number[], minimum: number[]): boolean { + for (let index = 0; index < minimum.length; index++) { + const difference = (actual[index] ?? 0) - minimum[index]!; + if (difference !== 0) return difference < 0; + } + return false; +} diff --git a/src/platform/terminal.test.ts b/src/platform/terminal.test.ts new file mode 100644 index 0000000..096d57d --- /dev/null +++ b/src/platform/terminal.test.ts @@ -0,0 +1,141 @@ +import {describe, expect, it} from 'vitest'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {resolveTerminalCapabilities} from './terminal.js'; + +describe('terminal capabilities', () => { + it('preserves the existing Unicode and truecolor default when no constraint is known', () => { + expect(resolveTerminalCapabilities({})).toEqual({ + unicode: true, colorLevel: 3, screenReader: false, reduceMotion: false, interactive: true + }); + }); + + it.each(['C', 'POSIX'])('uses ASCII decoration for the %s locale', locale => { + expect(resolveTerminalCapabilities({LANG: locale}).unicode).toBe(false); + }); + + it.each(['C.UTF-8', 'en_US.UTF8', 'ja_JP.UTF-8'])('preserves Unicode for %s', locale => { + expect(resolveTerminalCapabilities({LANG: locale}).unicode).toBe(true); + }); + + it('follows LC_ALL, LC_CTYPE, then LANG precedence and ignores empty locale variables', () => { + expect(resolveTerminalCapabilities({LC_ALL: 'C', LC_CTYPE: 'ja_JP.UTF-8', LANG: 'en_US.UTF-8'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({LC_ALL: '', LC_CTYPE: 'C', LANG: 'ja_JP.UTF-8'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({LC_ALL: 'C.UTF-8', LC_CTYPE: 'POSIX'}).unicode).toBe(true); + }); + + it('allows explicit glyph overrides without using SSH as an ASCII signal', () => { + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: '1', LANG: 'en_US.UTF-8'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({RADIOCLI_UNICODE: '1', LC_ALL: 'C'}, {asciiMode: true}).unicode).toBe(true); + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: '0'}, {asciiMode: true}).unicode).toBe(true); + expect(resolveTerminalCapabilities({RADIOCLI_UNICODE: '0'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: '1', RADIOCLI_UNICODE: '1'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({SSH_TTY: '/dev/pts/2', SSH_CONNECTION: 'host 1 host 2', LANG: 'C.UTF-8'}).unicode).toBe(true); + }); + + it('does not treat malformed glyph overrides as enabling a mode', () => { + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: 'maybe', RADIOCLI_UNICODE: 'later'}, {asciiMode: true}).unicode).toBe(false); + }); + + it('makes dumb terminals ASCII, colorless and static even when color and Unicode are forced', () => { + expect(resolveTerminalCapabilities({TERM: 'dumb', FORCE_COLOR: '3', RADIOCLI_UNICODE: '1'})).toMatchObject({ + unicode: false, colorLevel: 0, reduceMotion: true, interactive: false + }); + }); + + it('honors nonempty NO_COLOR over FORCE_COLOR and keeps an empty value inactive', () => { + expect(resolveTerminalCapabilities({NO_COLOR: '0', FORCE_COLOR: '3'}).colorLevel).toBe(0); + expect(resolveTerminalCapabilities({NO_COLOR: '', FORCE_COLOR: '3'}).colorLevel).toBe(3); + }); + + it.each([ + [{TERM: 'xterm'}, 1], + [{TERM: 'screen-256color'}, 2], + [{TERM: 'xterm-256color', COLORTERM: 'truecolor'}, 3], + [{TERM: 'xterm', COLORTERM: '24bit'}, 3], + [{FORCE_COLOR: '0'}, 0], + [{FORCE_COLOR: '1'}, 1], + [{FORCE_COLOR: '2'}, 2], + [{FORCE_COLOR: '3'}, 3] + ] as const)('selects the supported color level for %j', (env, colorLevel) => { + expect(resolveTerminalCapabilities(env).colorLevel).toBe(colorLevel); + }); + + it.each([[1, 0], [4, 1], [8, 2], [24, 3]])('uses a detected %i-bit color depth', (colorDepth, colorLevel) => { + expect(resolveTerminalCapabilities({}, {colorDepth}).colorLevel).toBe(colorLevel); + }); + + it.each([ + [{COLORTERM: 'truecolor'}, {colorDepth: 8}, 3], + [{COLORTERM: '24bit'}, {colorDepth: 4}, 3], + [{TERM: 'xterm-direct'}, {colorDepth: 8}, 3], + [{TERM: 'xterm-256color'}, {colorDepth: 8}, 2], + [{TERM: 'xterm'}, {colorDepth: 4}, 1] + ] as const)('honors explicit color capabilities for %j with stream evidence %j', (env, evidence, colorLevel) => { + expect(resolveTerminalCapabilities(env, evidence).colorLevel).toBe(colorLevel); + }); + + it('keeps explicit user color overrides above terminal capability declarations', () => { + expect(resolveTerminalCapabilities({FORCE_COLOR: '2', COLORTERM: 'truecolor'}, {colorDepth: 24}).colorLevel).toBe(2); + expect(resolveTerminalCapabilities({NO_COLOR: '1', COLORTERM: 'truecolor'}, {colorDepth: 24}).colorLevel).toBe(0); + }); + + it('keeps piped output plain and static unless color is explicitly requested', () => { + expect(resolveTerminalCapabilities({}, {isTTY: false})).toMatchObject({colorLevel: 0, interactive: false, reduceMotion: true}); + expect(resolveTerminalCapabilities({FORCE_COLOR: '2'}, {isTTY: false}).colorLevel).toBe(2); + }); + + it.each([{INK_SCREEN_READER: 'true'}, {RADIOCLI_SCREEN_READER: '1'}])('disables decoration animation in accessible mode %j', env => { + expect(resolveTerminalCapabilities(env)).toMatchObject({screenReader: true, reduceMotion: true, colorLevel: 0}); + }); + + it('accepts the actual Ink accessibility state and legacy animation overrides', () => { + expect(resolveTerminalCapabilities({}, {screenReader: true})).toMatchObject({screenReader: true, reduceMotion: true}); + expect(resolveTerminalCapabilities({RADIOCLI_DISABLE_ANIMATION: '1'}).reduceMotion).toBe(true); + expect(resolveTerminalCapabilities({RADIO_ATLAS_DISABLE_ANIMATION: '1'}).reduceMotion).toBe(true); + }); + + it('does not mutate the supplied environment', () => { + const env = Object.freeze({NO_COLOR: '1', FORCE_COLOR: '3', RADIOCLI_ASCII: '1'}); + const capabilities = resolveTerminalCapabilities(env); + expect(capabilities.colorLevel).toBe(0); + expect(env).toEqual({NO_COLOR: '1', FORCE_COLOR: '3', RADIOCLI_ASCII: '1'}); + }); +}); + +describe('terminal color startup', () => { + it.each([ + {env: {NO_COLOR: '1', FORCE_COLOR: '3'}, evidence: {}, expected: 0}, + {env: {TERM: 'dumb', FORCE_COLOR: '3'}, evidence: {}, expected: 0}, + {env: {TERM: 'xterm'}, evidence: {}, expected: 1}, + {env: {TERM: 'xterm-256color'}, evidence: {}, expected: 2}, + {env: {TERM: 'xterm-256color', COLORTERM: 'truecolor'}, evidence: {}, expected: 3}, + {env: {TERM: 'xterm-256color', COLORTERM: 'truecolor'}, evidence: {isTTY: true, colorDepth: 8}, expected: 3}, + {env: {FORCE_COLOR: '1', COLORTERM: 'truecolor'}, evidence: {}, expected: 1}, + {env: {FORCE_COLOR: '2', GITHUB_ACTIONS: 'true', CI: 'true'}, evidence: {}, expected: 2} + ])('configures real Ink before its color dependency loads for $env with $evidence', ({env, evidence, expected}) => { + const output = execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', ` + import {configureTerminalRenderer} from './src/ui/terminal-renderer.ts'; + const evidence = ${JSON.stringify(evidence)}; + const terminal = await configureTerminalRenderer(process.env, evidence); + const [{createElement}, {Box, Text, renderToString}, {resolveDisplayMode}] = await Promise.all([ + import('react'), import('ink'), import('./src/ui/display-context.ts') + ]); + const display = resolveDisplayMode({}, process.env, evidence); + const frame = renderToString(createElement(Box, {backgroundColor: display.app}, + createElement(Text, {color: '#74f28a', backgroundColor: display.panel}, '東京 / Café'))); + process.stdout.write(JSON.stringify({terminal, frame})); + `], { + cwd: fileURLToPath(new URL('../../', import.meta.url)), encoding: 'utf8', stdio: 'pipe', + env: {...process.env, TERM: undefined, COLORTERM: undefined, FORCE_COLOR: undefined, NO_COLOR: undefined, INK_SCREEN_READER: undefined, RADIOCLI_SCREEN_READER: undefined, ...env} + }); + const result = JSON.parse(output) as {terminal: {colorLevel: number}; frame: string}; + expect(result.terminal.colorLevel).toBe(expected); + expect(result.frame).toContain('東京 / Café'); + if (expected === 0) expect(result.frame).not.toContain('\u001B['); + else expect(result.frame).toContain('\u001B['); + if (expected < 3) expect(result.frame).not.toMatch(/\u001B\[(?:38|48);2;/u); + if (expected < 2) expect(result.frame).not.toMatch(/\u001B\[(?:38|48);5;/u); + if (expected === 3) expect(result.frame).toMatch(/\u001B\[(?:38|48);2;/u); + }); +}); diff --git a/src/platform/terminal.ts b/src/platform/terminal.ts new file mode 100644 index 0000000..831d2de --- /dev/null +++ b/src/platform/terminal.ts @@ -0,0 +1,72 @@ +export type TerminalColorLevel = 0 | 1 | 2 | 3; + +export type TerminalEvidence = { + isTTY?: boolean; + colorDepth?: number; + screenReader?: boolean; + asciiMode?: boolean; + reduceMotion?: boolean; +}; + +export type TerminalCapabilities = { + unicode: boolean; + colorLevel: TerminalColorLevel; + screenReader: boolean; + reduceMotion: boolean; + interactive: boolean; +}; + +/** Environment overrides affect this invocation, never the saved settings. */ +export function resolveTerminalCapabilities(env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): TerminalCapabilities { + const dumb = env.TERM?.toLowerCase() === 'dumb'; + const screenReader = evidence.screenReader === true || env.INK_SCREEN_READER === 'true' || flag(env.RADIOCLI_SCREEN_READER) === true; + const interactive = evidence.isTTY !== false && !dumb; + const asciiOverride = flag(env.RADIOCLI_ASCII); + const unicodeOverride = flag(env.RADIOCLI_UNICODE); + const locale = [env.LC_ALL, env.LC_CTYPE, env.LANG].find(value => value?.trim())?.trim(); + // An explicit ASCII request wins when both overrides are enabled. A bare + // C/POSIX locale is a known constraint; SSH and missing locale data are not. + const unicode = dumb ? false + : asciiOverride === true ? false + : unicodeOverride === true ? true + : asciiOverride === false ? true + : unicodeOverride === false ? false + : locale === 'C' || locale === 'POSIX' ? false : !evidence.asciiMode; + const colorLevel = dumb || screenReader || Boolean(env.NO_COLOR) ? 0 : resolveColorLevel(env, evidence); + return { + unicode, + colorLevel, + screenReader, + reduceMotion: Boolean(evidence.reduceMotion) || !interactive || screenReader || env.RADIOCLI_DISABLE_ANIMATION === '1' || env.RADIO_ATLAS_DISABLE_ANIMATION === '1', + interactive + }; +} + +function resolveColorLevel(env: NodeJS.ProcessEnv, evidence: TerminalEvidence): TerminalColorLevel { + const forced = env.FORCE_COLOR; + if (forced === '0' || forced === 'false') return 0; + if (forced === '2') return 2; + if (forced === '3') return 3; + if (forced === '' || forced === '1' || forced === 'true') return 1; + if (evidence.isTTY === false) return 0; + // COLORTERM=truecolor/24bit and direct-color TERM values are explicit + // capability declarations. Some terminals expose those declarations while + // Node's getColorDepth() still reports 8 bits, so honor them before the + // conservative stream probe to avoid silently quantizing RGB themes. + if (/^(truecolor|24bit)$/i.test(env.COLORTERM ?? '') || /(?:direct|truecolor)/i.test(env.TERM ?? '')) return 3; + if (evidence.colorDepth !== undefined) { + return evidence.colorDepth <= 1 ? 0 : evidence.colorDepth <= 4 ? 1 : evidence.colorDepth <= 8 ? 2 : 3; + } + if (/256color/i.test(env.TERM ?? '')) return 2; + if (env.TERM) return 1; + // Preserve the existing rich display unless the environment supplies a + // concrete constraint. Native callers can pass stdout.getColorDepth(). + return 3; +} + +function flag(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + if (/^(1|true|yes|on)$/i.test(value)) return true; + if (/^(0|false|no|off)$/i.test(value)) return false; + return undefined; +} diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts new file mode 100644 index 0000000..1561e36 --- /dev/null +++ b/src/platform/terminals.test.ts @@ -0,0 +1,231 @@ +import {execFileSync,spawn,spawnSync,type ChildProcess} from 'node:child_process'; +import {EventEmitter} from 'node:events'; +import {existsSync,mkdtempSync,mkdirSync,readFileSync,rmSync,symlinkSync,writeFileSync} from 'node:fs'; +import {basename,join,posix} from 'node:path'; +import {afterEach,describe,expect,it,vi} from 'vitest'; +import {createTerminalLaunch,detectGraphicalTerminal,launchTerminalCommand} from './terminals.js'; +import {waitForLaunch} from './launch-command.js'; + +const roots:string[]=[]; +afterEach(()=>{vi.useRealTimers();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true,maxRetries:5,retryDelay:50});}); +const resolve=(command:string)=>command; +function fixture(){const root=mkdtempSync(join(process.cwd(),'.radiocli-terminals-'));roots.push(root);return root;} +function terminalPath(root:string,name:string){return posix.join(basename(root),name);} +function nodeInvocation(args: readonly string[], environment: NodeJS.ProcessEnv = {}): {command: string; args: string[]} { + const index = args.indexOf('-EncodedCommand'); + expect(index).toBeGreaterThanOrEqual(0); + const script = Buffer.from(args[index + 1]!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + const key = /GetEnvironmentVariable\('([^']+)'/.exec(script)?.[1]; + const source = encoded ? Buffer.from(encoded, 'base64').toString('utf8') : environment[key!]; + expect(source).toBeDefined(); + const invocation = JSON.parse(source!) as {command: string; args: string[]}; + return invocation.args.includes('-EncodedCommand') ? nodeInvocation(invocation.args) : invocation; +} + +describe('shared graphical terminal discovery',()=>{ + it('ignores a malformed saved descriptor and continues normal terminal detection',()=>{expect(detectGraphicalTerminal('linux',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linuxX'},command=>command==='kitty'?'/usr/bin/kitty':undefined)).toBe('linux:/usr/bin/kitty');}); + it.each(['linux','freebsd','openbsd','netbsd'] as const)('requires the central Unix adapter policy and an installed terminal on %s',platform=>{const env={DISPLAY:':0',TERMINAL:'xterm'};expect(detectGraphicalTerminal(platform,env,command=>command==='xterm'?'/usr/local/bin/xterm':undefined)).toBe(`${platform}:/usr/local/bin/xterm`);expect(detectGraphicalTerminal(platform,env,()=>undefined)).toBe(`${platform}:unsupported`);expect(detectGraphicalTerminal(platform,{...env,DISPLAY:''},resolve)).toBe(`${platform}:unsupported`);}); + it.each(['freebsd','openbsd','netbsd'] as const)('accepts a legacy Linux descriptor after revalidating the saved Unix terminal on %s',platform=>{expect(detectGraphicalTerminal(platform,{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/local/bin/xterm'},resolve)).toBe('linux:/usr/local/bin/xterm');expect(detectGraphicalTerminal(platform,{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/local/bin/xterm'},()=>undefined)).toBe(`${platform}:unsupported`);}); + it('requires an X11 or Wayland session before selecting a Unix terminal',()=>{expect(detectGraphicalTerminal('linux',{RADIOCLI_ALARM_TERMINAL:'linux:/bin/kitty'},resolve)).toBe('linux:unsupported');}); + it('revalidates saved executable paths and refuses stale files and directories',()=>{const root=fixture();const kitty=join(root,'kitty');mkdirSync(kitty);const env={DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:`linux:${terminalPath(root,'kitty')}`};expect(detectGraphicalTerminal('linux',env)).toBe('linux:unsupported');rmSync(kitty,{recursive:true});expect(detectGraphicalTerminal('linux',env)).toBe('linux:unsupported');}); + it.skipIf(process.platform==='win32')('refuses a saved Unix terminal without executable permission',()=>{const root=fixture();writeFileSync(join(root,'kitty'),'stub',{mode:0o600});expect(detectGraphicalTerminal('linux',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:`linux:${terminalPath(root,'kitty')}`})).toBe('linux:unsupported');}); + it('resolves installed terminals from the supplied PATH',()=>{const root=fixture();writeFileSync(join(root,'kitty'),'stub',{mode:0o700});expect(detectGraphicalTerminal('linux',{DISPLAY:':0',PATH:basename(root),TERMINAL:'kitty'})).toBe(`linux:${terminalPath(root,'kitty')}`);}); + it('does not select an X11-only terminal for a Wayland-only session',()=>{expect(detectGraphicalTerminal('linux',{WAYLAND_DISPLAY:'wayland-0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/xterm'},resolve)).toBe('linux:unsupported');}); + it('does not select a Wayland-only terminal for an X11-only session',()=>{expect(detectGraphicalTerminal('linux',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/foot'},resolve)).toBe('linux:unsupported');}); + it('does not accept a saved Linux descriptor on unsupported runtimes',()=>{expect(detectGraphicalTerminal('linux',{TERMUX_VERSION:'0.118.3',DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},resolve)).toBe('termux:unsupported');expect(detectGraphicalTerminal('haiku',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},resolve)).toBe('haiku:unsupported');}); +}); + +describe('graphical terminal invocation plans',()=>{ + it.each([ + ['darwin','apple-terminal'],['darwin','iterm'],['darwin','wezterm'],['darwin','ghostty'],['darwin','kitty'], + ['linux','/tools/kitty'],['win32','console'],['win32','windows-terminal'] + ] as const)('restores the saved identity through %s:%s despite an unrelated terminal environment', (platform,name) => { + if(platform==='darwin'&&(name==='apple-terminal'||name==='iterm')&&process.platform==='win32')return; + const saved=platform==='win32' + ?{HOME:"C:\\home\\Radio ' \" $() & 单播",RADIO_ATLAS_HOME:"C:\\legacy\\Radio ' \" $() & 单播",XDG_CACHE_HOME:'C:\\cache\\saved',XDG_RUNTIME_DIR:'C:\\run\\saved'} + :{HOME:"/home/Radio ' \" $() & 单播",RADIO_ATLAS_HOME:"/legacy/Radio ' \" $() & 单播",XDG_CACHE_HOME:'/cache/saved',XDG_RUNTIME_DIR:'/run/saved'}; + const probe="process.stdout.write(JSON.stringify({home:process.env.HOME,legacy:process.env.RADIO_ATLAS_HOME,current:process.env.RADIOCLI_HOME??null,cache:process.env.XDG_CACHE_HOME,runtime:process.env.XDG_RUNTIME_DIR,args:process.argv.slice(1)}))"; + const values=['--flag',"literal ' \" $() ; & 单播",'']; + const plan=createTerminalLaunch({platform,env:{...saved,DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:`${platform}:${name}`},nodePath:process.execPath,args:['-e',probe,'--',...values],resolve}); + let command:readonly string[]; + if(plan.command==='/usr/bin/osascript')command=['/bin/sh','-c',plan.args.at(-1)!]; + else if(platform==='win32'){const invocation=nodeInvocation(plan.args,plan.environment);command=[invocation.command,...invocation.args];} + else command=plan.args.slice(plan.args.indexOf(process.execPath)); + const output=execFileSync(command[0]!,command.slice(1),{env:{RADIOCLI_HOME:'/unrelated/current',HOME:'/unrelated/home',RADIO_ATLAS_HOME:'/unrelated/legacy',XDG_CACHE_HOME:'/unrelated/cache',XDG_RUNTIME_DIR:'/unrelated/runtime'},encoding:'utf8'}); + expect(JSON.parse(output)).toEqual({home:saved.HOME,legacy:saved.RADIO_ATLAS_HOME,current:null,cache:saved.XDG_CACHE_HOME,runtime:saved.XDG_RUNTIME_DIR,args:values}); + }); + + it('clears an inherited path override even when a Unix launch has no saved settings', () => { + const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',TERMINAL:'kitty'},nodePath:process.execPath,args:['-e',"process.stdout.write(process.env.RADIOCLI_HOME??'absent')"],resolve}); + expect(execFileSync(plan.args[1]!,plan.args.slice(2),{env:{RADIOCLI_HOME:'/unrelated'},encoding:'utf8'})).toBe('absent'); + }); + + it.skipIf(process.platform==='win32').each([ + ['qterminal','requested'],['x-terminal-emulator','requested'],['x-terminal-emulator','automatic'],['x-terminal-emulator','saved'] + ] as const)('preserves exact argv when %s (%s) reparses only its first -e argument', (name,selection) => { + const root=fixture();const nodeDirectory=join(root,"Radio Tools ' 单播");const nodePath=join(nodeDirectory,'node');mkdirSync(nodeDirectory);symlinkSync(process.execPath,nodePath); + const qterminal=join(root,'qterminal');const alias=join(root,'x-terminal-emulator'); + // QTerminal parses optarg, then appends every remaining argv literally: + // https://github.com/lxqt/qterminal/blob/master/src/main.cpp#L99-L107 + // This fake implements the unquoted whitespace case used by this fixture. + writeFileSync(qterminal,`import{spawnSync}from'node:child_process';const argv=process.argv.slice(2);const start=argv.indexOf('-e');const command=[...argv[start+1].split(/\\s+/),...argv.slice(start+2)];const child=spawnSync(command[0],command.slice(1),{stdio:'inherit'});if(child.error)console.error(child.error.message);process.exit(child.status??1);`); + symlinkSync(qterminal,alias); + const values=['--flag','--option=two words','a b',"apostrophe's",'"double quotes"','$(false); & | < > %PATH%!','单播','']; + const env={DISPLAY:':0',...(selection==='requested'?{TERMINAL:name}:selection==='saved'?{RADIOCLI_ALARM_TERMINAL:`linux:${alias}`}:{})}; + const plan=createTerminalLaunch({platform:'linux',env,nodePath,args:['-e','process.stdout.write(JSON.stringify(process.argv.slice(1)))','--',...values],resolve:command=>command==='/bin/sh'?'/bin/sh':command===alias||command==='x-terminal-emulator'?alias:command==='qterminal'?qterminal:undefined}); + expect(plan.command).toBe(join(root,name)); + const result=spawnSync(process.execPath,[plan.command,...plan.args],{env:{},encoding:'utf8'}); + expect(result.status,result.stderr).toBe(0);expect(JSON.parse(result.stdout)).toEqual(values); + }); + it.each([ + ['gnome-terminal',['--']],['mate-terminal',['-x']],['xfce4-terminal',['-x']],['terminator',['-x']], + ['wezterm',['start','--always-new-process','--']],['kitty',['-e']],['alacritty',['-e']],['konsole',['-e']],['ghostty',['-e']],['tilix',['-e']],['xterm',['-e']],['uxterm',['-e']],['foot',['--']] + ] as const)('uses %s argument boundaries with an encoded application invocation', (name,prefix)=>{ + const nodePath='/Node A/单播/node';const args=['/Radio A/cli.js','agent-ui','a;& quoted "value"']; + const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',WAYLAND_DISPLAY:'wayland-0',RADIOCLI_ALARM_TERMINAL:`linux:/tools/${name}`},nodePath,args,resolve}); + expect(plan.command).toBe(`/tools/${name}`);expect(plan.args.slice(0,prefix.length+2)).toEqual([...prefix,nodePath,'-e']); + expect(JSON.parse(Buffer.from(plan.args.at(-1)!,'base64url').toString('utf8'))).toEqual({args,environment:{}}); + }); + it.skipIf(process.platform==='win32')('uses a literal-safe shell boundary for qterminal command parsing',()=>{const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/tools/qterminal'},nodePath:process.execPath,args:['-e','process.stdout.write(process.argv[1])','--',"Data ' \" ; $() 单播"],resolve});expect(plan.args.slice(0,3)).toEqual(['-e','/bin/sh','-c']);expect(execFileSync('/bin/sh',plan.args.slice(2),{encoding:'utf8'})).toBe("Data ' \" ; $() 单播");}); + it('preserves a special data home and every argument through the Windows Node bootstrap',()=>{ + const home='C:\\Data %PATH%! & "quote" \' 单播';const values=['space value','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; + const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home},nodePath:process.execPath,args:['-e',"process.stdout.write(JSON.stringify({args:process.argv.slice(1),home:process.env.RADIOCLI_HOME}))",'--',...values],resolve,closeOnExit:true}); + const payload=nodeInvocation(plan.args,plan.environment); + expect(payload.args[1]).not.toContain('"');const output=execFileSync(payload.command,payload.args,{encoding:'utf8'});expect(JSON.parse(output)).toEqual({args:values,home});expect(plan.command).toBe('powershell.exe'); + }); + it('requests a separate Windows console without inheriting detached launcher stdio',()=>{ + const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve,closeOnExit:true}); + const script=Buffer.from(plan.args.at(-1)!,'base64').toString('utf16le'); + expect(script).toMatch(/CreateProcessW\(application, commandLine, IntPtr.Zero, IntPtr.Zero, false,\s*0x00000010, IntPtr.Zero, null, ref startup, out process\)/); + expect(script).toContain('startup.dwFlags = 0x00000001;');expect(script).not.toMatch(/GetStdHandle|Start-Process|startup\.hStd\w+\s*=/); + expect(script).toContain('CloseHandle(process.hThread)');expect(script).toContain('CloseHandle(process.hProcess)'); + expect(script).toMatch(/SetEnvironmentVariable\('[^']+',\$null,'Process'\);Add-Type/); + }); + it('keeps a console launch with ordinary deep install paths below the Windows command-line limit',()=>{ + const nodePath=`C:\\Program Files\\${'Runtime\\'.repeat(19)}node.exe`; + const cliPath=`C:\\${'RadioCLI\\'.repeat(22)}dist\\cli.js`; + const dataRoot=`C:\\${'Radio Data\\'.repeat(18)}`; + const env={RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:`${dataRoot}Home`,RADIOCLI_MPV_PATH:`${dataRoot}mpv.exe`,RADIOCLI_FFPLAY_PATH:`${dataRoot}ffplay.exe`,RADIOCLI_VLC_PATH:`${dataRoot}vlc.exe`,RADIOCLI_FFMPEG_PATH:`${dataRoot}ffmpeg.exe`}; + const plan=createTerminalLaunch({platform:'win32',env,nodePath,args:[cliPath],resolve}); + expect([nodePath,cliPath,...Object.values(env)].every(value=>value.length<260)).toBe(true); + // Include quotes, separators, and NUL in the CreateProcessW limit. + expect([plan.command,...plan.args].reduce((length,value)=>length+value.length+3,1)).toBeLessThan(32_767); + const handoff=Object.values(plan.environment!);expect(handoff).toHaveLength(1); + expect(handoff[0]!.length+1).toBeLessThan(32_767); + const inner=JSON.parse(handoff[0]!) as {command:string;args:string[]}; + expect([inner.command,...inner.args].reduce((length,value)=>length+value.length+3,1)).toBeLessThan(32_767); + }); + it.skipIf(process.platform!=='win32')('opens real Windows console TTY handles and preserves literal argv and environment',async()=>{ + const root=fixture();const cli=join(root,"Radio %PATH%! & ' 单播.cjs");const output=join(root,'console-result.json'); + const preload=join(root,'console-preload.cjs');const stages=join(root,'console-stages.jsonl');const powershellStages=join(root,'powershell-stages.log'); + // Observe both the Node bootstrap and CLI without touching standard handles + // or logging argv contents. Monitoring leaves normal exception handling intact. + writeFileSync(preload,[ + "const fs=require('node:fs')", + `const stage=process.argv[1]===${JSON.stringify(cli)}?'cli':'bootstrap'`, + `function record(event){try{fs.appendFileSync(${JSON.stringify(stages)},JSON.stringify({...event,pid:process.pid,stage})+'\\n','utf8')}catch{}}`, + "record({event:'start',argumentLengths:process.argv.map(value=>value.length)})", + "process.once('exit',code=>record({event:'exit',code}))", + "process.on('uncaughtExceptionMonitor',(error,origin)=>record({event:'uncaught',origin,code:error.code??null,message:String(error.message).slice(0,2_000)}))" + ].join(';')); + const nodeOptions=[process.env.NODE_OPTIONS,`--require ${JSON.stringify(preload)}`].filter(Boolean).join(' '); + // File output observes the actual TTY child without redirecting its stdio. + writeFileSync(cli,[ + "const fs=require('node:fs');const output=process.argv[2]", + "fs.writeFileSync(output+'.started','started','utf8')", + 'const result={args:process.argv.slice(3),home:process.env.RADIOCLI_HOME,mpv:process.env.RADIOCLI_MPV_PATH}', + "for(const name of ['stdin','stdout','stderr']){try{result[name]=process[name].isTTY===true}catch(error){result[name]={code:error.code??null,message:error.message}}}", + "fs.writeFileSync(output+'.tmp',JSON.stringify(result),'utf8');fs.renameSync(output+'.tmp',output)" + ].join(';')); + const home='C:\\Data %PATH%! & "quote" \' 单播';const mpv="C:\\Players %PATH%! & ' 单播\\mpv.exe";const args=['spaces here','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; + const diagnostic:{commandCharacters:number;detached:boolean;spawned:boolean;code:number|null;signal:NodeJS.Signals|null;error?:string;stdout:string;stderr:string}={commandCharacters:0,detached:false,spawned:false,code:null,signal:null,stdout:'',stderr:''}; + await launchTerminalCommand({platform:'win32',env:{...process.env,NODE_OPTIONS:nodeOptions,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:[cli,output,...args],closeOnExit:true,spawn:(command,args,options)=>{ + diagnostic.detached=Boolean(options?.detached); + const environment={...options?.env};const handoff=environment.RADIOCLI_WINDOWS_CONSOLE_COMMAND;expect(handoff).toBeDefined(); + const inner=JSON.parse(handoff!) as {command:string;args:string[]};const index=inner.args.indexOf('-EncodedCommand')+1;expect(index).toBeGreaterThan(0); + const script=Buffer.from(inner.args[index]!,'base64').toString('utf16le'); + // Observe PowerShell startup without waiting in the outer launcher or + // redirecting the console. Preserve the invocation's existing exit logic. + const path=Buffer.from(powershellStages,'utf8').toString('base64'); + const prefix=`$radiocliProbe=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${path}'));[IO.File]::AppendAllText($radiocliProbe,"powershell-start $PID\n");trap{[IO.File]::AppendAllText($radiocliProbe,([string]$_.Exception.Message)+"\n");break};`; + const observed=prefix+script.replace('exit $LASTEXITCODE','[IO.File]::AppendAllText($radiocliProbe,"native-exit $LASTEXITCODE\n");exit $LASTEXITCODE'); + inner.args[index]=Buffer.from(observed,'utf16le').toString('base64');environment.RADIOCLI_WINDOWS_CONSOLE_COMMAND=JSON.stringify(inner); + const outerArgs=[...args];const outerIndex=outerArgs.indexOf('-EncodedCommand')+1;expect(outerIndex).toBeGreaterThan(0); + const outer=Buffer.from(outerArgs[outerIndex]!,'base64').toString('utf16le'); + const outerMarker=`[IO.File]::AppendAllText([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${path}')),"powershell-outer-start $PID\n");`; + outerArgs[outerIndex]=Buffer.from(outerMarker+outer,'utf16le').toString('base64'); + diagnostic.commandCharacters=[command,...outerArgs].reduce((length,value)=>length+value.length+3,1); + // Capture only the outer launcher; CreateProcessW still creates the child + // with its own unredirected console handles. Never log inherited env. + const child=spawn(command,outerArgs,{...options,env:environment,stdio:['ignore','pipe','pipe']}); + child.stdout?.on('data',chunk=>diagnostic.stdout=(diagnostic.stdout+String(chunk)).slice(-4_000)); + child.stderr?.on('data',chunk=>diagnostic.stderr=(diagnostic.stderr+String(chunk)).slice(-4_000)); + child.once('spawn',()=>diagnostic.spawned=true); + child.once('error',error=>diagnostic.error=error.message); + child.once('close',(code,signal)=>{diagnostic.code=code;diagnostic.signal=signal;}); + return child; + }}).catch(error=>{throw new Error(`Windows console launcher failed: ${JSON.stringify(diagnostic)}`,{cause:error});}); + const deadline=Date.now()+10_000; + // The result precedes Node's exit hooks and the final PowerShell diagnostic. + // Wait for both writers before removing the fixture on Windows. + const completed=()=>existsSync(output)&&existsSync(powershellStages)&&/native-exit 0\r?\n/.test(readFileSync(powershellStages,'utf8')); + while(!completed()&&Date.now()setTimeout(resolve,25)); + const nodeStages=existsSync(stages)?readFileSync(stages,'utf8').slice(-8_000):''; + const powershellStage=existsSync(powershellStages)?readFileSync(powershellStages,'utf8').slice(-4_000):''; + expect(completed(),`Windows console probe did not finish: ${JSON.stringify({...diagnostic,nodeStarted:existsSync(output+'.started'),nodeStages,powershellStage})}`).toBe(true); + expect(JSON.parse(readFileSync(output,'utf8'))).toEqual({stdin:true,stdout:true,stderr:true,args,home,mpv}); + },15_000); + it.each([{platform:'linux' as const,terminal:'linux:/tools/kitty'},{platform:'darwin' as const,terminal:'darwin:wezterm'}])('preserves configured players and network policy through an existing $terminal server',({platform,terminal})=>{ + const configured={RADIOCLI_MPV_PATH:"/Players % ! & ' 单播/mpv",RADIOCLI_FFPLAY_PATH:'/Players A/ffplay',RADIOCLI_VLC_PATH:'/Players A/vlc',RADIOCLI_FFMPEG_PATH:'/Players A/ffmpeg',RADIOCLI_OFFLINE:'0',RADIOCLI_LOW_BANDWIDTH:'true'}; + const script=`process.stdout.write(JSON.stringify(Object.fromEntries(${JSON.stringify(Object.keys(configured))}.map(key=>[key,process.env[key]]))))`; + const plan=createTerminalLaunch({platform,env:{...configured,DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:terminal,HTTPS_PROXY:'https://private-password@proxy.invalid'},nodePath:process.execPath,args:['-e',script],resolve}); + const args=plan.args.slice(plan.args.indexOf(process.execPath)+1); + const output=execFileSync(process.execPath,args,{encoding:'utf8',env:{...process.env,...Object.fromEntries(Object.keys(configured).map(key=>[key,'stale-server-value']))}}); + expect(JSON.parse(output)).toEqual(configured); + expect(JSON.parse(Buffer.from(args.at(-1)!,'base64url').toString('utf8')).environment).toEqual(configured); + }); + it.skipIf(process.platform==='win32')('passes configured player paths literally through Apple Terminal shell commands',()=>{ + const mpv="/Players $ % ! & ' 单播/mpv"; + const plan=createTerminalLaunch({platform:'darwin',env:{RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:['-e','process.stdout.write(process.env.RADIOCLI_MPV_PATH)'],resolve}); + expect(execFileSync('/bin/sh',['-c',plan.args.at(-1)!],{encoding:'utf8',env:{...process.env,RADIOCLI_MPV_PATH:'stale'}})).toBe(mpv); + }); + it.each(['darwin','linux','win32'] as const)('rejects control characters in approved %s terminal environment values',platform=>{expect(()=>createTerminalLaunch({platform,env:{DISPLAY:':0',RADIOCLI_MPV_PATH:'/tools/mpv\nInjected=value'},nodePath:'/node',args:['/cli.js'],resolve})).toThrow(/RADIOCLI_MPV_PATH.*control/i);}); + it('uses the same data-home bootstrap without assuming /usr/bin/env exists',()=>{const home="/Data A/O'Brien 单播";const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/tools/kitty',RADIOCLI_HOME:home},nodePath:process.execPath,args:['-e','process.stdout.write(process.env.RADIOCLI_HOME)'],resolve});expect(plan.args).not.toContain('/usr/bin/env');expect(execFileSync(process.execPath,plan.args.slice(2),{encoding:'utf8'})).toBe(home);}); + it('fails before spawning when Windows has no runnable PowerShell',()=>{expect(()=>createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve:()=>undefined})).toThrow(/PowerShell.*unavailable/i);}); +}); + +describe('terminal launcher acceptance',()=>{ + it('rejects a launcher that exits unsuccessfully immediately after spawn',async()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();const result=waitForLaunch(child);child.emit('spawn');child.emit('close',1);await expect(result).rejects.toThrow(/launcher.*exit.*1/i);}); + it('accepts a live launcher without claiming application readiness',async()=>{vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();const result=waitForLaunch(child);child.emit('spawn');await vi.advanceTimersByTimeAsync(100);await expect(result).resolves.toBeUndefined();expect(child.unref).toHaveBeenCalledOnce();}); + it('bounds a launcher that never reports process startup',async()=>{vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;const result=expect(waitForLaunch(child)).rejects.toThrow(/did not report process startup/i);await vi.advanceTimersByTimeAsync(3_000);await result;}); + it('keeps a transient console bootstrap referenced until it exits',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const launch=vi.fn(()=>child); + const result=launchTerminalCommand({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve,spawn:launch});let settled=false;void result.then(()=>settled=true); + expect(launch).toHaveBeenCalledWith('powershell.exe',expect.any(Array),expect.objectContaining({detached:false,stdio:'ignore',windowsHide:true})); + child.emit('spawn');await vi.advanceTimersByTimeAsync(100); + expect(settled).toBe(false);expect(child.unref).not.toHaveBeenCalled(); + child.emit('close',0);await expect(result).resolves.toBe('win32:console');expect(child.kill).not.toHaveBeenCalled(); + }); + it('rejects a transient bootstrap failure after the ordinary launcher grace period',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const result=waitForLaunch(child,{waitForExit:true}).then(()=>undefined,error=>error as Error); + child.emit('spawn');await vi.advanceTimersByTimeAsync(250);child.emit('close',7); + expect(await result).toMatchObject({message:expect.stringMatching(/launcher.*exit.*7/i)});expect(child.kill).not.toHaveBeenCalled(); + }); + it('bounds cleanup when a transient bootstrap never confirms its exit',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const result=waitForLaunch(child,{waitForExit:true}).then(()=>undefined,error=>error as Error); + child.emit('spawn');await vi.advanceTimersByTimeAsync(9_999);expect(child.kill).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(child.kill).toHaveBeenCalledWith('SIGTERM');expect(child.unref).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_000); + expect(await result).toMatchObject({message:expect.stringMatching(/bootstrap.*did not complete/i)});expect(child.kill).toHaveBeenCalledTimes(2);expect(child.kill).toHaveBeenLastCalledWith('SIGKILL');expect(child.unref).toHaveBeenCalledOnce(); + }); + it('absorbs late native errors after accepting a detached application',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn(); + const result=waitForLaunch(child);child.emit('spawn');await vi.advanceTimersByTimeAsync(100);await result; + expect(()=>{child.emit('error',new Error('late native error'));child.emit('error',new Error('another native error'));}).not.toThrow(); + }); +}); diff --git a/src/platform/terminals.ts b/src/platform/terminals.ts new file mode 100644 index 0000000..a68fa01 --- /dev/null +++ b/src/platform/terminals.ts @@ -0,0 +1,182 @@ +import {spawn,type ChildProcess,type SpawnOptions} from 'node:child_process'; +import {posix,win32} from 'node:path'; +import {resolveCommandDetails} from './executables.js'; +import {identifyPlatform,nativeAdapters} from './runtime.js'; +import {powershellCommand} from './shell.js'; +import {launchEnvironment, nodeLaunchCommand, waitForLaunch} from './launch-command.js'; + +export type TerminalResolver=(command:string)=>string|undefined; +type TerminalSpawn=(command:string,args:readonly string[],options?:SpawnOptions)=>ChildProcess; +export type TerminalOptions={platform?:NodeJS.Platform;env?:NodeJS.ProcessEnv;resolve?:TerminalResolver;spawn?:TerminalSpawn}; +type TerminalCommand=TerminalOptions&{nodePath:string;args:readonly string[];title?:string;closeOnExit?:boolean}; +type TerminalLaunch={terminal:string;command:string;args:string[];environment?:Record}; + +type UnixTerminal={name:string;args:string[];display?:'x11'|'wayland';shell?:boolean}; +const unixTerminals:UnixTerminal[]=[ + {name:'ghostty',args:['-e']},{name:'wezterm',args:['start','--always-new-process','--']}, + {name:'kitty',args:['-e']},{name:'gnome-terminal',args:['--']},{name:'konsole',args:['-e']}, + {name:'xfce4-terminal',args:['-x']},{name:'x-terminal-emulator',args:['-e'],shell:true}, + {name:'alacritty',args:['-e']},{name:'foot',args:['--'],display:'wayland'}, + {name:'mate-terminal',args:['-x']},{name:'qterminal',args:['-e'],shell:true}, + {name:'terminator',args:['-x']},{name:'tilix',args:['-e']}, + {name:'xterm',args:['-e'],display:'x11'},{name:'uxterm',args:['-e'],display:'x11'} +]; +const macTerminals=['darwin:apple-terminal','darwin:iterm','darwin:wezterm','darwin:ghostty','darwin:kitty']; + +export function detectGraphicalTerminal(platform:NodeJS.Platform=process.platform,env:NodeJS.ProcessEnv=process.env,resolve:TerminalResolver=resolver(platform,env)):string{ + const host=identifyPlatform({platform,env});const adapter=nativeAdapters(host).terminal; + const unsupported=`${host.id==='unknown'?platform:host.id}:unsupported`; + const configured=env.RADIOCLI_ALARM_TERMINAL?.trim(); + if(adapter==='macos'){ + if(configured&&macTerminals.includes(configured))return configured; + const program=`${env.TERM_PROGRAM??''} ${env.__CFBundleIdentifier??''}`.toLowerCase(); + for(const name of ['iterm','wezterm','ghostty','kitty'])if(program.includes(name))return`darwin:${name}`; + return'darwin:apple-terminal'; + } + if(adapter==='windows'){ + if(configured==='win32:windows-terminal'||configured==='win32:console')return configured; + return env.WT_SESSION?'win32:windows-terminal':'win32:console'; + } + if(adapter!=='unix'||!hasDisplay(env))return unsupported; + if(configured){ + const separator=configured.indexOf(':');const prefix=separator<0?'':configured.slice(0,separator); + if(prefix===host.id||prefix==='linux'){ + const path=resolveUnixTerminal(configured.slice(separator+1),env,resolve); + return path?`${prefix}:${path}`:unsupported; + } + } + const requested=env.TERMINAL?.trim()||env.TERM_PROGRAM?.trim(); + const selected=requested&&resolveUnixTerminal(requested,env,resolve); + if(selected)return`${host.id}:${selected}`; + for(const terminal of unixTerminals){const path=resolveUnixTerminal(terminal.name,env,resolve);if(path)return`${host.id}:${path}`;} + return unsupported; +} + +export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ + const platform=options.platform??process.platform;const env=options.env??process.env;const resolve=options.resolve??resolver(platform,env); + const terminal=detectGraphicalTerminal(platform,env,resolve);const values=[options.nodePath,...options.args]; + if(values.some(value=>value.includes('\0')))throw new Error('Terminal command values cannot contain NUL bytes.'); + const environment=launchEnvironment(env,{platform}); + const direct=nodeLaunchCommand(options.nodePath,options.args,environment); + if(terminal.endsWith(':unsupported')){ + if(nativeAdapters(identifyPlatform({platform,env})).terminal==='unix'&&!hasDisplay(env))throw new Error('No graphical desktop session is available: DISPLAY and WAYLAND_DISPLAY are unset. Open radiocli manually for controls.'); + throw new Error('No supported installed graphical terminal was found. Open radiocli manually for controls.'); + } + if(terminal==='darwin:apple-terminal'||terminal==='darwin:iterm'){ + const command=`${direct.map(shellQuote).join(' ')}${options.closeOnExit?'; exit':''}`; + return{terminal,command:'/usr/bin/osascript',args:terminal==='darwin:apple-terminal'?appleTerminalScript(command):iTermScript(command)}; + } + if(terminal==='darwin:wezterm')return{terminal,command:'/usr/bin/open',args:['-na','WezTerm','--args','start','--always-new-process','--',...direct]}; + if(terminal==='darwin:ghostty')return{terminal,command:'/usr/bin/open',args:['-na','Ghostty','--args','-e',...direct]}; + if(terminal==='darwin:kitty')return{terminal,command:'/usr/bin/open',args:['-na','kitty','--args','--detach',...direct]}; + if(terminal.startsWith('win32:')){ + const systemRoot=env.SystemRoot??env.WINDIR; + const powershell=resolve('powershell.exe')??(systemRoot?resolve(win32.join(systemRoot,'System32','WindowsPowerShell','v1.0','powershell.exe')):undefined)??resolve('pwsh.exe'); + if(!powershell)throw new Error('PowerShell is unavailable; a RadioCLI terminal cannot be requested.'); + // PowerShell 5 does not preserve arbitrary native argv quoting. Only a + // fixed quote-free Node program and encoded JSON cross that boundary. + const args=powershellCommand(direct,{}, {keepOpen:!options.closeOnExit}); + if(terminal==='win32:console')return{terminal,command:powershell,...newWindowsConsole(powershell,args)}; + const wt=resolve('wt.exe')??(env.LOCALAPPDATA?resolve(win32.join(env.LOCALAPPDATA,'Microsoft','WindowsApps','wt.exe')):undefined); + if(!wt)throw new Error('Windows Terminal is unavailable; the saved terminal cannot be requested.'); + return{terminal,command:wt,args:['-w','new','new-tab','--title',options.title??'RadioCLI',powershell,...args]}; + } + const executable=terminal.slice(terminal.indexOf(':')+1);const spec=unixTerminals.find(item=>item.name===posix.basename(executable)); + if(!spec)throw new Error('The saved graphical terminal is unsupported.'); + // QTerminal reparses only its first -e argument, then appends argv literally. + // The alternatives alias can select QTerminal too, so both use the fixed + // POSIX shell boundary. This also preserves argv for ordinary -e terminals. + if(spec.shell){const sh=resolve('/bin/sh');if(!sh)throw new Error('The POSIX shell required by this terminal is unavailable.');return{terminal,command:executable,args:[...spec.args,sh,'-c',direct.map(shellQuote).join(' ')]};} + return{terminal,command:executable,args:[...spec.args,...direct]}; +} + +/** Request acceptance is separate from the caller's TUI/session verification. */ +export async function launchTerminalCommand(options:TerminalCommand):Promise{ + const plan=createTerminalLaunch(options); + // PowerShell can exit without executing its script under DETACHED_PROCESS. + // Keep this short-lived bootstrap attached until it creates the independent + // console. The new console, not its bootstrap, owns the interactive handles. + // https://github.com/nodejs/node/issues/51018 + const consoleBootstrap=plan.terminal==='win32:console'; + const child=(options.spawn??spawn)(plan.command,plan.args,{env:{...(options.env??process.env),...plan.environment},detached:!consoleBootstrap,stdio:'ignore',windowsHide:consoleBootstrap}); + await waitForLaunch(child,{waitForExit:consoleBootstrap});return plan.terminal; +} + +function resolver(platform:NodeJS.Platform,env:NodeJS.ProcessEnv):TerminalResolver{return command=>resolveCommandDetails(command,{platform,env}).path??undefined;} +function hasDisplay(env:NodeJS.ProcessEnv){return Boolean(env.DISPLAY?.trim()||env.WAYLAND_DISPLAY?.trim());} +function resolveUnixTerminal(input:string,env:NodeJS.ProcessEnv,resolve:TerminalResolver):string|undefined{ + const spec=unixTerminals.find(item=>item.name===posix.basename(input).toLowerCase()); + if(!spec||spec.display==='x11'&&!env.DISPLAY?.trim()||spec.display==='wayland'&&!env.WAYLAND_DISPLAY?.trim())return undefined; + const path=resolve(input);return path&&unixTerminals.some(item=>item.name===posix.basename(path))?path:undefined; +} +function shellQuote(value:string):string{return`'${value.replaceAll("'",`'\\''`)}'`;} +function newWindowsConsole(powershell: string, args: readonly string[]): {args: string[]; environment: Record} { + const key = 'RADIOCLI_WINDOWS_CONSOLE_COMMAND'; + // The transient launcher has NUL stdio. CreateProcessW explicitly creates + // new console buffers without inheriting those handles; STARTF_USESTDHANDLES + // must stay unset. ShellExecute-based Start-Process does not provide this + // handle contract. Only fixed flags and encoded data enter the argument string. + // https://learn.microsoft.com/en-us/windows/console/creation-of-a-console + // https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw + const native = String.raw` +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +public static class RadioCliConsole { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo { + public uint cb; + public string lpReserved, lpDesktop, lpTitle; + public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags; + public short wShowWindow, cbReserved2; + public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError; + } + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation { + public IntPtr hProcess, hThread; + public uint dwProcessId, dwThreadId; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW(string application, StringBuilder commandLine, + IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startup, + out ProcessInformation process); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + public static void Launch(string application, string arguments) { + var commandLine = new StringBuilder("\"" + application + "\" " + arguments); + if (commandLine.Length >= 32767) throw new ArgumentException("The Windows console command exceeds the native command-line limit."); + var startup = new StartupInfo(); + startup.cb = (uint)Marshal.SizeOf(typeof(StartupInfo)); + startup.dwFlags = 0x00000001; // STARTF_USESHOWWINDOW only; no inherited standard handles. + startup.wShowWindow = 1; // SW_SHOWNORMAL + ProcessInformation process; + if (!CreateProcessW(application, commandLine, IntPtr.Zero, IntPtr.Zero, false, + 0x00000010, IntPtr.Zero, null, ref startup, out process)) { // CREATE_NEW_CONSOLE + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + } +}`; + const script = [ + "$ErrorActionPreference='Stop'", + "$ProgressPreference='SilentlyContinue'", + `$radiocliConsole=[Environment]::GetEnvironmentVariable('${key}','Process') | ConvertFrom-Json`, + `[Environment]::SetEnvironmentVariable('${key}',$null,'Process')`, + `Add-Type -TypeDefinition '${native.replaceAll("'", "''")}'`, + "[RadioCliConsole]::Launch([string]$radiocliConsole.command, [string]::Join(' ', [string[]]$radiocliConsole.args))" + ].join(';'); + // Only this short-lived outer process needs the handoff. Encoding the inner + // PowerShell command again exceeds Windows' command-line limit for ordinary + // deep install paths. Clear the private variable before creating the console. + return { + args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], + environment: {[key]: JSON.stringify({command: powershell, args})} + }; +} +function appleTerminalScript(command:string):string[]{return['-e','on run argv','-e','tell application "Terminal"','-e','activate','-e','do script (item 1 of argv)','-e','end tell','-e','end run',command];} +function iTermScript(command:string):string[]{return['-e','on run argv','-e','tell application "iTerm"','-e','activate','-e','set w to (create window with default profile)','-e','tell current session of w to write text (item 1 of argv)','-e','end tell','-e','end run',command];} diff --git a/src/player/airplay-discovery.test.ts b/src/player/airplay-discovery.test.ts index 01e4c56..e533728 100644 --- a/src/player/airplay-discovery.test.ts +++ b/src/player/airplay-discovery.test.ts @@ -1,7 +1,21 @@ -import {describe, expect, it} from 'vitest'; -import {parseRaopBrowseOutput, parseRaopLookupOutput} from './airplay-discovery.js'; +import * as childProcess from 'node:child_process'; +import {describe, expect, it, vi} from 'vitest'; +import * as commands from '../platform/executables.js'; +import {discoverAirPlayDevices, parseRaopBrowseOutput, parseRaopLookupOutput} from './airplay-discovery.js'; + +vi.mock('node:child_process', () => ({spawn: vi.fn(() => {throw new Error('discovery must remain offline');})})); describe('AirPlay RAOP discovery parsing', () => { + it('does not spawn multicast discovery in offline mode even when dns-sd exists', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const command = vi.spyOn(commands, 'commandExists').mockReturnValue(true); + const spawn = vi.mocked(childProcess.spawn); + try { + expect(await discoverAirPlayDevices({platform: 'darwin'})).toEqual([]); + expect(spawn).not.toHaveBeenCalled(); + } finally {spawn.mockClear(); command.mockRestore(); vi.unstubAllEnvs();} + }); + it('extracts RAOP instance names from dns-sd browse output', () => { expect( parseRaopBrowseOutput(` diff --git a/src/player/airplay-discovery.ts b/src/player/airplay-discovery.ts index 1e65a83..9d14005 100644 --- a/src/player/airplay-discovery.ts +++ b/src/player/airplay-discovery.ts @@ -2,7 +2,9 @@ import {spawn} from 'node:child_process'; import {lookup} from 'node:dns/promises'; import {networkInterfaces} from 'node:os'; import type {AirPlayDevice} from '../types.js'; -import {commandExists} from './command.js'; +import {commandExists} from '../platform/executables.js'; +import {networkPolicy} from '../platform/network.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; export type AirPlayDiscoveryOptions = { platform?: NodeJS.Platform; @@ -24,7 +26,7 @@ export async function discoverAirPlayDevices({ lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes }: AirPlayDiscoveryOptions = {}): Promise { - if (platform !== 'darwin' || !commandExists('dns-sd')) { + if (networkPolicy().offline || !nativeAdapters(identifyPlatform({platform})).airPlay || !commandExists('dns-sd')) { return []; } diff --git a/src/player/backend-install.test.ts b/src/player/backend-install.test.ts index cfeb095..79c2b1d 100644 --- a/src/player/backend-install.test.ts +++ b/src/player/backend-install.test.ts @@ -1,25 +1,34 @@ import {describe, expect, it} from 'vitest'; -import {detectPlaybackBackends, mpvInstallCommand, playbackBackendCapabilities, playbackBackendLabel, playbackBackendStatusLines} from './backend-install.js'; +import {detectPlaybackBackends, playbackBackendInstallHint, playbackBackendCapabilities, playbackBackendLabel, playbackBackendStatusLines} from './backend-install.js'; +import {mpvInstallCommand} from '../platform/packages.js'; + +const hintOptions = {hasCommand: () => true, getUid: () => 1000}; describe('playback backend install guidance', () => { it('uses Homebrew for macOS mpv guidance', () => { - expect(mpvInstallCommand('darwin')).toBe('brew install mpv'); + expect(mpvInstallCommand('darwin', '', {}, hintOptions)).toBe('brew install mpv'); }); it('uses apt for Debian-like Linux mpv guidance', () => { - expect(mpvInstallCommand('linux', 'ID=ubuntu\nID_LIKE=debian\n')).toBe('sudo apt install mpv'); + expect(mpvInstallCommand('linux', 'ID=ubuntu\nID_LIKE=debian\n', {}, hintOptions)).toBe('sudo apt-get install -y mpv'); }); it('uses dnf for Fedora-like Linux mpv guidance', () => { - expect(mpvInstallCommand('linux', 'ID=fedora\n')).toBe('sudo dnf install mpv'); + expect(mpvInstallCommand('linux', 'ID=fedora\n', {}, hintOptions)).toBe('sudo dnf install -y mpv'); }); it('uses winget for native Windows mpv guidance', () => { - expect(mpvInstallCommand('win32')).toBe('winget install --id shinchiro.mpv -e'); + expect(mpvInstallCommand('win32', '', {}, hintOptions)).toBe('winget install --id shinchiro.mpv -e --accept-package-agreements --accept-source-agreements'); + }); + + it.each([ + ['freebsd', 'pkg install -y mpv'], ['openbsd', 'sudo pkg_add -I mpv'], ['netbsd', 'pkgin -y install mpv'] + ] as const)('describes the explicit %s mpv installation path', (platform, command) => { + expect(mpvInstallCommand(platform, '', {}, hintOptions)).toContain(command); }); it('reports npm and native playback responsibilities separately', () => { - expect(playbackBackendStatusLines([], 'darwin')).toEqual([ + expect(playbackBackendStatusLines([], 'darwin', '', hintOptions)).toEqual([ 'playback=missing', 'playback_backend=none', 'controls=missing', @@ -32,20 +41,29 @@ describe('playback backend install guidance', () => { }); it('reports native Windows playback setup with winget commands', () => { - expect(playbackBackendStatusLines([], 'win32')).toEqual([ + expect(playbackBackendStatusLines([], 'win32', '', hintOptions)).toEqual([ 'playback=missing', 'playback_backend=none', 'controls=missing', 'controls_hint=install mpv for playback and controls', 'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg', 'guided_setup=radiocli setup', - 'install_mpv=winget install --id shinchiro.mpv -e', - 'optional_ffplay=winget install --id Gyan.FFmpeg -e' + 'install_mpv=winget install --id shinchiro.mpv -e --accept-package-agreements --accept-source-agreements', + 'optional_ffplay=winget install --id Gyan.FFmpeg -e --accept-package-agreements --accept-source-agreements' ]); }); + it.each([{backends: []}, {backends: ['ffplay']}, {backends: ['vlc']}, {backends: ['mpv']}])('keeps diagnostics on the available Scoop route for backends $backends', ({backends}) => { + const options = {hasCommand: (command: string) => command === 'scoop', getUid: () => undefined}; + const lines = playbackBackendStatusLines(backends, 'win32', '', options); + expect(lines).toContain('install_mpv=scoop install mpv (first run scoop bucket add extras)'); + expect(lines).toContain('optional_ffplay=scoop install ffmpeg'); + expect(lines.join(' ')).not.toContain('winget'); + expect(playbackBackendInstallHint('win32', '', options)).toContain('scoop install mpv'); + }); + it('prefers mpv when available', () => { - expect(playbackBackendStatusLines(['ffplay', 'mpv'], 'darwin')).toEqual([ + expect(playbackBackendStatusLines(['ffplay', 'mpv'], 'darwin', '', hintOptions)).toEqual([ 'playback=ready', 'playback_backend=mpv', 'controls=full', @@ -65,7 +83,7 @@ describe('playback backend install guidance', () => { }); it('reports AirPlay as limited output-only playback when it is the only backend', () => { - expect(playbackBackendStatusLines(['airplay'], 'darwin')).toEqual([ + expect(playbackBackendStatusLines(['airplay'], 'darwin', '', hintOptions)).toEqual([ 'playback=airplay-only', 'playback_backend=airplay', 'controls=airplay-limited', @@ -85,7 +103,7 @@ describe('playback backend install guidance', () => { }); it('reports ffplay as fallback playback with limited controls', () => { - expect(playbackBackendStatusLines(['ffplay'], 'darwin')).toEqual([ + expect(playbackBackendStatusLines(['ffplay'], 'darwin', '', hintOptions)).toEqual([ 'playback=fallback-only', 'playback_backend=ffplay', 'controls=limited', diff --git a/src/player/backend-install.ts b/src/player/backend-install.ts index 2163333..26ceeb7 100644 --- a/src/player/backend-install.ts +++ b/src/player/backend-install.ts @@ -1,5 +1,6 @@ -import {existsSync, readFileSync} from 'node:fs'; -import {commandExists} from './command.js'; +import {ffplayInstallCommand, mpvInstallCommand, type PackageHintOptions} from '../platform/packages.js'; +import {identifyPlatform, nativeAdapters, readLinuxOsRelease} from '../platform/runtime.js'; +import {commandExists} from '../platform/executables.js'; import {airPlaySenderHealth} from './airplay-sender-health.js'; type PlaybackBackendDetectionOptions = { @@ -30,7 +31,7 @@ export function detectPlaybackBackends({ backends.push('vlc'); } - if (platform === 'darwin' && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) { + if (nativeAdapters(identifyPlatform({platform})).airPlay && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) { backends.push('airplay'); } @@ -102,22 +103,24 @@ export function playbackBackendLabel(backend: string | null | undefined): string export function playbackBackendInstallHint( platform: NodeJS.Platform = process.platform, - osRelease = readLinuxOsRelease() + osRelease = readLinuxOsRelease(platform), + options: PackageHintOptions = {} ): string { - return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease)}), then run radiocli doctor.`; + return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease, process.env, options)}), then run radiocli doctor.`; } export function playbackBackendStatusLines( backends: string[], platform: NodeJS.Platform = process.platform, - osRelease = readLinuxOsRelease() + osRelease = readLinuxOsRelease(platform), + options: PackageHintOptions = {} ): string[] { const backendSet = new Set(backends); const lines = [ 'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg', 'guided_setup=radiocli setup', - `install_mpv=${mpvInstallCommand(platform, osRelease)}`, - `optional_ffplay=${ffplayInstallCommand(platform, osRelease)}` + `install_mpv=${mpvInstallCommand(platform, osRelease, process.env, options)}`, + `optional_ffplay=${ffplayInstallCommand(platform, osRelease, process.env, options)}` ]; if (backendSet.has('mpv')) { @@ -167,112 +170,3 @@ export function playbackBackendStatusLines( ...lines ]; } - -export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { - if (platform === 'darwin') { - return 'brew install mpv'; - } - - if (platform === 'win32') { - return 'winget install --id shinchiro.mpv -e'; - } - - if (platform !== 'linux') { - return 'install mpv with your system package manager'; - } - - const ids = linuxReleaseIds(osRelease); - if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { - return 'sudo apt install mpv'; - } - - if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { - return 'sudo dnf install mpv'; - } - - if (hasAny(ids, ['arch', 'manjaro'])) { - return 'sudo pacman -S mpv'; - } - - if (hasAny(ids, ['alpine'])) { - return 'sudo apk add mpv'; - } - - if (hasAny(ids, ['opensuse', 'suse'])) { - return 'sudo zypper install mpv'; - } - - return 'install mpv with your system package manager'; -} - -function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { - if (platform === 'darwin') { - return 'brew install ffmpeg'; - } - - if (platform === 'win32') { - return 'winget install --id Gyan.FFmpeg -e'; - } - - if (platform !== 'linux') { - return 'install FFmpeg with your system package manager'; - } - - const ids = linuxReleaseIds(osRelease); - if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { - return 'sudo apt install ffmpeg'; - } - - if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { - return 'sudo dnf install ffmpeg'; - } - - if (hasAny(ids, ['arch', 'manjaro'])) { - return 'sudo pacman -S ffmpeg'; - } - - if (hasAny(ids, ['alpine'])) { - return 'sudo apk add ffmpeg'; - } - - if (hasAny(ids, ['opensuse', 'suse'])) { - return 'sudo zypper install ffmpeg'; - } - - return 'install FFmpeg with your system package manager'; -} - -function readLinuxOsRelease(): string { - if (process.platform !== 'linux' || !existsSync('/etc/os-release')) { - return ''; - } - - try { - return readFileSync('/etc/os-release', 'utf8'); - } catch { - return ''; - } -} - -function linuxReleaseIds(osRelease: string): Set { - const ids = new Set(); - for (const line of osRelease.split('\n')) { - const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); - if (!match) { - continue; - } - - for (const value of match[2]!.replaceAll('"', '').split(/\s+/)) { - const normalized = value.trim().toLowerCase(); - if (normalized) { - ids.add(normalized); - } - } - } - - return ids; -} - -function hasAny(values: Set, candidates: string[]): boolean { - return candidates.some(candidate => values.has(candidate)); -} diff --git a/src/player/command-diagnostics.ts b/src/player/command-diagnostics.ts index 4e0ff10..97b9f1d 100644 --- a/src/player/command-diagnostics.ts +++ b/src/player/command-diagnostics.ts @@ -1,5 +1,5 @@ import {spawnSync} from 'node:child_process'; -import {resolveCommandDetails, type CommandDiscovery} from './command.js'; +import {resolveCommandDetails, type CommandDiscovery} from '../platform/executables.js'; export type CommandDiagnostic = { path: string | null; @@ -23,7 +23,7 @@ type CommandExecutor = ( export function diagnoseCommand(command: string, overrides: Partial = {}): CommandDiagnostic { const resolution = (overrides.resolve ?? resolveCommandDetails)(command); if (!resolution.path) { - return {...resolution, launchable: false, version: null, error: 'not found'}; + return {...resolution, launchable: false, version: null, error: resolution.error ?? 'not found'}; } const execute: CommandExecutor = overrides.execute ?? ((path, args, options) => spawnSync(path, args, options)); diff --git a/src/player/mpv-ipc-client.test.ts b/src/player/mpv-ipc-client.test.ts index 56c5ce0..7fa6240 100644 --- a/src/player/mpv-ipc-client.test.ts +++ b/src/player/mpv-ipc-client.test.ts @@ -12,6 +12,26 @@ afterEach(async () => { }); describe('MpvIpcClient', () => { + it('preserves UTF-8 metadata across native socket packets', async () => { + const path = testIpcPath(); + const sockets = new Set(); + const server = createServer(socket => { + sockets.add(socket); + socket.on('error', () => undefined); + socket.once('data', request => { + const {request_id} = JSON.parse(String(request)) as {request_id: number}; + const response = Buffer.from(`${JSON.stringify({request_id, error: 'success', data: '日本 Café'})}\n`); + const cut = response.indexOf(Buffer.from('日')) + 1; + socket.write(response.subarray(0, cut)); + setTimeout(() => { if (!socket.destroyed) socket.write(response.subarray(cut)); }, 20); + }); + }); + await listen(server, path); + const client = new MpvIpcClient(path, 500); + cleanupTasks.push(() => closeTestIpc(client, server, sockets, path)); + await expect(client.query({command: ['get_property', 'media-title']})).resolves.toBe('日本 Café'); + }); + it('multiplexes concurrent requests over one socket and reconnects after a drop', async () => { const path = testIpcPath(); const sockets = new Set(); diff --git a/src/player/mpv-ipc-client.ts b/src/player/mpv-ipc-client.ts index f007331..dee76d0 100644 --- a/src/player/mpv-ipc-client.ts +++ b/src/player/mpv-ipc-client.ts @@ -122,7 +122,8 @@ export class MpvIpcClient { this.socket = socket; this.connectingSocket = null; this.buffer = ''; - socket.on('data', chunk => this.consume(chunk.toString('utf8'))); + socket.setEncoding('utf8'); + socket.on('data', chunk => this.consume(String(chunk))); socket.on('error', error => this.dropSocket(socket, error)); socket.on('close', () => this.dropSocket(socket, new Error('mpv IPC connection closed.'))); resolve(socket); diff --git a/src/player/player-controller.test.ts b/src/player/player-controller.test.ts index 6c43b12..48fe39a 100644 --- a/src/player/player-controller.test.ts +++ b/src/player/player-controller.test.ts @@ -3,16 +3,16 @@ import {spawn} from 'node:child_process'; import {existsSync, unlinkSync} from 'node:fs'; import {createServer, type Server} from 'node:net'; import {afterEach, describe, expect, it, vi} from 'vitest'; -import {commandExists} from './command.js'; +import {commandExists} from '../platform/executables.js'; import {discoverAirPlayDevices} from './airplay-discovery.js'; -import {createMpvIpcPath, extractMpvTitle, isPlaybackOutputError, PlayerController} from './player-controller.js'; +import {extractMpvTitle, isPlaybackOutputError, PlayerController} from './player-controller.js'; import type {AirPlayDevice, AppSettings, Station} from '../types.js'; vi.mock('node:child_process', () => ({ spawn: vi.fn() })); -vi.mock('./command.js', () => ({ +vi.mock('../platform/executables.js', () => ({ commandExists: vi.fn(), resolveCommand: vi.fn() })); @@ -64,16 +64,6 @@ describe('extractMpvTitle', () => { }); }); -describe('createMpvIpcPath', () => { - it('uses a Unix socket path on POSIX platforms', () => { - expect(createMpvIpcPath('linux', 123, 456)).toMatch(/radiocli-123-456\.sock$/); - }); - - it('uses a Windows named pipe path on native Windows', () => { - expect(createMpvIpcPath('win32', 123, 456)).toBe('\\\\.\\pipe\\radiocli-123-456'); - }); -}); - describe('PlayerController lifecycle', () => { it('throws before spawning when no playback backend is available', async () => { commandExistsMock.mockReturnValue(false); @@ -514,6 +504,100 @@ describe('PlayerController lifecycle', () => { await mpvServer.close(); }); + it('recovers an ARM Linux mpv audio initialization failure by trying ALSA first', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const failedChild = fakeChildProcess(); + const recoveredChild = fakeChildProcess(); + const mpv = {current: null as FakeMpvIpc | null}; + spawnMock + .mockReturnValueOnce(failedChild as never) + .mockImplementationOnce((_command, args) => { + mpv.current = fakeMpvIpc(mpvIpcPath(args)); + return recoveredChild as never; + }); + const controller = new PlayerController( + () => settings({preferredBackend: 'mpv'}), + {platform: 'linux', arch: 'arm64', env: {}} + ); + + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + failedChild.stderr.emit('data', '[ao] Failed to initialize audio driver pipewire\nCould not open/initialize audio device -> no sound.\n'); + failedChild.emit('exit', 2, null); + await playing; + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--ao=alsa,'); + expect(spawnMock.mock.calls[1]?.[1]).toContain('--ao=alsa,'); + expect(controller.getState()).toMatchObject({backend: 'mpv', state: 'playing', ready: true}); + + await controller.stop(); + await expectFakeMpv(mpv.current).close(); + }); + + it.each([ + ['macOS', {platform: 'darwin' as const, arch: 'arm64', env: {}}], + ['Windows', {platform: 'win32' as const, arch: 'arm64', env: {}}], + ['Linux x64', {platform: 'linux' as const, arch: 'x64', env: {}}], + ['explicit ARM Linux output', {platform: 'linux' as const, arch: 'arm64', env: {RADIOCLI_MPV_AUDIO_OUTPUT: 'pipewire'}}] + ])('does not override mpv audio selection on %s', async (_label, runtime) => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'}), runtime); + + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.stderr.emit('data', '[ao] Failed to initialize audio driver pipewire\nCould not open/initialize audio device -> no sound.\n'); + child.emit('exit', 2, null); + + await expect(playing).rejects.toMatchObject({ + name: 'PlaybackOutputError', + message: expect.stringContaining('Could not open/initialize audio device') + }); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('keeps a station-specific mpv failure eligible for auto-skip without exposing its URL', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController( + () => settings({preferredBackend: 'mpv'}), + {platform: 'linux', arch: 'arm64', env: {}} + ); + + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.stderr.emit('data', 'Failed to open https://listener:secret@streams.example.com/private?token=secret\n'); + child.emit('exit', 2, null); + const error = await playing.then(() => null, (reason: unknown) => reason); + + expect(error).toBeInstanceOf(Error); + expect(isPlaybackOutputError(error)).toBe(false); + expect((error as Error).message).toContain('[stream URL]'); + expect((error as Error).message).not.toContain('secret'); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('reports an OS-level player launch failure immediately as a non-skippable output error', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv', tuneTimeoutSeconds: 30})); + + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.emit('error', new Error('spawn mpv EACCES')); + + await expect(playing).rejects.toMatchObject({ + name: 'PlaybackOutputError', + message: expect.stringContaining('spawn mpv EACCES') + }); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(controller.getState()).toMatchObject({backend: 'mpv', state: 'error', ready: false}); + }); + it('syncs external mpv pause changes from macOS media controls', async () => { commandExistsMock.mockImplementation(command => command === 'mpv'); const child = fakeChildProcess(); diff --git a/src/player/player-controller.ts b/src/player/player-controller.ts index 24600e9..c881540 100644 --- a/src/player/player-controller.ts +++ b/src/player/player-controller.ts @@ -1,16 +1,16 @@ import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; import {existsSync, unlinkSync} from 'node:fs'; -import {tmpdir} from 'node:os'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {AirPlayDevice, AppSettings, IcyNowPlaying, PlaybackDiagnostics, PlaybackState, Station} from '../types.js'; import {detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage} from './backend-install.js'; -import {resolveCommand} from './command.js'; +import {resolveCommand} from '../platform/executables.js'; import {discoverAirPlayDevices} from './airplay-discovery.js'; import {airPlaySenderHealth} from './airplay-sender-health.js'; import {encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage, type AirPlayWorkerCommand, type AirPlayWorkerEvent} from './airplay-worker-protocol.js'; import {safeMediaTarget, sanitizeTerminalText} from '../safety.js'; import {MpvIpcClient} from './mpv-ipc-client.js'; +import {mpvIpcPath} from '../platform/ipc.js'; export type PlayerEvent = (state: PlaybackState) => void; export type MetadataEvent = (metadata: IcyNowPlaying) => void; @@ -20,6 +20,21 @@ export type PlaybackControlResult = { }; const minAirPlayTuneTimeoutSeconds = 30; +const maxPlayerDiagnosticCharacters = 4096; + +type PlayerRuntime = { + platform: NodeJS.Platform; + arch: string; + env: NodeJS.ProcessEnv; +}; + +type PlayerExit = { + backend: 'mpv' | 'ffplay' | 'vlc' | 'airplay'; + code: number | null; + signal: NodeJS.Signals | null; + diagnostic: string; + spawnError?: boolean; +}; export class PlaybackOutputError extends Error { constructor(message: string) { @@ -62,8 +77,12 @@ export class PlayerController { private confirmedMpvVolume = 70; private mpvSessionId = 0; private stopPromise: Promise | null = null; + private lastPlayerExit: PlayerExit | null = null; - constructor(private readonly getSettings: () => AppSettings) {} + constructor( + private readonly getSettings: () => AppSettings, + private readonly runtime: PlayerRuntime = {platform: process.platform, arch: process.arch, env: process.env} + ) {} onChange(listener: PlayerEvent): () => void { this.listeners.add(listener); @@ -199,7 +218,25 @@ export class PlayerController { }); if (backend === 'mpv') { this.playWithMpv(url, station.name); - await this.waitForReady(backend); + try { + await this.waitForReady(backend); + } catch (error) { + if (!this.shouldRetryMpvWithAlsa(error)) throw error; + await this.stop(); + this.backend = backend; + this.setState({ + backend, + state: 'loading', + message: `Opening ${station.name}`, + volume: this.getSettings().volume, + muted: false, + stationName: station.name, + streamUrl: url, + ready: false + }); + this.playWithMpv(url, station.name, 'alsa,'); + await this.waitForReady(backend); + } } else if (backend === 'ffplay') { this.playWithFfplay(url); await this.waitForReady(backend); @@ -442,8 +479,8 @@ export class PlayerController { return `No playback backend found. ${playbackBackendInstallHint()}`; } - private playWithMpv(url: string, initialTitle: string): void { - this.ipcPath = createMpvIpcPath(); + private playWithMpv(url: string, initialTitle: string, recoveredAudioOutput?: string): void { + this.ipcPath = mpvIpcPath(); this.mpvIpcClient = new MpvIpcClient(this.ipcPath); this.mpvSessionId += 1; this.confirmedMpvVolume = clampVolume(this.getSettings().volume); @@ -453,9 +490,11 @@ export class PlayerController { resolveCommand('mpv') ?? 'mpv', [ '--no-video', - '--really-quiet', + '--msg-level=all=warn', '--force-window=no', - ...(process.env.RADIOCLI_MPV_AUDIO_OUTPUT ? [`--ao=${process.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] : []), + ...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT + ? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] + : []), `--force-media-title=${this.currentMpvMediaTitle}`, `--volume=${this.getSettings().volume}`, `--input-ipc-server=${this.ipcPath}`, @@ -730,27 +769,49 @@ export class PlayerController { private wireProcess(): void { const child = this.process; - if (!child) { + const backend = this.backend; + if (!child || !backend) { return; } + this.lastPlayerExit = null; + let diagnostic = ''; + child.stderr.on('data', chunk => { + diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk)); + }); + // Local players can write diagnostics indefinitely. Drain both pipes so a // full OS pipe buffer can never stall playback. child.stdout.resume?.(); child.stderr.resume?.(); child.on('error', error => { + diagnostic = appendPlayerDiagnostic(diagnostic, error.message); + if (this.process !== child) return; + this.lastPlayerExit = {backend, code: null, signal: null, diagnostic, spawnError: true}; + this.rejectPendingAirPlayReady(error); + this.rejectPendingAirPlayRetune(error); + if (backend === 'airplay') { + this.currentAirPlayDevice = null; + this.currentAirPlayDeviceId = null; + this.pendingAirPlayPasscode = null; + this.airPlaySessionEstablished = false; + } + this.process = null; + this.stopMpvPolling(); + this.cleanupIpc(); this.setState({ ...this.state, - backend: this.backend ?? 'none', + backend, state: 'error', message: error.message, ready: false }); }); - child.on('exit', code => { + child.on('exit', (code, signal) => { if (this.process === child) { + this.lastPlayerExit = {backend, code, signal, diagnostic}; this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.')); if (this.backend === 'airplay') { this.currentAirPlayDevice = null; @@ -888,11 +949,15 @@ export class PlayerController { while (Date.now() - started < timeoutMs) { if (!this.process) { - throw new Error('Player exited before the stream became ready.'); + throw this.playerExitedBeforeReady(backend); } if (backend === 'ffplay' || backend === 'vlc') { - await waitForStartupWindow(() => this.process, Math.min(500, timeoutMs)); + await waitForStartupWindow( + () => this.process, + Math.min(500, timeoutMs), + () => this.playerExitedBeforeReady(backend) + ); return; } @@ -914,6 +979,26 @@ export class PlayerController { throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`); } + private shouldRetryMpvWithAlsa(error: unknown): boolean { + return this.runtime.platform === 'linux' && + (this.runtime.arch === 'arm' || this.runtime.arch === 'arm64') && + !this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT && + isAudioOutputInitializationFailure(this.lastPlayerExit?.diagnostic ?? '') && + isPlaybackOutputError(error); + } + + private playerExitedBeforeReady(backend: 'mpv' | 'ffplay' | 'vlc'): Error { + const exit = this.lastPlayerExit?.backend === backend ? this.lastPlayerExit : null; + const reason = exit + ? exit.signal ? `signal ${exit.signal}` : exit.code === null ? 'without an exit code' : `code ${exit.code}` + : 'before its exit status was available'; + const diagnostic = concisePlayerDiagnostic(exit?.diagnostic ?? ''); + const message = `${backend} exited before the stream became ready (${reason})${diagnostic ? `: ${diagnostic}` : '.'}`; + return isBackendInitializationFailure(backend, exit, diagnostic) + ? new PlaybackOutputError(message) + : new Error(message); + } + private async hasMpvAudioStarted(): Promise { const [timePos, audioPts] = await Promise.all([ this.queryMpv({command: ['get_property', 'time-pos']}).catch(() => null), @@ -1040,18 +1125,6 @@ export class PlayerController { } } -export function createMpvIpcPath( - platform: NodeJS.Platform = process.platform, - pid = process.pid, - timestamp = Date.now() -): string { - if (platform === 'win32') { - return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`; - } - - return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`); -} - function airPlayWorkerPath(): string { const currentPath = fileURLToPath(import.meta.url); const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js'; @@ -1123,17 +1196,56 @@ export function extractMpvTitle(metadata: Record | null): string return undefined; } -async function waitForStartupWindow(getProcess: () => ChildProcessWithoutNullStreams | null, ms: number): Promise { +async function waitForStartupWindow( + getProcess: () => ChildProcessWithoutNullStreams | null, + ms: number, + exitedError: () => Error = () => new Error('Player exited before the stream became ready.') +): Promise { const started = Date.now(); while (Date.now() - started < ms) { if (!getProcess()) { - throw new Error('Player exited before the stream became ready.'); + throw exitedError(); } await delay(Math.min(100, ms - (Date.now() - started))); } } +function appendPlayerDiagnostic(current: string, chunk: string): string { + const combined = current + chunk; + return combined.length <= maxPlayerDiagnosticCharacters + ? combined + : combined.slice(-maxPlayerDiagnosticCharacters); +} + +function concisePlayerDiagnostic(value: string): string { + const lines = value + .split(/\r?\n/) + .map(line => sanitizeTerminalText(line.replace(/https?:\/\/\S+/gi, '[stream URL]'))) + .filter((line): line is string => Boolean(line)) + .slice(-3) + .join(' · ') + .slice(0, 700); + return lines; +} + +function isAudioOutputInitializationFailure(value: string): boolean { + return /audio output initialization failed|could not open\/initialize audio device|failed to initialize audio driver/i.test(value); +} + +function isBackendInitializationFailure( + backend: 'mpv' | 'ffplay' | 'vlc', + exit: PlayerExit | null, + diagnostic: string +): boolean { + if (isAudioOutputInitializationFailure(diagnostic)) return true; + if (exit?.spawnError) return true; + if (/unknown (?:command line )?option|error initializing|failed to (?:create|connect to) .*(?:socket|pipe)/i.test(diagnostic)) return true; + // mpv reserves status 1 for player initialization and invalid options. A + // stream that could not be played uses status 2 and remains skippable. + return backend === 'mpv' && exit?.code === 1; +} + function cleanMetadataTitle(value: string | undefined): string | undefined { const normalized = cleanMediaTitle(value); if (!normalized) { diff --git a/src/providers/cache.ts b/src/providers/cache.ts index 1788910..e2788aa 100644 --- a/src/providers/cache.ts +++ b/src/providers/cache.ts @@ -1,6 +1,6 @@ import {chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync} from 'node:fs'; import {createHash} from 'node:crypto'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {join} from 'node:path'; type CacheEnvelope = { @@ -116,37 +116,8 @@ export class ProviderCache { } function defaultProviderCachePath(): string { - if (process.env.RADIOCLI_HOME) { - return join(process.env.RADIOCLI_HOME, 'radiocli-cache.json'); - } - - if (process.env.RADIO_ATLAS_HOME) { - return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas-cache.json'); - } - - const currentPath = currentDefaultProviderCachePath(); - const legacyPath = legacyDefaultProviderCachePath(); - return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath; -} - -function currentDefaultProviderCachePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Caches', 'radiocli', 'radiocli-cache.json'); - } - - if (process.platform === 'win32') { - return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'radiocli-cache.json'); - } - - return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radiocli', 'radiocli-cache.json'); -} - -function legacyDefaultProviderCachePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas-cache.json'); - } - - return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radio-atlas', 'radio-atlas-cache.json'); + const {cache, legacyCache} = platformPaths(); + return existsSync(cache) || !existsSync(legacyCache) ? cache : legacyCache; } function writeJsonAtomically(filePath: string, value: unknown): void { @@ -163,7 +134,7 @@ function writeJsonAtomically(filePath: string, value: unknown): void { } } -export function backupBadFile(filePath: string): void { +function backupBadFile(filePath: string): void { if (!existsSync(filePath)) { return; } diff --git a/src/providers/radio-browser.test.ts b/src/providers/radio-browser.test.ts index 7b198c3..e7ac7c3 100644 --- a/src/providers/radio-browser.test.ts +++ b/src/providers/radio-browser.test.ts @@ -1,6 +1,7 @@ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; +import {createServer} from 'node:http'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {ProviderCache} from './cache.js'; import {RadioBrowserProvider} from './radio-browser.js'; @@ -10,6 +11,8 @@ const roots: string[] = []; describe('RadioBrowserProvider', () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); for (const root of roots.splice(0)) { rmSync(root, {recursive: true, force: true}); @@ -172,6 +175,79 @@ describe('RadioBrowserProvider', () => { expect(fetch).not.toHaveBeenCalled(); }); + it('discovers the current Radio Browser servers and ignores unsafe or non-HTTPS SRV records', async () => { + const resolveSrv = vi.fn(async () => [ + {name: 'de1.api.radio-browser.info', port: 443, priority: 1, weight: 1}, + {name: 'DE1.api.radio-browser.info.', port: 443, priority: 1, weight: 1}, + {name: 'wrong.example.com', port: 443, priority: 1, weight: 100}, + {name: 'insecure.api.radio-browser.info', port: 80, priority: 1, weight: 100} + ]); + const fetch = mockFetch(url => { + if (url.origin === 'https://all.api.radio-browser.info') { + return jsonResponse({message: 'unavailable'}, {status: 503, statusText: 'Service Unavailable'}); + } + return jsonResponse([{name: 'Japan', iso_3166_1: 'jp', stationcount: 500}]); + }); + const provider = new RadioBrowserProvider(undefined, cacheForTest(), {resolveSrv}); + + await expect(provider.countries(10)).resolves.toEqual([{name: 'Japan', code: 'JP', stationCount: 500}]); + expect(resolveSrv).toHaveBeenCalledOnce(); + expect(resolveSrv).toHaveBeenCalledWith('_api._tcp.radio-browser.info'); + expect(fetch.mock.calls.map(([input]) => new URL(String(input)).origin)).toEqual([ + 'https://all.api.radio-browser.info', + 'https://de1.api.radio-browser.info' + ]); + + fetch.mockClear(); + await provider.health(); + expect(resolveSrv).toHaveBeenCalledOnce(); + }); + + it('uses the canonical endpoint without waiting for DNS service discovery when it is healthy', async () => { + const resolveSrv = vi.fn(() => new Promise(() => {})); + const fetch = mockFetch(() => jsonResponse([{name: 'Japan', iso_3166_1: 'jp', stationcount: 500}])); + const provider = new RadioBrowserProvider(undefined, cacheForTest(), {resolveSrv}); + + await expect(provider.countries(10)).resolves.toEqual([{name: 'Japan', code: 'JP', stationCount: 500}]); + expect(fetch).toHaveBeenCalledOnce(); + expect(resolveSrv).not.toHaveBeenCalled(); + }); + + it('recovers an EAI_AGAIN lookup with the TLS-preserving system-resolver fallback', async () => { + const systemFetch = mockFetch(() => { + throw new TypeError('fetch failed', {cause: Object.assign(new Error('temporary failure in name resolution'), {code: 'EAI_AGAIN'})}); + }); + const fallbackFetch = vi.fn(async (_input: string | URL, _init?: RequestInit) => jsonResponse([ + {stationuuid: 'recovered-fm', name: 'Recovered FM', url: 'https://stream.example/live'} + ])); + const provider = new RadioBrowserProvider( + ['https://all.api.radio-browser.info'], + cacheForTest(), + {fetchWithSystemResolver: fallbackFetch, allowsDirectDnsFallback: () => true} + ); + + await expect(provider.popular(1)).resolves.toMatchObject([{id: 'recovered-fm', name: 'Recovered FM'}]); + expect(systemFetch).toHaveBeenCalledOnce(); + expect(fallbackFetch).toHaveBeenCalledOnce(); + expect(String(fallbackFetch.mock.calls[0]?.[0])).toContain('all.api.radio-browser.info/json/stations/search'); + }); + + it('does not use the direct DNS fallback when proxy routing forbids it', async () => { + const systemFetch = mockFetch(() => { + throw new TypeError('fetch failed', {cause: Object.assign(new Error('temporary failure in name resolution'), {code: 'EAI_AGAIN'})}); + }); + const fallbackFetch = vi.fn(async (_input: string | URL, _init?: RequestInit) => jsonResponse([])); + const provider = new RadioBrowserProvider( + ['https://all.api.radio-browser.info'], + cacheForTest(), + {fetchWithSystemResolver: fallbackFetch, allowsDirectDnsFallback: () => false} + ); + + await expect(provider.popular(1)).rejects.toThrow(/EAI_AGAIN|temporary failure|fetch failed/i); + expect(systemFetch).toHaveBeenCalledOnce(); + expect(fallbackFetch).not.toHaveBeenCalled(); + }); + it('returns stale cached data when every mirror is offline', async () => { const cacheFile = cacheFileForTest(); writeFileSync( @@ -211,6 +287,81 @@ describe('RadioBrowserProvider', () => { }); }); + it('uses stale cached startup results immediately in explicit offline mode', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const cache = cachedEntry('/json/stations/search?hidebroken=true&limit=1&order=clickcount&reverse=true', [ + {stationuuid: 'offline-fm', name: 'Offline FM', url: 'https://stream.example/live.mp3'} + ], Date.now() - 60 * 60 * 1000); + const fetch = mockFetch(() => {throw new Error('Network must not be attempted.');}); + const provider = new RadioBrowserProvider(['https://primary.example', 'https://secondary.example'], cache); + expect(await provider.popular(1)).toMatchObject([{id: 'offline-fm', streamUrl: 'https://stream.example/live.mp3'}]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reports an offline cache miss without trying a mirror', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = mockFetch(() => jsonResponse([])); + const provider = new RadioBrowserProvider(['https://primary.example'], cacheForTest()); + await expect(provider.popular(1)).rejects.toThrow(/offline.*cache|cache.*offline/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('does not vote, locate, or resolve externally when offline and retains a supplied stream URL', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = mockFetch(() => {throw new Error('Network must not be attempted.');}); + const provider = new RadioBrowserProvider(['https://primary.example'], cacheForTest()); + const station = {...radioBrowserStation('saved'), streamUrl: 'https://stream.example/live.mp3'}; + expect(await provider.vote(station)).toBe(false); + expect(await provider.detectLocation()).toBeNull(); + expect(await provider.resolve(station)).toEqual({url: station.streamUrl, name: station.name}); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('avoids an uncached atlas in low-bandwidth mode while ordinary directory requests still work', async () => { + vi.stubEnv('RADIOCLI_LOW_BANDWIDTH', '1'); + const fetch = mockFetch(() => jsonResponse([{stationuuid: 'small-list', name: 'Small list'}])); + const provider = new RadioBrowserProvider(['https://primary.example'], cacheForTest()); + await expect(provider.nearby({latitude: 48.8, longitude: 2.3, source: 'test'})).rejects.toThrow(/low.bandwidth.*cache|cache.*low.bandwidth/i); + expect(fetch).not.toHaveBeenCalled(); + expect(await provider.popular(1)).toMatchObject([{id: 'small-list'}]); + expect(fetch).toHaveBeenCalledOnce(); + expect(await provider.search('small', {limit: 1})).toMatchObject([{id: 'small-list'}]); + }); + + it('uses a bounded stale atlas in low-bandwidth mode without refreshing it', async () => { + vi.stubEnv('RADIOCLI_LOW_BANDWIDTH', '1'); + const cache = cachedEntry('/json/stations/search?has_geo_info=true&hidebroken=true&limit=100000&order=name', [ + {stationuuid: 'cached-nearby', name: 'Cached nearby', geo_lat: 48.8, geo_long: 2.3} + ], Date.now() - 7 * 60 * 60 * 1000); + const fetch = mockFetch(() => jsonResponse([])); + const provider = new RadioBrowserProvider(['https://primary.example'], cache); + expect(await provider.nearby({latitude: 48.8, longitude: 2.3, source: 'test'}, 1)).toMatchObject([{id: 'cached-nearby'}]); + expect(fetch).not.toHaveBeenCalled(); + }); + + for (const host of ['127.0.0.1', '::1']) { + it(`uses native fetch to read a real ${host} directory endpoint`, async context => { + let requests = 0; + const server = createServer((_request, response) => {requests += 1;response.end(JSON.stringify([{name: 'Japan', iso_3166_1: 'jp', stationcount: 1}]));}); + try { + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, host, resolve);}); + } catch (error) { + if (host === '::1' && ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) context.skip('The host does not provide IPv6 loopback.'); + throw error; + } + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + const provider = new RadioBrowserProvider([`http://${host === '::1' ? '[::1]' : host}:${address.port}`], cacheForTest()); + expect(await provider.countries()).toEqual([{name: 'Japan', code: 'JP', stationCount: 1}]); + expect(requests).toBe(1); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + } + it('loads country stations with limit and offset pagination', async () => { const fetch = mockFetch(url => { expect(url.pathname).toBe('/json/stations/search'); @@ -372,6 +523,12 @@ function cacheForTest(): ProviderCache { return new ProviderCache(cacheFileForTest()); } +function cachedEntry(key: string, value: unknown, createdAt: number): ProviderCache { + const file = cacheFileForTest(); + writeFileSync(file, JSON.stringify({version: 1, entries: {[key]: {createdAt, value}}})); + return new ProviderCache(file); +} + function cacheFileForTest(): string { const root = mkdtempSync(join(tmpdir(), 'radiocli-provider-')); roots.push(root); diff --git a/src/providers/radio-browser.ts b/src/providers/radio-browser.ts index 0b1fe7a..7111313 100644 --- a/src/providers/radio-browser.ts +++ b/src/providers/radio-browser.ts @@ -1,8 +1,16 @@ +import type {SrvRecord} from 'node:dns'; +import {resolveSrv} from 'node:dns/promises'; import {z} from 'zod'; import type {Country, LocationGuess, ResolvedStream, SearchOptions, Station} from '../types.js'; import {ProviderCache} from './cache.js'; import {userAgent} from '../version.js'; import {safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText} from '../safety.js'; +import { + allowsDirectDnsFallback, + fetchHttpsWithSystemResolver, + networkPolicy, + withExternalResponse +} from '../platform/network.js'; const stationSchema = z.object({ stationuuid: z.string(), @@ -53,12 +61,23 @@ const locationSchema = z const geoAtlasLimit = 100_000; const geoAtlasMaxAgeMs = 6 * 60 * 60 * 1000; -// The atlas is much larger than ordinary directory responses. A single shared -// 20-second deadline used to give each of four mirrors roughly five seconds to -// connect, download, and parse up to 100k rows. Use a size-aware per-attempt -// budget while retaining an overall bound and stale-cache fallback. +// The atlas is much larger than ordinary directory responses. Give each live, +// discovered server enough time to connect, download, and parse up to 100k rows +// while retaining an overall bound and stale-cache fallback. const geoAtlasAttemptTimeoutMs = 30_000; const geoAtlasTotalTimeoutMs = 75_000; +const mirrorDiscoveryTimeoutMs = 750; +const radioBrowserService = '_api._tcp.radio-browser.info'; +const radioBrowserApiSuffix = '.api.radio-browser.info'; +const nameResolutionErrorCodes = new Set(['EAI_AGAIN', 'ENOTFOUND', 'ESERVFAIL', 'ETIMEOUT', 'EREFUSED']); + +type RadioBrowserRuntime = { + resolveSrv?: (hostname: string) => Promise; + fetchWithSystemResolver?: typeof fetchHttpsWithSystemResolver; + allowsDirectDnsFallback?: () => boolean; +}; + +const defaultRuntime: RadioBrowserRuntime = {}; class ProviderUnavailableError extends Error { constructor(message: string, cause?: Error) { @@ -75,12 +94,19 @@ export class RadioBrowserProvider { readonly id = 'radio-browser' as const; readonly label = 'Radio Browser'; private readonly baseUrls: string[]; + private readonly discoverMirrors: boolean; private activeBaseUrl: string; private geoAtlasPromise: Promise | null = null; private geoAtlasLoadedAt = 0; - - constructor(baseUrls = defaultRadioBrowserMirrors(), private readonly cache = new ProviderCache()) { - this.baseUrls = baseUrls.map(url => url.replace(/\/$/, '')); + private mirrorDiscoveryPromise: Promise | null = null; + + constructor( + baseUrls: string[] | undefined = undefined, + private readonly cache = new ProviderCache(), + private readonly runtime: RadioBrowserRuntime = defaultRuntime + ) { + this.discoverMirrors = baseUrls === undefined; + this.baseUrls = (baseUrls ?? defaultRadioBrowserMirrors()).map(url => url.replace(/\/$/, '')); this.activeBaseUrl = this.baseUrls[0] ?? 'https://all.api.radio-browser.info'; } @@ -260,35 +286,26 @@ export class RadioBrowserProvider { } async detectLocation(): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); try { - const response = await fetch('https://ipapi.co/json/', { - signal: controller.signal, - headers: {'User-Agent': userAgent()} + return await withExternalResponse('https://ipapi.co/json/', { + timeoutMs: 5000, + init: {headers: {'User-Agent': userAgent()}} + }, async response => { + if (!response.ok) return null; + const parsed = locationSchema.parse(await response.json()); + if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number') return null; + return { + city: parsed.city, + region: parsed.region, + country: parsed.country_name, + countryCode: parsed.country_code, + latitude: parsed.latitude, + longitude: parsed.longitude, + source: 'ipapi.co' + }; }); - if (!response.ok) { - return null; - } - - const parsed = locationSchema.parse(await response.json()); - if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number') { - return null; - } - - return { - city: parsed.city, - region: parsed.region, - country: parsed.country_name, - countryCode: parsed.country_code, - latitude: parsed.latitude, - longitude: parsed.longitude, - source: 'ipapi.co' - }; } catch { return null; - } finally { - clearTimeout(timeout); } } @@ -351,7 +368,8 @@ export class RadioBrowserProvider { { maxAgeMs: geoAtlasMaxAgeMs, timeoutMs: geoAtlasTotalTimeoutMs, - attemptTimeoutMs: geoAtlasAttemptTimeoutMs + attemptTimeoutMs: geoAtlasAttemptTimeoutMs, + cacheOnly: networkPolicy().lowBandwidth } ); @@ -361,7 +379,7 @@ export class RadioBrowserProvider { private async request( path: string, params: Record = {}, - options: {maxAgeMs?: number; timeoutMs?: number; attemptTimeoutMs?: number} = {} + options: {maxAgeMs?: number; timeoutMs?: number; attemptTimeoutMs?: number; cacheOnly?: boolean} = {} ): Promise { const cacheKey = buildCacheKey(path, params); if (options.maxAgeMs) { @@ -371,10 +389,20 @@ export class RadioBrowserProvider { } } + const policy = networkPolicy(); + if (policy.offline || options.cacheOnly) { + const stale = options.maxAgeMs ? this.cache.getStale(cacheKey) : null; + if (stale !== null) return stale; + throw new ProviderUnavailableError(policy.offline + ? `${this.label} is offline: no cached response is available.` + : `${this.label} is in low-bandwidth mode: no cached atlas is available. Disable RADIOCLI_LOW_BANDWIDTH to download it.`); + } + let lastError: Error | null = null; - const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl); const totalTimeoutMs = options.timeoutMs ?? 9000; const deadline = Date.now() + totalTimeoutMs; + const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl); + let discoveryAttempted = !this.discoverMirrors; for (let mirrorIndex = 0; mirrorIndex < mirrors.length; mirrorIndex += 1) { const baseUrl = mirrors[mirrorIndex]!; const remainingMs = deadline - Date.now(); @@ -387,13 +415,13 @@ export class RadioBrowserProvider { } try { - const mirrorsLeft = mirrors.length - mirrorIndex; + const mirrorsLeft = mirrors.length - mirrorIndex + (discoveryAttempted ? 0 : 1); const fairShareTimeoutMs = Math.max(1, Math.floor(remainingMs / mirrorsLeft)); const attemptTimeoutMs = Math.max( 1, Math.min(remainingMs, options.attemptTimeoutMs ?? fairShareTimeoutMs) ); - const value = await fetchJsonWithTimeout(url, attemptTimeoutMs); + const value = await fetchJsonWithTimeout(url, attemptTimeoutMs, this.runtime); this.activeBaseUrl = baseUrl; if (options.maxAgeMs) { try { @@ -407,6 +435,15 @@ export class RadioBrowserProvider { } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } + + if (!discoveryAttempted && mirrorIndex === mirrors.length - 1) { + discoveryAttempted = true; + const discoveryBudgetMs = Math.min(mirrorDiscoveryTimeoutMs, Math.max(1, deadline - Date.now())); + const discovered = await this.loadDiscoveredMirrors(discoveryBudgetMs); + for (const baseUrl of discovered) { + if (!mirrors.includes(baseUrl)) mirrors.push(baseUrl); + } + } } const stale = this.cache.getStale(cacheKey); @@ -419,10 +456,18 @@ export class RadioBrowserProvider { lastError ?? undefined ); } + + private async loadDiscoveredMirrors(timeoutMs: number): Promise { + this.mirrorDiscoveryPromise ??= settleWithin(discoverRadioBrowserMirrors(this.runtime), timeoutMs, []); + return this.mirrorDiscoveryPromise; + } } function describeRequestError(error: Error | null): string { if (!error) return 'request failed'; + const nestedCode = findErrorCode(error); + if (nestedCode === 'EAI_AGAIN') return 'temporary DNS lookup failed (EAI_AGAIN)'; + if (nestedCode && nameResolutionErrorCodes.has(nestedCode)) return `DNS lookup failed (${nestedCode})`; const cause = error.cause; if (cause && typeof cause === 'object') { const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : null; @@ -436,12 +481,7 @@ function describeRequestError(error: Error | null): string { } function defaultRadioBrowserMirrors(): string[] { - return [ - 'https://all.api.radio-browser.info', - 'https://de1.api.radio-browser.info', - 'https://nl1.api.radio-browser.info', - 'https://at1.api.radio-browser.info' - ]; + return ['https://all.api.radio-browser.info']; } function preferActiveMirror(baseUrls: string[], active: string): string[] { @@ -453,26 +493,96 @@ function buildCacheKey(path: string, params: Record): string { return `${path}?${new URLSearchParams(pairs).toString()}`; } -async function fetchJsonWithTimeout(url: URL, timeoutMs: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { - signal: controller.signal, - headers: { - 'User-Agent': userAgent(' (+https://radio-browser.info)'), - Accept: 'application/json' - } - }); +async function fetchJsonWithTimeout(url: URL, timeoutMs: number, runtime: RadioBrowserRuntime): Promise { + const startedAt = Date.now(); + const init = { + headers: { + 'User-Agent': userAgent(' (+https://radio-browser.info)'), + Accept: 'application/json' + } + } satisfies RequestInit; + const consume = async (response: Response): Promise => { if (!response.ok) { throw new Error(`Radio Browser request failed: ${response.status} ${response.statusText}`); } - - // Keep the abort timer alive while consuming the body as well as headers. return (await response.json()) as T; - } finally { - clearTimeout(timeout); + }; + + try { + return await withExternalResponse(url, {timeoutMs, init}, consume); + } catch (error) { + const remainingMs = timeoutMs - (Date.now() - startedAt); + if ( + remainingMs <= 0 || + !isNameResolutionError(error) || + !isRadioBrowserApiHostname(url.hostname) || + !(runtime.allowsDirectDnsFallback ?? allowsDirectDnsFallback)() + ) { + throw error; + } + + return withExternalResponse(url, { + timeoutMs: remainingMs, + init, + fetchImpl: runtime.fetchWithSystemResolver ?? fetchHttpsWithSystemResolver + }, consume); + } +} + +async function discoverRadioBrowserMirrors(runtime: RadioBrowserRuntime): Promise { + const records = await (runtime.resolveSrv ?? resolveSrv)(radioBrowserService); + return dedupeUrls( + records + .filter(record => record.port === 443) + .sort((a, b) => a.priority - b.priority || b.weight - a.weight || a.name.localeCompare(b.name)) + .flatMap(record => { + const hostname = record.name.replace(/\.$/, '').toLowerCase(); + return isRadioBrowserApiHostname(hostname) ? [`https://${hostname}`] : []; + }) + ); +} + +function isRadioBrowserApiHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return normalized === 'api.radio-browser.info' || normalized.endsWith(radioBrowserApiSuffix); +} + +function isNameResolutionError(error: unknown): boolean { + const code = findErrorCode(error); + return code !== null && nameResolutionErrorCodes.has(code); +} + +function findErrorCode(error: unknown): string | null { + const seen = new Set(); + const pending = [error]; + while (pending.length > 0) { + const value = pending.pop(); + if (!value || seen.has(value)) continue; + seen.add(value); + if (typeof value === 'object') { + if ('code' in value && typeof value.code === 'string') return value.code; + if ('cause' in value) pending.push(value.cause); + if (value instanceof AggregateError) pending.push(...value.errors); + } } + return null; +} + +function dedupeUrls(urls: string[]): string[] { + return [...new Set(urls)]; +} + +function settleWithin(promise: Promise, timeoutMs: number, fallback: T): Promise { + return new Promise(resolve => { + const timeout = setTimeout(() => resolve(fallback), timeoutMs); + promise.then(value => { + clearTimeout(timeout); + resolve(value); + }, () => { + clearTimeout(timeout); + resolve(fallback); + }); + }); } export function dedupeStations(stations: Station[]): Station[] { diff --git a/src/providers/radio-garden.test.ts b/src/providers/radio-garden.test.ts new file mode 100644 index 0000000..6e8612d --- /dev/null +++ b/src/providers/radio-garden.test.ts @@ -0,0 +1,58 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {RadioGardenProvider} from './radio-garden.js'; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('RadioGardenProvider networking', () => { + it('does not search or probe provider health when offline', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = vi.fn(async () => new Response('{"hits":[]}')); + vi.stubGlobal('fetch', fetch); + const provider = new RadioGardenProvider(); + await expect(provider.search('jazz')).rejects.toThrow(/offline/i); + expect(await provider.health()).toMatch(/offline/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('preserves supplied stream URLs when offline', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = vi.fn(async () => new Response('{}')); + vi.stubGlobal('fetch', fetch); + const provider = new RadioGardenProvider(); + const station = {id: 'saved', provider: 'radio-garden' as const, name: 'Saved station', tags: [], streamUrl: 'https://stream.example/live.mp3'}; + expect(await provider.resolve(station)).toEqual({url: station.streamUrl, name: station.name}); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('aborts a search whose response body stalls after headers', async () => { + vi.useFakeTimers(); + let body!: ReadableStreamDefaultController; + vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({start(controller) {body = controller;}})))); + let error: Error | undefined; + const searching = new RadioGardenProvider().search('jazz').catch(value => {error = value as Error;}); + try { + await vi.advanceTimersByTimeAsync(9001); + expect(error?.message).toMatch(/timed out/i); + } finally { + body.error(new Error('test cleanup')); + await searching; + } + }); + + it('cancels an unused edge-protection response body', async () => { + const cancel = vi.fn(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({cancel}), {status: 403}))); + expect(await new RadioGardenProvider().health()).toBe('blocked by edge protection'); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('retains ordinary search behavior in low-bandwidth mode', async () => { + vi.stubEnv('RADIOCLI_LOW_BANDWIDTH', '1'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({hits: [{_source: {type: 'channel', title: 'Jazz FM', url: '/listen/jazz/one', subtitle: 'Japan'}}]})))); + expect(await new RadioGardenProvider().search('jazz')).toMatchObject([{id: 'one', name: 'Jazz FM'}]); + }); +}); diff --git a/src/providers/radio-garden.ts b/src/providers/radio-garden.ts index 1949404..9bb817b 100644 --- a/src/providers/radio-garden.ts +++ b/src/providers/radio-garden.ts @@ -1,5 +1,6 @@ import {z} from 'zod'; import type {ResolvedStream, SearchOptions, Station} from '../types.js'; +import {withExternalResponse} from '../platform/network.js'; const searchSchema = z .object({ @@ -34,14 +35,13 @@ export class RadioGardenProvider { async health(): Promise { try { - const response = await fetchWithTimeout(`${this.baseUrl}/geo`, 5000, { - headers: {'User-Agent': 'radiocli/0.1'} + return await withExternalResponse(`${this.baseUrl}/geo`, { + timeoutMs: 5000, + init: {headers: {'User-Agent': 'radiocli/0.1'}} + }, response => { + if (response.status === 403) return 'blocked by edge protection'; + return response.ok ? 'online' : `${response.status} ${response.statusText}`; }); - if (response.status === 403) { - return 'blocked by edge protection'; - } - - return response.ok ? 'online' : `${response.status} ${response.statusText}`; } catch (error) { return error instanceof Error ? error.message : 'unavailable'; } @@ -51,15 +51,13 @@ export class RadioGardenProvider { const url = new URL(`${this.baseUrl}/search`); url.searchParams.set('q', query); - const response = await fetchWithTimeout(url, 9000, { - headers: {'User-Agent': 'radiocli/0.1', Accept: 'application/json'} + const parsed = await withExternalResponse(url, { + timeoutMs: 9000, + init: {headers: {'User-Agent': 'radiocli/0.1', Accept: 'application/json'}} + }, async response => { + if (!response.ok) throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`); + return searchSchema.parse(await response.json()); }); - - if (!response.ok) { - throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`); - } - - const parsed = searchSchema.parse(await response.json()); return (parsed.hits ?? []) .map(hit => hit._source) .filter((source): source is NonNullable => source?.type === 'channel' && Boolean(source.url)) @@ -88,13 +86,3 @@ export class RadioGardenProvider { }; } } - -async function fetchWithTimeout(url: string | URL, timeoutMs: number, init: RequestInit = {}): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, {...init, signal: controller.signal}); - } finally { - clearTimeout(timeout); - } -} diff --git a/src/setup.test.ts b/src/setup.test.ts index 3af6a4d..b9dc72a 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -1,10 +1,85 @@ import {PassThrough} from 'node:stream'; import {describe, expect, it, vi} from 'vitest'; -import {createSetupPlan, detectPackageManager, parseSetupArgs, runSetup, type SetupComponent} from './setup.js'; +import {createSetupPlan, parseSetupArgs, runSetup, type SetupComponent} from './setup.js'; +import {detectPackageManager, ffplayInstallCommand, mpvInstallCommand, type SetupPackageManager} from './platform/packages.js'; const nothingInstalled: Record = {mpv: false, ffmpeg: false, vlc: false}; describe('RadioCLI setup', () => { + it.each([ + ['darwin', '', 'brew', 'brew', {}], + ['win32', '', 'winget', 'winget', {}], + ['win32', '', 'scoop', 'scoop', {}], + ['win32', '', 'choco', 'choco', {}], + ['linux', 'ID=ubuntu\nID_LIKE=debian', 'apt', 'apt', {}], + ['linux', 'ID=fedora', 'dnf', 'dnf', {}], + ['linux', 'ID=arch', 'pacman', 'pacman', {}], + ['linux', 'ID=alpine', 'apk', 'apk', {}], + ['linux', 'ID=opensuse', 'zypper', 'zypper', {}], + ['linux', 'ID=unknown', 'dnf', 'dnf', {}], + ['freebsd', '', 'pkg', 'pkg', {}], + ['openbsd', '', 'pkg_add', 'pkg_add', {}], + ['netbsd', '', 'pkgin', 'pkgin', {}], + ['sunos', '', 'pkgin', 'pkgin', {}], + ['haiku', '', 'pkgman', 'pkgman', {}], + ['android', '', 'termux-pkg', 'pkg', {TERMUX_VERSION: '0.118.3'}], + ['linux', 'ID=debian', 'termux-pkg', 'pkg', {TERMUX_VERSION: '0.118.3'}] + ] satisfies [NodeJS.Platform, string, SetupPackageManager, string, NodeJS.ProcessEnv][])('uses the selected %s/%s/%s setup recipes in playback hints', (platform, osRelease, manager, executable, env) => { + const hasCommand = (command: string): boolean => [executable, 'doas'].includes(command); + expect(detectPackageManager(platform, osRelease, hasCommand, env)).toBe(manager); + const plan = createSetupPlan({platform, osRelease, env, packageManager: manager, installed: nothingInstalled, + selected: ['mpv', 'ffmpeg'], elevation: 'doas'}); + const options = {hasCommand, getUid: () => 1000}; + const hints = {mpv: mpvInstallCommand(platform, osRelease, env, options), ffmpeg: ffplayInstallCommand(platform, osRelease, env, options)}; + for (const command of plan.commands) { + if (command.component === 'mpv' || command.component === 'ffmpeg') expect(hints[command.component]).toContain(command.display); + } + if (manager === 'termux-pkg') expect(hints.ffmpeg).toContain('x11-repo'); + if (manager === 'pkgin') expect(hints.ffmpeg).toContain('matching pkgsrc'); + if (manager === 'pkg') expect(hints.ffmpeg).toContain('SDL'); + if (manager === 'scoop') expect(hints.mpv).toContain('scoop bucket add extras'); + }); + + it('shows every Scoop prerequisite in the dry-run plan', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + await runSetup({platform: 'win32', env: {}, args: ['--dry-run', '--only=mpv'], input: new PassThrough(), output, + hasCommand: command => command === 'scoop'}); + expect(text).toContain('scoop bucket add extras'); + expect(text).toContain('scoop install mpv'); + expect(text).not.toContain('winget'); + }); + + it('prints an ASCII-safe setup plan in a dumb terminal without control sequences', async () => { + vi.stubEnv('TERM', 'dumb'); + const output = Object.assign(new PassThrough(), {isTTY: true}); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + try { + await runSetup({platform: 'linux', osRelease: 'ID=debian', args: ['--dry-run', '--only=mpv'], + input: new PassThrough(), output, hasCommand: command => command === 'apt'}); + expect(text).toContain('SETUP RECEIVER'); + expect(text).not.toMatch(/[^\x00-\x7f]/); + expect(text).not.toContain('\u001b'); + } finally {vi.unstubAllEnvs();} + }); + + it('caps setup colors at the terminal depth', async () => { + vi.stubEnv('TERM', 'xterm'); + vi.stubEnv('FORCE_COLOR', undefined); + vi.stubEnv('NO_COLOR', undefined); + const output = Object.assign(new PassThrough(), {isTTY: true, getColorDepth: () => 4}); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + try { + await runSetup({platform: 'linux', osRelease: 'ID=debian', args: ['--dry-run', '--only=mpv'], + input: new PassThrough(), output, hasCommand: command => command === 'apt'}); + expect(text).toContain('\u001b[32m'); + expect(text).not.toContain('38;2;'); + } finally {vi.unstubAllEnvs();} + }); + it('detects native package managers by platform and Linux family', () => { const available = new Set(['brew', 'winget', 'scoop', 'apt', 'dnf', 'pacman']); const hasCommand = (command: string): boolean => available.has(command); @@ -122,6 +197,172 @@ describe('RadioCLI setup', () => { expect(text).toContain('Agent integration: would be enabled and installed for detected MCP clients.'); }); + it.each([ + {platform: 'freebsd' as const, manager: 'pkg', uid: 0, commands: ['pkg'], expected: 'pkg install -y mpv'}, + {platform: 'openbsd' as const, manager: 'pkg_add', uid: 1000, commands: ['pkg_add', 'doas'], expected: 'doas pkg_add -I mpv'}, + {platform: 'linux' as const, manager: 'apk', uid: 0, commands: ['apk'], expected: 'apk add mpv'} + ])('executes the $manager privilege plan', async ({platform, manager, uid, commands, expected}) => { + const execute = vi.fn(async (_command: {display: string}) => undefined); + await runSetup({platform, osRelease: 'ID=alpine', args: ['--yes', '--only=mpv', `--package-manager=${manager}`], + input: new PassThrough(), output: new PassThrough(), hasCommand: command => commands.includes(command), + getUid: () => uid, runCommand: execute}); + expect(execute).toHaveBeenCalledOnce(); + expect(execute.mock.calls[0]?.[0]).toMatchObject({display: expected}); + }); + + it('rejects installation when a privileged package tool has no usable privilege helper', async () => { + const execute = vi.fn(async () => undefined); + await expect(runSetup({platform: 'freebsd', args: ['--yes', '--only=mpv'], getUid: () => 1000, + input: new PassThrough(), output: new PassThrough(), hasCommand: command => command === 'pkg', runCommand: execute + })).rejects.toThrow('requires root, sudo, or doas'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('labels the root requirement when previewing commands without an available privilege helper', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + await runSetup({platform: 'freebsd', env: {}, args: ['--dry-run', '--only=mpv'], getUid: () => 1000, + input: new PassThrough(), output, hasCommand: command => command === 'pkg'}); + expect(text).toContain('pkg install -y mpv'); + expect(text).toContain('Run package commands as root; sudo/doas unavailable.'); + expect(text).not.toContain('sudo pkg'); + }); + + it('shows a manual pkgsrc FFmpeg plan without invoking a guessed package', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => { text += String(chunk); }); + const execute = vi.fn(async () => undefined); + await runSetup({platform: 'netbsd', args: ['--dry-run', '--only=ffmpeg'], getUid: () => 0, + input: new PassThrough(), output, hasCommand: command => command === 'pkgin', runCommand: execute}); + expect(text).toContain('manual installation required'); + expect(text).toContain('RADIOCLI_FFPLAY_PATH'); + expect(execute).not.toHaveBeenCalled(); + await expect(runSetup({platform: 'netbsd', args: ['--yes', '--only=ffmpeg'], getUid: () => 0, + input: new PassThrough(), output, hasCommand: command => command === 'pkgin', runCommand: execute + })).rejects.toThrow('No verified automatic installation command'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg', label: 'Android / Termux', expected: 'pkg install -y mpv'}, + {platform: 'linux' as const, env: {PREFIX: '/data/data/com.termux/files/usr'}, manager: 'pkg', label: 'Android / Termux', expected: 'pkg install -y mpv'}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', label: 'Haiku', expected: 'pkgman install -y mpv'}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', label: 'Solaris / illumos', expected: 'pkgin -y install mpv'} + ])('previews the verified $platform installation without running it', async ({platform, env, manager, label, expected}) => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => undefined); + await runSetup({platform, env, args: ['--dry-run', '--only=mpv'], input: new PassThrough(), output, + hasCommand: command => command === manager, getUid: () => 0, runCommand: execute}); + expect(text).toContain(label); + expect(text).toContain(expected); + expect(text).toContain('Dry run complete'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'termux-pkg', executable: 'pkg', uid: 10123, expected: 'pkg install -y mpv'}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', executable: 'pkgman', uid: 1000, expected: 'pkgman install -y mpv'}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', executable: 'pkgin', uid: 0, expected: 'pkgin -y install mpv'} + ])('runs the explicitly approved $manager command with its native privilege model', async ({platform, env, manager, executable, uid, expected}) => { + const execute = vi.fn(async (_command: {display: string}) => undefined); + await runSetup({platform, env, args: ['--yes', '--only=mpv', `--package-manager=${manager}`], input: new PassThrough(), output: new PassThrough(), + hasCommand: command => command === executable, getUid: () => uid, runCommand: execute}); + expect(execute).toHaveBeenCalledOnce(); + expect(execute.mock.calls[0]?.[0]).toMatchObject({display: expected}); + }); + + it('requires approval before running Termux pkg and rejects root execution', async () => { + const execute = vi.fn(async () => undefined); + const options = {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, input: new PassThrough(), output: new PassThrough(), + hasCommand: (command: string) => command === 'pkg', runCommand: execute}; + await expect(runSetup({...options, args: ['--only=mpv'], getUid: () => 10123})).rejects.toThrow('Use --yes'); + await expect(runSetup({...options, args: ['--yes', '--only=mpv'], getUid: () => 0})).rejects.toThrow('normal Termux app user'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('checks the native pkg executable for an explicit Termux manager selection', async () => { + const execute = vi.fn(async () => undefined); + await expect(runSetup({platform: 'android', env: {TERMUX_VERSION: '0.118.3'}, args: ['--yes', '--only=mpv', '--package-manager=termux-pkg'], + input: new PassThrough(), output: new PassThrough(), hasCommand: () => false, getUid: () => 10123, runCommand: execute + })).rejects.toThrow('Requested package manager is not available: termux-pkg (pkg)'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg'}, + {platform: 'freebsd' as const, env: {}, manager: 'termux-pkg'}, + {platform: 'sunos' as const, env: {}, manager: 'pkg'}, + {platform: 'aix' as const, env: {}, manager: 'dnf'} + ])('rejects the wrong native recipe for $manager on $platform before even previewing an install', async ({platform, env, manager}) => { + const execute = vi.fn(async () => undefined); + await expect(runSetup({platform, env, args: ['--dry-run', '--only=mpv', `--package-manager=${manager}`], + input: new PassThrough(), output: new PassThrough(), hasCommand: () => true, getUid: () => 0, runCommand: execute + })).rejects.toThrow('No verified'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg', only: 'ffmpeg', note: 'x11-repo'}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', only: 'vlc', note: 'Node 20'}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', only: 'ffmpeg', note: 'RADIOCLI_FFPLAY_PATH'} + ])('keeps unverified $platform components manual', async ({platform, env, manager, only, note}) => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => undefined); + const options = {platform, env, input: new PassThrough(), output, hasCommand: (command: string) => command === manager, getUid: () => 1000, runCommand: execute}; + await runSetup({...options, args: ['--dry-run', `--only=${only}`]}); + expect(text).toContain('manual installation required'); + expect(text).toContain(note); + await expect(runSetup({...options, args: ['--yes', `--only=${only}`]})).rejects.toThrow('No verified automatic installation command'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('shows the Haiku tools package and current Node catalog blocker together', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + await runSetup({platform: 'haiku', env: {}, args: ['--dry-run', '--only=ffmpeg'], input: new PassThrough(), output, + hasCommand: command => command === 'pkgman'}); + expect(text).toContain('pkgman install -y ffmpeg8_tools'); + expect(text).toMatch(/Node 20.*Node 22/); + expect(text).not.toContain('sudo pkgman'); + }); + + it('prints actionable manual AIX guidance even with a DNF command on PATH', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => undefined); + const options = {platform: 'aix' as const, env: {}, input: new PassThrough(), output, + hasCommand: (command: string) => command === 'dnf', runCommand: execute}; + await runSetup({...options, args: ['--dry-run', '--only=mpv']}); + expect(text).toContain('RADIOCLI_MPV_PATH'); + expect(text).not.toContain('dnf install'); + await expect(runSetup({...options, args: ['--yes', '--only=mpv']})).rejects.toThrow('No supported system package manager'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg', uid: 10123}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', uid: 1000}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', uid: 0} + ])('reports a native $manager failure without claiming setup completed', async ({platform, env, manager, uid}) => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => {throw new Error(`${manager}: package catalog unavailable`);}); + await expect(runSetup({platform, env, args: ['--yes', '--only=mpv'], input: new PassThrough(), output, + hasCommand: command => command === manager, getUid: () => uid, runCommand: execute + })).rejects.toThrow('package catalog unavailable'); + expect(execute).toHaveBeenCalledOnce(); + expect(text).not.toContain('Setup complete'); + }); + it('runs selected installers sequentially and prints final verification', async () => { const output = new PassThrough(); let text = ''; diff --git a/src/setup.ts b/src/setup.ts index 51d0916..f86574d 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,23 +1,16 @@ +import {componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageInstallPrerequisites, packageManagerElevation, packageManagerNeedsRoot, packageManagerNotes, packageManagerProgram, packageManagers, platformLabel, type SetupCommand, type SetupComponent, type SetupPackageManager} from './platform/packages.js'; +export type {SetupComponent}; import {spawn, type SpawnOptions} from 'node:child_process'; -import {existsSync, readFileSync, realpathSync} from 'node:fs'; +import {realpathSync} from 'node:fs'; import {createInterface} from 'node:readline/promises'; import type {Readable, Writable} from 'node:stream'; -import {clearCommandCache, commandExists} from './player/command.js'; +import {clearCommandCache, commandExists, resolveCommand} from './platform/executables.js'; import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backend-install.js'; import {configureMcpIntegrations} from './agent/mcp-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; - -export type SetupComponent = 'mpv' | 'ffmpeg' | 'vlc'; -export type SetupPackageManager = 'brew' | 'winget' | 'scoop' | 'choco' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper'; - -type SetupCommand = { - component: SetupComponent | null; - label: string; - program: string; - args: string[]; - display: string; -}; +import {resolveTerminalCapabilities} from './platform/terminal.js'; +import {identifyPlatform, readLinuxOsRelease, type PlatformProfile} from './platform/runtime.js'; export type SetupPlan = { platform: NodeJS.Platform; @@ -31,11 +24,13 @@ export type SetupPlan = { type SetupOptions = { platform?: NodeJS.Platform; osRelease?: string; + env?: NodeJS.ProcessEnv; args?: string[]; input?: Readable; output?: Writable; hasCommand?: (command: string) => boolean; runCommand?: (command: SetupCommand, output: Writable) => Promise; + getUid?: () => number | undefined; }; type ParsedSetupArgs = { @@ -49,20 +44,22 @@ type ParsedSetupArgs = { }; const components: SetupComponent[] = ['mpv', 'ffmpeg', 'vlc']; -const packageManagers: SetupPackageManager[] = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper']; export async function runSetup(options: SetupOptions = {}): Promise { const platform = options.platform ?? process.platform; const osRelease = options.osRelease ?? readLinuxOsRelease(platform); + const env = options.env ?? process.env; + const host = identifyPlatform({platform, osRelease, env}); const input = options.input ?? process.stdin; const output = options.output ?? process.stdout; const hasCommand = options.hasCommand ?? commandExists; const parsed = parseSetupArgs(options.args ?? []); + if (parsed.packageManager) validateNativePackageManager(parsed.packageManager, host); const installed = detectInstalledComponents(hasCommand); - const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand); + const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand, env); writeHeader(output); - output.write(`System ${platformLabel(platform, osRelease)} · Node ${process.version}\n`); + output.write(`System ${platformLabel(platform, osRelease, env)} ${separator(output)} Node ${process.version}\n`); output.write(`Manager ${packageManager ?? 'not detected'}\n\n`); let selected = parsed.only ?? defaultComponents(platform, parsed.all); @@ -81,8 +78,14 @@ export async function runSetup(options: SetupOptions = {}): Promise { agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true); } - const plan = createSetupPlan({platform, osRelease, packageManager, installed, selected}); + const isRoot = (options.getUid ?? process.getuid)?.() === 0; + const elevation = packageManagerElevation(hasCommand, isRoot); + const plan = createSetupPlan({platform, osRelease, env, packageManager, installed, selected, elevation}); printPlan(plan, output); + if (packageManager && packageManagerNeedsRoot(packageManager) && !isRoot && !elevation) { + output.write(' Note: Run package commands as root; sudo/doas unavailable.\n'); + } + for (const note of packageManagerNotes(packageManager, host)) output.write(` Note: ${note}\n`); const missing = plan.selected.filter(component => !plan.installed[component]); @@ -108,8 +111,16 @@ export async function runSetup(options: SetupOptions = {}): Promise { return; } - if (parsed.packageManager && !hasCommand(parsed.packageManager)) { - throw new Error(`Requested package manager is not available: ${parsed.packageManager}.`); + const manual = missing.filter(component => !plan.commands.some(command => command.component === component)); + if (manual.length) throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`); + if (plan.packageManager === 'termux-pkg' && isRoot) throw new Error('Run setup as the normal Termux app user. Termux pkg refuses root execution.'); + if (packageManagerNeedsRoot(plan.packageManager) && !isRoot && !elevation) { + throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.'); + } + + if (parsed.packageManager && !hasCommand(packageManagerProgram(parsed.packageManager))) { + const program = packageManagerProgram(parsed.packageManager); + throw new Error(`Requested package manager is not available: ${parsed.packageManager}${program !== parsed.packageManager ? ` (${program})` : ''}.`); } if (!parsed.yes && isInteractive(input, output)) { @@ -124,10 +135,10 @@ export async function runSetup(options: SetupOptions = {}): Promise { if (plan.commands.some(command => command.program === 'sudo')) { output.write('\nRadioCLI needs administrator approval for the system package manager.\n'); - await runVisibleCommand('sudo', ['-v']); + await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']); } - const execute = options.runCommand ?? runInstallCommand; + const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform)); output.write('\nInstalling\n'); for (const command of plan.commands) { await execute(command, output); @@ -142,32 +153,29 @@ export async function runSetup(options: SetupOptions = {}): Promise { export function createSetupPlan({ platform, osRelease = '', + env = process.env, packageManager, installed, - selected + selected, + elevation = 'sudo' }: { platform: NodeJS.Platform; osRelease?: string; + env?: NodeJS.ProcessEnv; packageManager: SetupPackageManager | null; installed: Record; selected: SetupComponent[]; + elevation?: 'sudo' | 'doas' | null; }): SetupPlan { + if (packageManager) validateNativePackageManager(packageManager, identifyPlatform({platform, osRelease, env})); const uniqueSelected = components.filter(component => selected.includes(component)); const missing = uniqueSelected.filter(component => !installed[component]); - const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component)) : []; - if (packageManager === 'scoop' && missing.some(component => component === 'mpv' || component === 'vlc')) { - commands.unshift({ - component: null, - label: 'Scoop extras bucket', - program: 'scoop', - args: ['bucket', 'add', 'extras'], - display: 'scoop bucket add extras' - }); - } + const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, {elevation})).filter((command): command is SetupCommand => command !== null) : []; + if (packageManager) commands.unshift(...packageInstallPrerequisites(packageManager, missing)); return { platform, - platformLabel: platformLabel(platform, osRelease), + platformLabel: platformLabel(platform, osRelease, env), packageManager, installed, selected: uniqueSelected, @@ -175,28 +183,15 @@ export function createSetupPlan({ }; } -export function detectPackageManager( - platform: NodeJS.Platform, - osRelease: string, - hasCommand: (command: string) => boolean = commandExists -): SetupPackageManager | null { - if (platform === 'darwin') return hasCommand('brew') ? 'brew' : null; - if (platform === 'win32') return firstAvailable(['winget', 'scoop', 'choco'], hasCommand); - if (platform !== 'linux') return null; - - const ids = linuxReleaseIds(osRelease); - const preferred: SetupPackageManager[] = hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop']) - ? ['apt'] - : hasAny(ids, ['fedora', 'rhel', 'centos']) - ? ['dnf'] - : hasAny(ids, ['arch', 'manjaro']) - ? ['pacman'] - : hasAny(ids, ['alpine']) - ? ['apk'] - : hasAny(ids, ['opensuse', 'suse']) - ? ['zypper'] - : ['apt', 'dnf', 'pacman', 'apk', 'zypper']; - return firstAvailable(preferred, hasCommand); +function validateNativePackageManager(manager: SetupPackageManager, host: PlatformProfile): void { + const required = host.id === 'termux' ? 'termux-pkg' : host.id === 'haiku' ? 'pkgman' : host.id === 'sunos' ? 'pkgin' : null; + if ((required && manager !== required) + || (manager === 'termux-pkg' && host.id !== 'termux') + || (manager === 'pkgman' && host.id !== 'haiku') + || (manager === 'pkg' && host.id !== 'freebsd') + || ['aix', 'android', 'unknown'].includes(host.id)) { + throw new Error(`No verified ${manager} playback package recipe for ${host.id}.${required ? ` Use --package-manager=${required}.` : ' Install a native player manually and run radiocli doctor.'}`); + } } export function parseSetupArgs(args: string[]): ParsedSetupArgs { @@ -279,46 +274,6 @@ function parsePackageManager(value: string | undefined): SetupPackageManager { return value as SetupPackageManager; } -function packageInstallCommand(manager: SetupPackageManager, component: SetupComponent): SetupCommand { - const packages: Record> = { - brew: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - winget: {mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC'}, - scoop: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - choco: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - apt: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - dnf: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - pacman: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - apk: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - zypper: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'} - }; - const packageName = packages[manager][component]; - let program: string = manager; - let args: string[]; - - if (manager === 'brew') args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])]; - else if (manager === 'winget') args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements']; - else if (manager === 'scoop') args = ['install', packageName]; - else if (manager === 'choco') args = ['install', packageName, '-y']; - else if (manager === 'apt') { - program = 'sudo'; - args = ['apt-get', 'install', '-y', packageName]; - } else if (manager === 'dnf') { - program = 'sudo'; - args = ['dnf', 'install', '-y', packageName]; - } else if (manager === 'pacman') { - program = 'sudo'; - args = ['pacman', '-S', '--needed', '--noconfirm', packageName]; - } else if (manager === 'apk') { - program = 'sudo'; - args = ['apk', 'add', packageName]; - } else { - program = 'sudo'; - args = ['zypper', '--non-interactive', 'install', packageName]; - } - - return {component, label: componentLabel(component), program, args, display: [program, ...args].join(' ')}; -} - function defaultComponents(platform: NodeJS.Platform, all: boolean): SetupComponent[] { if (all) return [...components]; return platform === 'darwin' ? ['mpv', 'ffmpeg'] : ['mpv']; @@ -345,9 +300,9 @@ async function promptForComponents({ }): Promise { output.write('Choose components (installed items will be skipped):\n'); const selected: SetupComponent[] = []; - if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ' · installed' : ''}`, true)) selected.push('mpv'); - if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ' · installed' : ''}`, platform === 'darwin')) selected.push('ffmpeg'); - if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ' · installed' : ''}`, false)) selected.push('vlc'); + if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true)) selected.push('mpv'); + if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin')) selected.push('ffmpeg'); + if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false)) selected.push('vlc'); return selected; } @@ -364,18 +319,22 @@ async function promptYesNo(input: Readable, output: Writable, question: string, function printPlan(plan: SetupPlan, output: Writable): void { output.write('\nInstallation plan\n'); + for (const command of plan.commands.filter(command => command.component === null)) { + output.write(` ${pendingMark(output)} ${command.label} ${separator(output)} ${command.display}\n`); + } for (const component of plan.selected) { if (plan.installed[component]) output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`); else { const command = plan.commands.find(candidate => candidate.component === component); - output.write(` ${pendingMark(output)} ${componentLabel(component)}${command ? ` · ${command.display}` : ' · manual installation required'}\n`); + output.write(` ${pendingMark(output)} ${componentLabel(component)} ${separator(output)} ${command ? command.display : 'manual installation required'}\n`); } } if (plan.selected.length === 0) output.write(' No components selected\n'); } -async function runInstallCommand(command: SetupCommand, output: Writable): Promise { - const interactive = Boolean((output as NodeJS.WriteStream).isTTY); +async function runInstallCommand(command: SetupCommand, output: Writable, platform: NodeJS.Platform): Promise { + const terminal = setupTerminal(output); + const interactive = terminal.interactive && !terminal.reduceMotion; const startedAt = Date.now(); let timer: NodeJS.Timeout | undefined; let frame = 0; @@ -392,7 +351,8 @@ async function runInstallCommand(command: SetupCommand, output: Writable): Promi try { await new Promise((resolve, reject) => { - const child = spawn(command.program, command.args, {stdio: ['inherit', 'pipe', 'pipe']} satisfies SpawnOptions); + const invocation = packageCommandInvocation(command, platform, resolveCommand); + const child = spawn(invocation.program, invocation.args, {shell: false, stdio: ['inherit', 'pipe', 'pipe']} satisfies SpawnOptions); child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); }); child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); }); child.once('error', reject); @@ -415,8 +375,9 @@ function progressFrame(label: string, frame: number, elapsedMs: number, output: const cycle = travel * 2; const offset = frame % cycle; const start = offset <= travel ? offset : cycle - offset; - const bar = Array.from({length: width}, (_, index) => index >= start && index < start + segment ? '█' : '░').join(''); - return ` ${accent(output, '◒')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`; + const unicode = setupTerminal(output).unicode; + const bar = Array.from({length: width}, (_, index) => index >= start && index < start + segment ? (unicode ? '█' : '#') : (unicode ? '░' : '.')).join(''); + return ` ${accent(output, unicode ? '◒' : '*')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`; } function printVerification(output: Writable): void { @@ -430,20 +391,7 @@ function printVerification(output: Writable): void { function writeHeader(output: Writable): void { output.write(`${accent(output, 'RADIOCLI')} SETUP RECEIVER\n`); - output.write(`${accent(output, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}\n`); -} - -function platformLabel(platform: NodeJS.Platform, osRelease: string): string { - if (platform === 'darwin') return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`; - if (platform === 'win32') return 'Windows'; - if (platform === 'linux') return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux'; - return platform; -} - -function componentLabel(component: SetupComponent): string { - if (component === 'ffmpeg') return 'FFmpeg / ffplay'; - if (component === 'vlc') return 'VLC fallback'; - return 'mpv'; + output.write(`${accent(output, setupTerminal(output).unicode ? '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' : '------------------------------------')}\n`); } function isInteractive(input: Readable, output: Writable): boolean { @@ -451,23 +399,33 @@ function isInteractive(input: Readable, output: Writable): boolean { } function accent(output: Writable, value: string): string { - return colorsEnabled(output) ? `\u001b[38;2;116;242;138m${value}\u001b[0m` : value; + const level = setupTerminal(output).colorLevel; + const color = level === 3 ? '38;2;116;242;138' : level === 2 ? '38;5;120' : '32'; + return level > 0 ? `\u001b[${color}m${value}\u001b[0m` : value; } function successMark(output: Writable): string { - return accent(output, '✓'); + return accent(output, setupTerminal(output).unicode ? '✓' : '+'); } function pendingMark(output: Writable): string { - return accent(output, '◆'); + return accent(output, setupTerminal(output).unicode ? '◆' : '*'); } function failureMark(output: Writable): string { - return colorsEnabled(output) ? '\u001b[38;2;255;95;135m✗\u001b[0m' : '✗'; + const terminal = setupTerminal(output); + const value = terminal.unicode ? '✗' : 'x'; + const color = terminal.colorLevel === 3 ? '38;2;255;95;135' : terminal.colorLevel === 2 ? '38;5;204' : '31'; + return terminal.colorLevel > 0 ? `\u001b[${color}m${value}\u001b[0m` : value; } -function colorsEnabled(output: Writable): boolean { - return Boolean((output as NodeJS.WriteStream).isTTY) && !process.env.NO_COLOR; +function setupTerminal(output: Writable) { + const stream = output as NodeJS.WriteStream; + return resolveTerminalCapabilities(process.env, {isTTY: Boolean(stream.isTTY), colorDepth: stream.getColorDepth?.()}); +} + +function separator(output: Writable): string { + return setupTerminal(output).unicode ? '·' : '-'; } function clearLine(): string { @@ -494,35 +452,3 @@ async function runVisibleCommand(program: string, args: string[]): Promise child.once('close', code => code === 0 ? resolve() : reject(new Error(`${program} ${args.join(' ')} exited with code ${code}.`))); }); } - -function firstAvailable(values: T[], hasCommand: (command: string) => boolean): T | null { - return values.find(hasCommand) ?? null; -} - -function readLinuxOsRelease(platform: NodeJS.Platform): string { - if (platform !== 'linux' || !existsSync('/etc/os-release')) return ''; - try { - return readFileSync('/etc/os-release', 'utf8'); - } catch { - return ''; - } -} - -function linuxReleaseIds(osRelease: string): Set { - const ids = new Set(); - for (const line of osRelease.split('\n')) { - const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); - if (!match) continue; - for (const value of match[2]!.replaceAll('"', '').split(/\s+/)) if (value.trim()) ids.add(value.trim().toLowerCase()); - } - return ids; -} - -function osReleaseValue(osRelease: string, key: string): string { - const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`)); - return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? ''; -} - -function hasAny(values: Set, candidates: string[]): boolean { - return candidates.some(candidate => values.has(candidate)); -} diff --git a/src/storage/store.test.ts b/src/storage/store.test.ts index 2b940c0..15aeb17 100644 --- a/src/storage/store.test.ts +++ b/src/storage/store.test.ts @@ -1,17 +1,25 @@ -import {existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import * as fs from 'node:fs'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; -import {afterEach, describe, expect, it} from 'vitest'; +import {afterEach, describe, expect, it, vi} from 'vitest'; import {JsonLibraryStore} from './store.js'; +import * as paths from '../platform/paths.js'; import {AlarmPowerGuardStore} from '../alarms/power-guard-store.js'; import {defaultReceiverStyle, receiverStyleNames, themeNames, type AlarmCreateInput, type Station} from '../types.js'; const roots: string[] = []; const originalRadioCliHome = process.env.RADIOCLI_HOME; const originalRadioAtlasHome = process.env.RADIO_ATLAS_HOME; +// Windows ACLs and a privileged POSIX user do not enforce these chmod fixtures. +const nativePermissions = process.platform !== 'win32' && process.getuid?.() !== 0; + +vi.mock('node:fs', async importOriginal => ({...await importOriginal()})); +vi.mock('../platform/paths.js', async importOriginal => ({...await importOriginal()})); describe('JsonLibraryStore', () => { afterEach(() => { + vi.restoreAllMocks(); for (const root of roots.splice(0)) { rmSync(root, {recursive: true, force: true}); } @@ -522,7 +530,236 @@ describe('JsonLibraryStore', () => { expect(store.snapshot().settings.theme).toBe('green'); expect(store.snapshot().settings.receiverStyle).toBe(defaultReceiverStyle); expect(store.snapshot().activity.sessions).toEqual([]); - expect(readdirSync(root).some(name => name.startsWith('library.json.bad-'))).toBe(true); + const backup = readdirSync(root).find(name => name.startsWith('library.json.bad-')); + expect(backup).toBeDefined(); + expect(readFileSync(join(root, backup!), 'utf8')).toBe('{not json'); + }); + + it.skipIf(!nativePermissions)('keeps a readable library usable in a read-only directory and preserves it after a failed commit', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli library café ')); + roots.push(root); + const file = join(root, '音楽 library.json'); + const original = JSON.stringify({settings: {theme: 'amber'}}); + writeFileSync(file, original); + chmodSync(root, 0o500); + try { + const store = new JsonLibraryStore(file); + const before = store.snapshot(); + expect(before.settings.theme).toBe('amber'); + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(/RADIOCLI_HOME.*writable/i); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + expect(readdirSync(root)).toEqual(['音楽 library.json']); + } finally { + chmodSync(root, 0o700); + } + }); + + it.skipIf(!nativePermissions)('does not replace a read-only library through an otherwise writable directory', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli readonly café ')); + roots.push(root); + const file = join(root, 'library.json'); + const original = JSON.stringify({settings: {theme: 'amber'}}); + writeFileSync(file, original); + chmodSync(file, 0o400); + try { + const store = new JsonLibraryStore(file); + const before = store.snapshot(); + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(/RADIOCLI_HOME.*writable/i); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + expect(readdirSync(root)).toEqual(['library.json']); + } finally { + chmodSync(file, 0o600); + } + }); + + it.skipIf(!nativePermissions)('does not rename an unreadable intact library as corrupt or return empty defaults', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli unreadable café ')); + roots.push(root); + const file = join(root, 'private library.json'); + const original = JSON.stringify({settings: {theme: 'amber'}}); + writeFileSync(file, original); + chmodSync(file, 0); + try { + let failure: unknown; + try { new JsonLibraryStore(file); } catch (error) { failure = error; } + expect(failure).toMatchObject({code: 'EACCES', cause: {code: 'EACCES'}}); + expect((failure as Error).message).toMatch(/read.*RADIOCLI_HOME.*writable/i); + expect((failure as Error).message).not.toContain(root); + expect(readdirSync(root)).toEqual(['private library.json']); + } finally { + chmodSync(file, 0o600); + } + expect(readFileSync(file, 'utf8')).toBe(original); + }); + + it.skipIf(!nativePermissions)('does not interpret an inaccessible ancestor as an absent library', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli inaccessible café ')); + roots.push(root); + const parent = join(root, 'private'); + const file = join(parent, 'library.json'); + mkdirSync(parent); + writeFileSync(file, '{"settings":{"theme":"amber"}}'); + chmodSync(parent, 0); + try { + expect(() => new JsonLibraryStore(file)).toThrow(/read.*RADIOCLI_HOME.*writable/i); + } finally { + chmodSync(parent, 0o700); + } + expect(readFileSync(file, 'utf8')).toBe('{"settings":{"theme":"amber"}}'); + expect(readdirSync(parent)).toEqual(['library.json']); + }); + + it.skipIf(!nativePermissions)('does not switch to an older library when the preferred directory is inaccessible', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli migration café ')); + roots.push(root); + const parent = join(root, 'current data'); + const file = join(parent, 'library.json'); + const legacy = join(root, 'legacy.json'); + mkdirSync(parent); + writeFileSync(file, '{"settings":{"theme":"ruby"}}'); + writeFileSync(legacy, '{"settings":{"theme":"amber"}}'); + vi.spyOn(paths, 'platformPaths').mockReturnValue({...paths.platformPaths(), library: file, legacyLibrary: legacy}); + chmodSync(parent, 0); + try { + expect(() => new JsonLibraryStore()).toThrow(/read.*RADIOCLI_HOME.*writable/i); + } finally { + chmodSync(parent, 0o700); + } + expect(readFileSync(file, 'utf8')).toBe('{"settings":{"theme":"ruby"}}'); + expect(readFileSync(legacy, 'utf8')).toBe('{"settings":{"theme":"amber"}}'); + }); + + it.skipIf(!nativePermissions)('preserves corrupt data when a backup cannot be written', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli corrupt café ')); + roots.push(root); + const file = join(root, 'library.json'); + writeFileSync(file, '{not json'); + chmodSync(root, 0o500); + try { + expect(() => new JsonLibraryStore(file)).toThrow(/corrupt.*RADIOCLI_HOME.*writable/i); + expect(readFileSync(file, 'utf8')).toBe('{not json'); + expect(readdirSync(root)).toEqual(['library.json']); + } finally { + chmodSync(root, 0o700); + } + }); + + it('retains I/O failure details without classifying the library as corrupt', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + writeFileSync(file, '{"settings":{"theme":"amber"}}'); + const failure = Object.assign(new Error(`disk failure at ${file}`), {code: 'EIO'}); + vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { throw failure; }); + + let reported: unknown; + try { new JsonLibraryStore(file); } catch (error) { reported = error; } + expect(reported).toMatchObject({code: 'EIO', cause: failure}); + expect((reported as Error).message).not.toContain(file); + expect(readFileSync(file, 'utf8')).toBe('{"settings":{"theme":"amber"}}'); + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it('preserves committed data and the original write failure when temporary and lock cleanup also fail', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + const failure = Object.assign(new Error(`rename failed at ${file}`), {code: 'ENOSPC'}); + const cleanup = Object.assign(new Error('cleanup failed'), {code: 'EACCES'}); + vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => { throw failure; }); + vi.spyOn(fs, 'rmSync').mockImplementation(() => { throw cleanup; }); + + let reported: unknown; + try { store.updateSettings({theme: 'ruby'}); } catch (error) { reported = error; } + expect(reported).toMatchObject({code: 'ENOSPC', cause: failure}); + expect((reported as Error).message).toMatch(/RADIOCLI_HOME.*writable/i); + expect((reported as Error).message).not.toContain(file); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + }); + + it('reports a busy library without revealing the private path or changing state', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli busy café ')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + mkdirSync(`${file}.lock`); + + let reported: unknown; + try { store.updateSettings({theme: 'ruby'}); } catch (error) { reported = error; } + expect((reported as Error).message).toMatch(/busy/i); + expect((reported as Error).message).not.toContain(root); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + }); + + it('preserves the library if setting private temporary-file permissions fails', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + const failure = Object.assign(new Error('chmod failed'), {code: 'EPERM'}); + const chmod = vi.spyOn(fs, 'chmodSync').mockImplementationOnce(() => { throw failure; }); + + if (process.platform === 'win32') { + store.updateSettings({theme: 'ruby'}); + expect(chmod).not.toHaveBeenCalled(); + expect(new JsonLibraryStore(file).snapshot().settings.theme).toBe('ruby'); + } else { + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(expect.objectContaining({code: 'EPERM', cause: failure})); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + } + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it('keeps the saved state accurate if lock release fails after a successful replacement', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + vi.spyOn(fs, 'rmSync').mockImplementationOnce(() => { throw Object.assign(new Error('release failed'), {code: 'EACCES'}); }); + + expect(store.updateSettings({theme: 'ruby'}).settings.theme).toBe('ruby'); + expect(new JsonLibraryStore(file).snapshot().settings.theme).toBe('ruby'); + }); + + it('reports lock-inspection failures without retrying them as a missing lock', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + const lockPath = `${file}.lock`; + mkdirSync(lockPath); + const failure = Object.assign(new Error(`lock inspection failed at ${lockPath}`), {code: 'EIO'}); + const stat = fs.statSync; + let inspected = false; + vi.spyOn(fs, 'statSync').mockImplementation(((...args: Parameters) => { + if (args[0] === lockPath && !inspected) { + inspected = true; + throw failure; + } + return stat(...args); + }) as typeof fs.statSync); + + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(expect.objectContaining({code: 'EIO', cause: failure})); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); }); it.each(['scope', 'spectrum', 'oscilloscope', 'motion-bars', 'radar', 'dual-ripple', 'perspective-floor', 'bloom-bars', 'running-horse', 'starlink', 'tuning-dial', 'cassette', `${'term'}${'flix'}-plasma`, 'sierpinksi', 'voronoi', 'orbits', 'chromatic', 'ferro-crown', 'origami-tide', 'tv-static', 'sunspot', 'plasma', 'metaballs', 'motion-blob', 'clifford', 'mirror', 'paris', 'kyoto', 'sahara'])( diff --git a/src/storage/store.ts b/src/storage/store.ts index 026c425..5afbfbe 100644 --- a/src/storage/store.ts +++ b/src/storage/store.ts @@ -1,3 +1,5 @@ +import {platformPaths} from '../platform/paths.js'; +import {assertStorageWritable} from '../platform/storage.js'; import {chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; import {dirname, isAbsolute, join, resolve} from 'node:path'; @@ -19,7 +21,6 @@ import { type UpdateCheckState } from '../types.js'; import {canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone} from '../alarms/schedule.js'; -import {backupBadFile} from '../providers/cache.js'; import {migrateReceiverStyle} from '../ui/visualizers/receiver-style-registry.js'; const stationSchema: z.ZodType = z @@ -282,7 +283,7 @@ export class JsonLibraryStore { private readonly now: () => Date; constructor( - filePath = defaultStorePath(), + filePath = defaultLibraryPath(), options: {idGenerator?: () => string; now?: () => Date} = {} ) { this.filePath = filePath; @@ -623,14 +624,24 @@ export class JsonLibraryStore { } private read(): LibraryState { - if (!existsSync(this.filePath)) { - return defaultState(); + let contents: string; + try { + contents = readFileSync(this.filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultState(); + throw storageError('read the RadioCLI library', error); } try { - return migrateLibraryState(librarySchema.parse(JSON.parse(readFileSync(this.filePath, 'utf8')))); + return migrateLibraryState(librarySchema.parse(JSON.parse(contents))); } catch { - backupBadFile(this.filePath); + try { + assertStorageWritable(this.filePath); + renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`); + } catch (error) { + // Reset only after preserving the original bytes. A failed read never reaches here. + throw storageError('preserve the corrupt RadioCLI library', error); + } return defaultState(); } } @@ -649,8 +660,13 @@ export class JsonLibraryStore { } private write(state: LibraryState): void { - mkdirSync(dirname(this.filePath), {recursive: true, mode: 0o700}); - writeJsonAtomically(this.filePath, libraryStateForDisk(state)); + try { + assertStorageWritable(this.filePath); + mkdirSync(dirname(this.filePath), {recursive: true, mode: 0o700}); + writeJsonAtomically(this.filePath, libraryStateForDisk(state)); + } catch (error) { + throw storageError('write the RadioCLI library', error); + } } } @@ -697,38 +713,20 @@ function recoverActiveListeningSessionInState(state: LibraryState): LibraryState return finishActiveListeningSessionInState(state, new Date(recoveredEnd)); } -function defaultStorePath(): string { - if (process.env.RADIOCLI_HOME) { - return join(process.env.RADIOCLI_HOME, 'radiocli.json'); - } - - if (process.env.RADIO_ATLAS_HOME) { - return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas.json'); - } - - const currentPath = currentDefaultStorePath(); - const legacyPath = legacyDefaultStorePath(); - return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath; -} - -function currentDefaultStorePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json'); - } - - if (process.platform === 'win32') { - return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json'); - } - - return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json'); -} - -function legacyDefaultStorePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas.json'); +export function defaultLibraryPath(): string { + const {library, legacyLibrary} = platformPaths(); + for (const filePath of [library, legacyLibrary]) { + try { + statSync(filePath); + return filePath; + } catch (error) { + // A permission failure must not select an older library or empty defaults. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw storageError('read the RadioCLI library', error); + } + } } - - return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radio-atlas', 'radio-atlas.json'); + return library; } function resolveUserPath(requestedPath: string): string { @@ -809,46 +807,76 @@ function libraryStateForDisk(state: LibraryState): LibraryState { } function writeJsonAtomically(filePath: string, value: unknown): void { - const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`; try { - writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); - renameSync(tempPath, filePath); + writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); if (process.platform !== 'win32') { - chmodSync(filePath, 0o600); + chmodSync(tempPath, 0o600); } + // No fallible persistence steps follow the atomic replacement. + renameSync(tempPath, filePath); } catch (error) { - rmSync(tempPath, {force: true}); - throw error; + try { rmSync(tempPath, {force: true}); } catch { /* Preserve the original write failure. */ } + throw storageError('write RadioCLI data', error); } } function acquireStoreLock(filePath: string): () => void { const lockPath = `${filePath}.lock`; - mkdirSync(dirname(filePath), {recursive: true, mode: 0o700}); + try { + assertStorageWritable(filePath); + mkdirSync(dirname(filePath), {recursive: true, mode: 0o700}); + } catch (error) { + throw storageError('write the RadioCLI library', error); + } const deadline = Date.now() + 1000; while (true) { try { mkdirSync(lockPath, {mode: 0o700}); - return () => rmSync(lockPath, {recursive: true, force: true}); + return () => { + // The write has already succeeded or failed. Lock cleanup cannot reverse that result. + try { rmSync(lockPath, {recursive: true, force: true}); } catch { /* Stale locks expire below. */ } + }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== 'EEXIST') throw error; + if (code !== 'EEXIST') throw storageError('lock the RadioCLI library', error); try { if (Date.now() - statSync(lockPath).mtimeMs > 10_000) { rmSync(lockPath, {recursive: true, force: true}); - continue; } - } catch { - continue; + } catch (lockError) { + if ((lockError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw storageError('lock the RadioCLI library', lockError); + } } if (Date.now() >= deadline) { - throw new Error(`RadioCLI library is busy: ${filePath}`); + throw storageError('lock the RadioCLI library', Object.assign(new Error('Another writer holds the library lock.'), {code: 'EBUSY', cause: error})); } Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); } } } +class LibraryStorageError extends Error { + readonly code?: string; + readonly errno?: number; + + constructor(operation: string, cause: unknown) { + const code = (cause as NodeJS.ErrnoException | undefined)?.code; + const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : ''; + super(code === 'EBUSY' + ? 'The RadioCLI library is busy. Retry after the other writer finishes, or set RADIOCLI_HOME to a private writable directory.' + : `Unable to ${operation}${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`, {cause}); + this.name = 'LibraryStorageError'; + this.code = code; + this.errno = (cause as NodeJS.ErrnoException | undefined)?.errno; + } +} + +function storageError(operation: string, cause: unknown): LibraryStorageError { + return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause); +} + function mergeLibraryChanges(base: LibraryState, disk: LibraryState, next: LibraryState): LibraryState { return { recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50), diff --git a/src/ui/AdaptiveContent.test.tsx b/src/ui/AdaptiveContent.test.tsx index 28a26bd..a8b4b99 100644 --- a/src/ui/AdaptiveContent.test.tsx +++ b/src/ui/AdaptiveContent.test.tsx @@ -1,5 +1,6 @@ -import {render} from 'ink-testing-library'; -import {describe, expect, it} from 'vitest'; +import {act} from 'react'; +import {cleanup, render as inkRender} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {receiverStyleNames} from '../types.js'; import type { AirPlayDevice, @@ -17,6 +18,25 @@ import {DisplayContext, resolveDisplayMode} from './display-context.js'; import {displayWidth} from './format.js'; import {defaultExploreCursor} from './app-state.js'; +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // Ink's fixture supplies 100 columns but no rows. Keep its 24-row fallback + // deterministic without asking the host terminal through commands like tput. + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '24'); +}); +afterEach(() => { + act(() => cleanup()); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function render(tree: Parameters[0]): ReturnType { + let view!: ReturnType; + act(() => { view = inkRender(tree); }); + return view; +} + const station: Station = { id: 'kexp', provider: 'radio-browser', @@ -238,7 +258,7 @@ function renderAdaptive( renderStations: Station[] = [station] ): string { const renderLibrary = {...library, settings: {...settings, receiverStyle}}; - return render( + const view = render( - ).lastFrame() ?? ''; + ); + try { + return view.lastFrame() ?? ''; + } finally { + act(() => { + view.unmount(); + view.cleanup(); + }); + } } diff --git a/src/ui/AdaptiveContent.tsx b/src/ui/AdaptiveContent.tsx index 646fed5..eea53c8 100644 --- a/src/ui/AdaptiveContent.tsx +++ b/src/ui/AdaptiveContent.tsx @@ -112,7 +112,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { stationTitle, } = props; const accent = themeAccent(theme); - const {ascii} = useDisplay(); + const {ascii, reduceMotion} = useDisplay(); const {bodyRows} = adaptiveFrameMetrics(mode, height); const title = screenTitle(screen); const status = adaptiveStatus(props); @@ -142,7 +142,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { ascii={ascii} theme={theme} receiverStyle={library.settings.receiverStyle} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ); } @@ -161,7 +161,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { loading={loadingStations} error={props.stationError} ascii={ascii} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ); } @@ -208,7 +208,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { width={width} theme={theme} ascii={ascii} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ); } @@ -237,7 +237,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { const prompt = airPlayCode ? `Code: ${airPlayCode}` : 'Type the code shown by the receiver.'; return ( - {bodyRows > 0 ? {truncate(prompt, width)} : null} + {bodyRows > 0 ? {ascii ? toAsciiSafe(truncate(prompt, width)) : truncate(prompt, width)} : null} ); } @@ -280,7 +280,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { pageSize={bodyRows} width={width} theme={theme} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ) : ( @@ -318,6 +318,7 @@ function AdaptiveExplore({ }): React.ReactElement { const accent = themeAccent(theme); const {headerRows, headerGap, bodyRows} = adaptiveExploreFrameMetrics(mode, height); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const layout = computeAdaptiveExploreLayout(mode, width, Math.max(1, bodyRows)); const marker = React.useMemo( () => [{lat: cursor.latitude, lon: cursor.longitude, selected: true}], @@ -349,8 +350,8 @@ function AdaptiveExplore({ return ( - {truncate(title, width)} - {headerRows > 1 ? {truncate(status, width)} : null} + {a(truncate(title, width))} + {headerRows > 1 ? {a(truncate(status, width))} : null} {headerGap ? : null} {bodyRows > 0 ? layout.split ? ( @@ -403,7 +404,7 @@ function AdaptiveCosmoMap({ ascii: boolean; }): React.ReactElement { return ( - + {rows.map((row, rowIndex) => { const chunks: Array<{kind: CosmoMapCellKind; text: string}> = []; for (const cell of row.cells) { @@ -456,6 +457,7 @@ function AdaptiveFrame({ }): React.ReactElement { const accent = themeAccent(theme); const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const titlePrefix = mode === 'micro' ? 'RC / ' : 'RADIOCLI '; const titleText = truncate(`${titlePrefix}${title}${mode === 'micro' && status ? ` · ${status}` : ''}`, width); const ruleWidth = Math.max(0, width - displayWidth(titleText) - 1); @@ -463,10 +465,10 @@ function AdaptiveFrame({ return ( - - {titleText}{ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : ''} + + {a(titleText)}{ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : ''} - {mode === 'compact' && height >= 4 ? {truncate(status, width)} : null} + {mode === 'compact' && height >= 4 ? {a(truncate(status, width))} : null} {gapRows ? : null} {children} @@ -501,6 +503,8 @@ function AdaptiveHome({ library: LibraryState; }): React.ReactElement { const gapRows = height >= 5 ? 1 : 0; + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const showSummary = mode === 'compact' && height >= 10; const menuRows = Math.max(1, height - 1 - gapRows * 2 - (showSummary ? 1 : 0)); const selectedIndex = Math.min(Math.max(selected, 0), homeItems.length - 1); @@ -517,15 +521,15 @@ function AdaptiveHome({ const active = absoluteIndex === selectedIndex; const detail = mode === 'compact' && width >= 52 ? ` · ${item.detail}` : ''; return ( - - {active ? '> ' : ' '}{absoluteIndex + 1} {truncate(`${item.label}${detail}`, Math.max(1, width - 4))} + + {active ? '> ' : ' '}{absoluteIndex + 1} {a(truncate(`${item.label}${detail}`, Math.max(1, width - 4)))} ); })} {showSummary ? ( - {truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width)} + {a(truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width))} ) : null} {gapRows ? : null} @@ -562,7 +566,7 @@ function AdaptiveList({ const text = `${row.label}${detail}`; const favoriteWidth = row.favoriteGlyph ? 2 : 0; return ( - + {prefix} {rows.map(row => ( - - {truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)} + + {ascii ? toAsciiSafe(truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)) : truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)} {row.key === 'activity' ? : null} ))} @@ -614,15 +619,16 @@ function AdaptiveNowPlaying({ reduceMotion: boolean; }): React.ReactElement { const pulse = useReceiverPulse(); + const {screenReader} = useDisplay(); const accent = themeAccent(theme); const stationName = station?.name ?? 'No station tuned'; - const showMetadata = mode === 'compact' && height >= 9 && Boolean(metadata?.title); + const showMetadata = Boolean(metadata?.title) && (screenReader || mode === 'compact' && height >= 9); const headerRows = mode === 'compact' ? 2 : 1; const metadataRows = showMetadata ? 1 : 0; const gapRows = height >= 5 ? 1 : 0; const availableVisualRows = Math.max(1, height - headerRows - metadataRows - gapRows * 2); const visualHeight = visualizerHeight(receiverStyle, availableVisualRows, width); - const visualRows = buildVisualizer( + const visualRows = screenReader ? [] : buildVisualizer( receiverStyle, pulse, width, @@ -639,14 +645,14 @@ function AdaptiveNowPlaying({ return ( - + {mode === 'micro' ? : truncate(header, width)} - {mode === 'compact' ? {truncate(status, width)} : null} + {mode === 'compact' ? {ascii ? toAsciiSafe(truncate(status, width)) : truncate(status, width)} : null} {gapRows ? : null} - + {!screenReader ? {visualRows.map((row, index) => ( {row.segments @@ -656,9 +662,9 @@ function AdaptiveNowPlaying({ : row.text} ))} - + : null} {showMetadata ? ( - + ) : null} @@ -712,6 +718,7 @@ function AdaptiveSearch({ }): React.ReactElement { const accent = themeAccent(theme); const {panel: panelBackground} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const fieldText = query || 'station, genre, or place'; const prefix = loading ? (ascii ? '* ' : '⣾ ') : editing ? '› ' : '/ '; const fieldRows = height >= 4 ? 3 : 1; @@ -730,11 +737,11 @@ function AdaptiveSearch({ height={3} flexShrink={0} > - {prefix} - {truncate(fieldText, Math.max(1, width - 5))} + {a(prefix)} + {a(truncate(fieldText, Math.max(1, width - 5)))} ) : ( - {truncate(`[${prefix}${fieldText}]`, width)} + {a(truncate(`[${prefix}${fieldText}]`, width))} )} {gapRows ? : null} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4103586..837cc73 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {fileURLToPath} from 'node:url'; -import {Box, Text, useApp, useStdin, useStdout, useWindowSize} from 'ink'; +import {Box, Text, useApp, useIsScreenReaderEnabled, useStdin, useStdout, useWindowSize} from 'ink'; import {ProviderManager} from '../providers/provider-manager.js'; import {PlayerController, type PlaybackControlResult} from '../player/player-controller.js'; import {playbackBackendInstallHint, playbackBackendLabel} from '../player/backend-install.js'; @@ -20,8 +20,10 @@ import {useAppInput} from './use-app-input.js'; import {useCommandExecutor} from './use-command-executor.js'; import {isAirPlayCodePromptActive} from './screens/AirPlayCodeScreen.js'; import {isAirPlayBackendAvailable} from './airplay-settings.js'; +import {networkPolicy} from '../platform/network.js'; import {audioOutputLabel, resolvedAudioOutput} from './audio-output.js'; import {copyToClipboard, openExternal} from './system-actions.js'; +import {safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText} from '../safety.js'; import {appVersion} from '../version.js'; import {automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall} from '../update-check.js'; import {helpItemCount} from './help-content.js'; @@ -99,6 +101,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const {stdin} = useStdin(); const {stdout} = useStdout(); const {columns, rows} = useWindowSize(); + const screenReader = useIsScreenReaderEnabled(); const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]); const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]); const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]); @@ -116,6 +119,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const [selected, setSelected] = useState(0); const [settingsPage, setSettingsPage] = useState('root'); const [message, setMessage] = useState(null); + const [persistenceWarning, setPersistenceWarning] = useState(null); + const [presenceWarning, setPresenceWarning] = useState(null); const [footerMessage, setFooterMessage] = useState(null); const [countries, setCountries] = useState([]); const [countryFilter, setCountryFilter] = useState(''); @@ -139,6 +144,22 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const [sleepUntil, setSleepUntil] = useState(null); const [showDiagnostics, setShowDiagnostics] = useState(false); const [capturingTransportAction, setCapturingTransportAction] = useState(null); + const reportActionError = useCallback((error: unknown) => { + setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.'); + }, []); + // Optional history cannot change the result of playback or navigation. Keep + // its warning until an explicit save succeeds; a no-op checkpoint proves nothing. + const persistLibrary = useCallback((write: () => LibraryState): LibraryState | undefined => { + try { + const nextLibrary = write(); + setLibrary(nextLibrary); + return nextLibrary; + } catch (error) { + const detail = sanitizeTerminalText(error instanceof Error ? error.message : String(error)); + setPersistenceWarning(`Library not saved. Set RADIOCLI_HOME to a writable directory.${detail ? ` ${detail}` : ''}`); + return undefined; + } + }, []); const announcedUpdateRef = useRef(false); const automaticUpdateCheckStartedRef = useRef(false); const installingUpdateRef = useRef(false); @@ -179,8 +200,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const theme = library.settings.theme; const displayMode = useMemo( - () => resolveDisplayMode(library.settings), - [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion] + () => resolveDisplayMode(library.settings, process.env, {screenReader, isTTY: Boolean(stdout.isTTY), colorDepth: stdout.getColorDepth?.()}), + [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion, screenReader, stdout] ); const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]); const diagnostics = player.diagnostics(); @@ -285,6 +306,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const layout = computeTerminalLayout(columns, rows, footerRows); const frameWidth = layout.frameWidth; const mouseReportingActive = + !displayMode.screenReader && + process.env.TERM?.toLowerCase() !== 'dumb' && !commandMode && !capturingTransportAction && !editingCountryFilter && @@ -296,7 +319,18 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ); useEffect(() => player.onChange(setPlayback), [player]); - useEffect(() => stdin.isTTY ? registerTuiPresence() : undefined, [stdin]); + useEffect(() => { + if (!stdin.isTTY) return; + try { + const unregister = registerTuiPresence(); + return () => { + try {unregister();} + catch {console.error('RadioCLI could not remove its alarm-control presence marker. Stale markers are checked on the next launch.');} + }; + } catch { + setPresenceWarning('Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.'); + } + }, [stdin]); const playingStationRef = useRef(null); playingStationRef.current = playingStation; @@ -310,26 +344,26 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const key = stationKey(station); if (activeListeningStationRef.current !== key) { activeListeningStationRef.current = key; - setLibrary(store.startListeningSession(station)); + persistLibrary(() => store.startListeningSession(station)); } return; } if (activeListeningStationRef.current) { activeListeningStationRef.current = null; - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); } - }, [playback.ready, playback.state, playingStation, store]); + }, [persistLibrary, playback.ready, playback.state, playingStation, store]); useEffect(() => { if (playback.state !== 'playing' || !playback.ready || !playingStation) { return; } const timer = setInterval(() => { - setLibrary(store.checkpointActiveListeningSession()); + persistLibrary(() => store.checkpointActiveListeningSession()); }, LISTENING_HEARTBEAT_MS); return () => clearInterval(timer); - }, [playback.ready, playback.state, playingStation, store]); + }, [persistLibrary, playback.ready, playback.state, playingStation, store]); useEffect( () => @@ -340,10 +374,10 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const trackKey = `${stationKey(station)}:${metadata.title}`; if (lastRecordedTrackRef.current === trackKey) return; lastRecordedTrackRef.current = trackKey; - setLibrary(store.recordTrack(station, metadata.title)); + persistLibrary(() => store.recordTrack(station, metadata.title!)); } }), - [player, store] + [persistLibrary, player, store] ); useEffect(() => { @@ -368,7 +402,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe useEffect(() => { if ( footerPlayback.state !== 'loading' || - library.settings.reduceMotion || + displayMode.reduceMotion || process.env.RADIOCLI_DISABLE_ANIMATION === '1' || process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1' ) { @@ -378,7 +412,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS); return () => clearInterval(timer); - }, [footerPlayback.state, library.settings.reduceMotion]); + }, [footerPlayback.state, displayMode.reduceMotion]); useEffect(() => { if ( @@ -427,25 +461,28 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const delayMs = sleepUntil - Date.now(); if (delayMs <= 0) { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); - void player.stop(); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError); setSleepUntil(null); return; } const timer = setTimeout(() => { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); - void player.stop(); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError); setSleepUntil(null); }, delayMs); return () => clearTimeout(timer); - }, [player, sleepUntil, store]); + }, [persistLibrary, player, reportActionError, sleepUntil, store]); useEffect(() => { const backends = player.refreshDetectedBackends(); setAvailableBackends(backends); - void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([])); + const network = networkPolicy(); + if (!network.offline && !network.lowBandwidth) { + void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([])); + } if (backends.length === 0) { setMessage(`No playback backend found. ${playbackBackendInstallHint()}`); } @@ -461,9 +498,9 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const refreshUpdateCheck = useCallback(async () => { const updateCheck = await updateChecker({currentVersion: installedVersion}); - setLibrary(store.updateCheckState(updateCheck)); + persistLibrary(() => store.updateCheckState(updateCheck)); return updateCheck; - }, [installedVersion, store, updateChecker]); + }, [installedVersion, persistLibrary, store, updateChecker]); useEffect(() => { if ( @@ -484,12 +521,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe announcedUpdateRef.current = true; setMessage(`Update available: v${updateCheck.latestVersion} · run :update`); } + }).catch(error => { + if (!cancelled) reportActionError(error); }); return () => { cancelled = true; }; - }, [library.settings.automaticUpdateChecks, refreshUpdateCheck]); + }, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]); const setStationContextFor = useCallback((key: StationContextKey, context: StationContext) => { setStationContexts(current => ({...current, [key]: context})); @@ -507,6 +546,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const nextLibrary = store.updateSettings(settings); settingsRef.current = nextLibrary.settings; setLibrary(nextLibrary); + setPersistenceWarning(null); return nextLibrary; }, [store] @@ -523,9 +563,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return; } - updateSettings({preferredBackend}); - setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`); - }, [availableBackends, selectedAirPlayDevice, updateSettings]); + try { + updateSettings({preferredBackend}); + setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`); + } catch (error) { + reportActionError(error); + } + }, [availableBackends, reportActionError, selectedAirPlayDevice, updateSettings]); const updateMediaKeys = useCallback( (mediaKeys: AppSettings['mediaKeys']) => { @@ -617,14 +661,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const shutdown = useCallback(() => { tuneRequestRef.current += 1; - store.finishActiveListeningSession(); - player.stop().finally(exit); - }, [exit, player, store]); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError).finally(exit); + }, [exit, persistLibrary, player, reportActionError, store]); useEffect(() => { const finishForSignal = () => { - store.finishActiveListeningSession(); - void player.stop().finally(() => process.exit(0)); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError).finally(() => process.exit(0)); }; process.once('SIGTERM', finishForSignal); process.once('SIGHUP', finishForSignal); @@ -632,7 +676,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe process.off('SIGTERM', finishForSignal); process.off('SIGHUP', finishForSignal); }; - }, [player, store]); + }, [persistLibrary, player, reportActionError, store]); const showStationContext = useCallback( (context: StationContext, next: Screen = 'stations', options: NavigationOptions = {}) => { @@ -855,7 +899,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }); selectedByScreenRef.current.search = 0; lastSubmittedSearchRef.current = query.trim(); - setLibrary(store.addSearch(query)); + persistLibrary(() => store.addSearch(query)); searchHistoryRef.current = {cursor: -1, draft: ''}; setSelected(0); setEditingSearch(true); @@ -866,7 +910,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (requestId === searchRequestRef.current) setLoadingStations(false); } }, - [filters, providers, searchQuery, setStationContextFor, store] + [filters, persistLibrary, providers, searchQuery, setStationContextFor, store] ); const loadMoreSearchResults = useCallback(async () => { @@ -1088,8 +1132,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe setPlayingStation(station); setTuningStation(null); playbackQueueRef.current = queue; - const nextLibrary = store.addRecent(station); - if (screenRef.current === 'library') { + const nextLibrary = persistLibrary(() => store.addRecent(station)); + if (nextLibrary && screenRef.current === 'library') { const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters); const nextLibraryIndex = nextLibraryStations.findIndex(item => stationMatches(item, station)); if (nextLibraryIndex >= 0) { @@ -1098,7 +1142,6 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } } - setLibrary(nextLibrary); if (options.openNowPlaying) { go('now-playing'); } @@ -1125,7 +1168,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe setMessage(message); } }, - [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store] + [filters, go, persistLibrary, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store] ); // Resume the most recent station on launch when opted in, like a radio @@ -1188,18 +1231,19 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const wasFavorite = store.isFavorite(station); setLibrary(store.toggleFavorite(station)); + setPersistenceWarning(null); const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`; - if (screenRef.current === 'library') { + if (screenRef.current === 'library' || screenRef.current === 'search') { showTransientFooterMessage(favoriteMessage); } else { setMessage(favoriteMessage); } if (!wasFavorite && settingsRef.current.shareDirectoryVotes) { // Best-effort upvote back to the directory; never blocks favoriting. - void providers.vote(station); + void providers.vote(station).catch(reportActionError); } }, - [providers, showTransientFooterMessage, store] + [providers, reportActionError, showTransientFooterMessage, store] ); const showControlResult = useCallback((result: PlaybackControlResult) => { @@ -1208,16 +1252,22 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } }, []); - const openStationHomepage = useCallback((station: Station | null) => { + const openStationHomepage = useCallback(async (station: Station | null) => { if (!station?.homepage) { setMessage('This station has no homepage.'); return; } + const url = safeExternalHttpUrl(station.homepage); + if (!url) { + setMessage('This station has no valid HTTP(S) homepage.'); + return; + } + setMessage( - openExternal(station.homepage) + await openExternal(url) ? `Opening homepage: ${station.name}` - : 'That station homepage is not a valid HTTP(S) URL.' + : `Could not open a browser in this session. Homepage: ${sanitizeTerminalText(url) ?? ''}` ); }, []); @@ -1233,13 +1283,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined); } - if (!url) { - setMessage(`No stream URL available for ${station.name}.`); + const safeUrl = url ? safeMediaTarget(url) : null; + if (!safeUrl) { + setMessage(`No safe stream URL available for ${station.name}.`); return; } - const copied = copyToClipboard(url); - setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${url}`); + const copied = await copyToClipboard(safeUrl); + setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${sanitizeTerminalText(safeUrl) ?? ''}`); }, [providers] ); @@ -1269,9 +1320,9 @@ export function App({store: providedStore, providers: providedProviders, alarmSe updateSettings({volume: player.getState().volume}); } showControlResult(result); - }); + }).catch(reportActionError); }, - [player, showControlResult, updateSettings] + [player, reportActionError, showControlResult, updateSettings] ); const adjustVolume = useCallback( @@ -1283,18 +1334,18 @@ export function App({store: providedStore, providers: providedProviders, alarmSe updateSettings({volume: player.getState().volume}); } showControlResult(result); - }); + }).catch(reportActionError); }, - [player, showControlResult, updateSettings] + [player, reportActionError, showControlResult, updateSettings] ); const toggleMute = useCallback(() => { - void player.toggleMute().then(showControlResult); - }, [player, showControlResult]); + void player.toggleMute().then(showControlResult).catch(reportActionError); + }, [player, reportActionError, showControlResult]); const togglePause = useCallback(() => { - void player.togglePause().then(showControlResult); - }, [player, showControlResult]); + void player.togglePause().then(showControlResult).catch(reportActionError); + }, [player, reportActionError, showControlResult]); const showTransientMessage = useCallback((nextMessage: string) => { if (transientMessageTimerRef.current) { @@ -1350,6 +1401,10 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, [updateSettings]); const refreshAirPlayTargets = useCallback(async (announce = true): Promise => { + if (networkPolicy().offline) { + setMessage('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.'); + return []; + } try { const devices = await player.refreshAirPlayDevices(); setAvailableAirPlayDevices(devices); @@ -1619,7 +1674,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } const command = updateCommandForInstall(); - const copied = copyToClipboard(command.command); + const copied = await copyToClipboard(command.command); const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion; const prefix = latestVersion ? `Latest v${latestVersion}. ` : ''; const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method'; @@ -1682,6 +1737,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe loadCountry, openAirPlaySettings, openLibrary, + persistLibrary, player, playingStation, providers, @@ -1766,7 +1822,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (command.type === 'resume') { const control = await player.resume(); return respond(control.message ?? 'Resumed.', control.ok); } if (command.type === 'stop') { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); playingStationRef.current = null; setPlayingStation(null); @@ -1776,7 +1832,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } if (command.type === 'alarm-preempt') { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); playingStationRef.current = null; setPlayingStation(null); @@ -1798,10 +1854,12 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`); } if (command.type === 'airplay-list') { + if (networkPolicy().offline) return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []); const devices = await refreshAirPlayTargets(false); return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices); } if (command.type === 'airplay-select') { + if (networkPolicy().offline) return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false); const devices = await refreshAirPlayTargets(false); const device = devices.find(item => item.id === command.deviceId); if (!device) return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices); @@ -1991,7 +2049,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const hasActiveMicroPlayback = Boolean( footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused') ); - const microFooter = footerMessage ?? message ?? ( + const statusMessage = message ?? persistenceWarning ?? presenceWarning; + const microFooter = footerMessage ?? statusMessage ?? ( pageFooterOwnsCompactRow ? pageFooter : footerPlayback.state === 'loading' && playbackFooter @@ -2002,13 +2061,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ); const compactGlobalFooter = '←/→ tabs · ? help · q quit'; const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2; - const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter); + const fullStatusRows = fullStatusFooterRows(screen, statusMessage, footerMessage, playbackFooter); const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve); - const compactStatus = footerMessage ?? message ?? playbackFooter; + const compactStatus = footerMessage ?? statusMessage ?? playbackFooter; const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0)); const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve); const microLegendWidth = Math.max(1,frameWidth-versionReserve); - const microLegend = commandMode || capturingTransportAction || footerMessage || message + const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage ? microFooter : microShortcutFooterText(microFooter, microLegendWidth); const footerText = (value: string): string => displayMode.ascii ? toAsciiSafe(value) : value; @@ -2018,7 +2077,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe screen={screen} playback={playback} receiverStyle={library.settings.receiverStyle} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={displayMode.reduceMotion} > ) : ( <> - {layout.mode === 'micro' ? + {layout.mode === 'micro' ? {footerText(truncate(microLegend, microLegendWidth))} : <> - {compactStatus ? {footerText(truncate(compactStatus, frameWidth))} : null} + {compactStatus ? {footerText(truncate(compactStatus, frameWidth))} : null} {compactLegendRows.map((row, index) => index===compactLegendRows.length-1?{footerText(row)}:{footerText(row)})} } diff --git a/src/ui/__snapshots__/visual-baseline.test.tsx.snap b/src/ui/__snapshots__/visual-baseline.test.tsx.snap new file mode 100644 index 0000000..3637fa6 --- /dev/null +++ b/src/ui/__snapshots__/visual-baseline.test.tsx.snap @@ -0,0 +1,11325 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`palette baseline > 'alarm selection' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mAlarms[22m[38;2;138;150;168m ──────────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mScheduler: Ready[39m [49m +[48;2;7;10;15m[38;2;116;242;138mNext wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m Create alarm [49m +[1m[48;2;7;10;15m[38;2;116;242;138m› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM[39m[22m [49m +[48;2;7;10;15m Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194mNative scheduling is ready; the terminal does not need to stay open after save.[39m [49m +[1m[48;2;116;242;138m[30m BETA · Experimental. Use a secondary device for critical wake-ups. [39m[22m[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'amber/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;176;0mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'amber/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;176;0mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;176;0mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;176;0m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;214;138;0m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;176;0mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;176;0mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;176;0m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;176;0m2[39m [38;2;170;180;194mLongest streak: [38;2;255;176;0m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;176;0m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;176;0m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;176;0m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;176;0m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;95;55;0m [48;2;9;13;20m [48;2;154;98;0m [48;2;9;13;20m [48;2;214;138;0m [48;2;9;13;20m [48;2;255;176;0m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'blue/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;83;168;255mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'blue/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;83;168;255mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;83;168;255mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;83;168;255m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;51;136;221m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;83;168;255mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;83;168;255mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;83;168;255m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;83;168;255m2[39m [38;2;170;180;194mLongest streak: [38;2;83;168;255m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;83;168;255m2 days[39m [38;2;170;180;194mStations listened: [38;2;83;168;255m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;83;168;255m2/371[39m [38;2;170;180;194mStations counted after: [38;2;83;168;255m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;18;56;95m [48;2;9;13;20m [48;2;31;111;235m [48;2;9;13;20m [48;2;51;136;221m [48;2;9;13;20m [48;2;83;168;255m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'copper/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;208;135;112mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'copper/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;208;135;112mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;135;112mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;208;135;112m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;184;111;82m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;135;112mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;208;135;112mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;208;135;112m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;208;135;112m2[39m [38;2;170;180;194mLongest streak: [38;2;208;135;112m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;208;135;112m2 days[39m [38;2;170;180;194mStations listened: [38;2;208;135;112m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;208;135;112m2/371[39m [38;2;170;180;194mStations counted after: [38;2;208;135;112m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;68;40;31m [48;2;9;13;20m [48;2;127;79;55m [48;2;9;13;20m [48;2;184;111;82m [48;2;9;13;20m [48;2;208;135;112m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'coral/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;126;107mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'coral/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;126;107mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;126;107mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;126;107m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;214;92;73m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;126;107mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;126;107mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;126;107m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;126;107m2[39m [38;2;170;180;194mLongest streak: [38;2;255;126;107m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;126;107m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;126;107m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;126;107m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;126;107m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;76;32;24m [48;2;9;13;20m [48;2;154;64;50m [48;2;9;13;20m [48;2;214;92;73m [48;2;9;13;20m [48;2;255;126;107m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'cyan/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;34;211;238mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'cyan/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;34;211;238mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;34;211;238mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;34;211;238m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;34;184;207m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;34;211;238mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;34;211;238mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;34;211;238m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;34;211;238m2[39m [38;2;170;180;194mLongest streak: [38;2;34;211;238m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;34;211;238m2 days[39m [38;2;170;180;194mStations listened: [38;2;34;211;238m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;34;211;238m2/371[39m [38;2;170;180;194mStations counted after: [38;2;34;211;238m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;14;59;66m [48;2;9;13;20m [48;2;22;138;158m [48;2;9;13;20m [48;2;34;184;207m [48;2;9;13;20m [48;2;34;211;238m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'default receiver' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;116;242;138m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [1m[38;2;116;242;138mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;116;242;138mPLAYING[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⢀⣤⣠⣤⣄ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⣤⢾⣓⡋⣰⣾⠉⠛⠙⢻⣤ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⢠⣞⠃⠈⣱⡿⠃ ⢳⡆⠈⠹⠳⢦⡄ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠿⣿⣿⣶⠻⠉ ⠠⣽⠓⠿⢶⣆ ⢽ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⣠⣾⠾⠙⠚⡙⠚⠳⠿⠳⠂⠘⠋⠁ ⠘⠂ ⣼⠃ ⠈ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⣀⡀⣄⣤⣦⠞⠃ ⠁ ⠘⣳⠄⢨⡏ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠰⠟⠉⠉⣽⡟⠉⠰⣷⠄⢀⡀ ⢠⡆ ⢸⣿⣾⣅ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠻⢷⣩⣀⡀ ⣠⡏ ⠘⢿⣿⡄ ⣿⠿⣾⡿⣴⢶⡴⠾⢿⣗⣠⡌⢹⣿⠚⠛⠺⣿⠍ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠟⠿⣿⢾⠞⠋ ⢰⣿⣯ ⢯ ⢀⡀⢀⠙⠓⢿⣿⢿⠆ ⠈⣹⣷ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⢼⠟⢿⣷⣜⣷⣤⣄⡀ ⠹⠞⢿⣿⣷⣷⡿⠋⠈ ⢽⣿⡆ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠈⠳⣇⣤⡈⠙⢹⣟⣹⢷⡀ ⠟⠛⠋ ⢩⣿⣿ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠘⣿⢇⡀ ⠈⠿⠂⠙ ⠛ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠻⣿⣷⣆⡀ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠓⠛⠓⠛⠙⠛⠙⠛⠁ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138mBjork - Joga[39m [33m★ Favorite[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'green/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;116;242;138mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'green/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;116;242;138mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;116;242;138m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;57;211;83m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;116;242;138mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;116;242;138mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;116;242;138m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;116;242;138m2[39m [38;2;170;180;194mLongest streak: [38;2;116;242;138m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;116;242;138m2 days[39m [38;2;170;180;194mStations listened: [38;2;116;242;138m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;116;242;138m2/371[39m [38;2;170;180;194mStations counted after: [38;2;116;242;138m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;14;68;41m [48;2;9;13;20m [48;2;38;166;65m [48;2;9;13;20m [48;2;57;211;83m [48;2;9;13;20m [48;2;116;242;138m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'ice/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;185;246;255mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'ice/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;185;246;255mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;185;246;255mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;185;246;255m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;134;220;232m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;185;246;255mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;185;246;255mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;185;246;255m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;185;246;255m2[39m [38;2;170;180;194mLongest streak: [38;2;185;246;255m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;185;246;255m2 days[39m [38;2;170;180;194mStations listened: [38;2;185;246;255m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;185;246;255m2/371[39m [38;2;170;180;194mStations counted after: [38;2;185;246;255m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;36;71;77m [48;2;9;13;20m [48;2;74;149;160m [48;2;9;13;20m [48;2;134;220;232m [48;2;9;13;20m [48;2;185;246;255m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'lime/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;163;230;53mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'lime/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;163;230;53mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;163;230;53mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;163;230;53m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;132;179;43m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;163;230;53mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;163;230;53mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;163;230;53m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;163;230;53m2[39m [38;2;170;180;194mLongest streak: [38;2;163;230;53m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;163;230;53m2 days[39m [38;2;170;180;194mStations listened: [38;2;163;230;53m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;163;230;53m2/371[39m [38;2;170;180;194mStations counted after: [38;2;163;230;53m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;47;61;18m [48;2;9;13;20m [48;2;95;125;31m [48;2;9;13;20m [48;2;132;179;43m [48;2;9;13;20m [48;2;163;230;53m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'mono scene' 1`] = ` +"[1m[48;2;7;10;15m[38;2;208;208;208mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;208;208;208m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [1m[38;2;208;208;208mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;208;208;208mPLAYING[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;10;10;14m[38;2;10;10;14m [38;2;92;92;92m˙[38;2;10;10;14m [38;2;216;216;216m│[38;2;10;10;14m [38;2;92;92;92m˙[38;2;10;10;14m [38;2;255;255;255m•[38;2;10;10;14m [38;2;216;216;216m│[38;2;10;10;14m [38;2;106;106;106m˙˙[38;2;10;10;14m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;11;11;16m[38;2;11;11;16m [38;2;216;216;216m│[38;2;11;11;16m [38;2;216;216;216m│[38;2;11;11;16m [48;2;17;17;22m[38;2;88;88;96m▀▀▀▀[48;2;11;11;16m[38;2;11;11;16m [38;2;106;106;106m˙[38;2;11;11;16m [38;2;240;240;240m••[38;2;11;11;16m [38;2;106;106;106m˙[38;2;11;11;16m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;13;13;19m[38;2;13;13;19m [48;2;86;86;96m[38;2;255;255;255m•[38;2;86;86;96m [38;2;255;255;255m•[48;2;13;13;19m[38;2;13;13;19m [38;2;216;216;216m│[38;2;13;13;19m [48;2;17;17;22m[38;2;72;72;88m│[38;2;190;190;190m·[38;2;74;74;82m│[38;2;226;226;226m·[48;2;13;13;19m[38;2;13;13;19m [38;2;106;106;106m˙[38;2;240;240;240m•[1m●[22m[38;2;13;13;19m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;14;14;21m[38;2;14;14;21m [48;2;86;86;96m[38;2;86;86;96m [38;2;255;255;255m•[48;2;14;14;21m[38;2;14;14;21m [38;2;43;43;52m│[38;2;14;14;21m [48;2;17;17;22m[38;2;255;255;255m╱◆╲[48;2;14;14;21m[38;2;14;14;21m [48;2;17;17;22m[38;2;72;72;88m│[38;2;120;120;120m▪[38;2;74;74;82m│[38;2;43;43;52m░[48;2;14;14;21m[38;2;14;14;21m [38;2;240;240;240m••[38;2;14;14;21m [38;2;92;92;92m˙[38;2;14;14;21m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;16;16;23m[38;2;16;16;23m [48;2;86;86;96m[38;2;255;255;255m•••[48;2;16;16;23m[38;2;16;16;23m [48;2;25;25;32m[38;2;74;74;82m▀▀▀▀▀[48;2;16;16;23m[38;2;16;16;23m [48;2;17;17;22m[38;2;255;255;255m╱◆╲[48;2;16;16;23m[38;2;16;16;23m [48;2;25;25;32m[38;2;74;74;82m▀▀▀▀▀▀[48;2;16;16;23m[38;2;16;16;23m [48;2;17;17;22m[38;2;48;48;55m▄[38;2;226;226;226m·[38;2;48;48;55m▄▄[48;2;16;16;23m[38;2;16;16;23m [38;2;92;92;92m˙[38;2;16;16;23m [38;2;106;106;106m˙˙˙[38;2;16;16;23m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m▀▀▀[48;2;18;18;25m[38;2;18;18;25m [38;2;255;255;255m▀[48;2;17;17;22m[38;2;216;216;216m▀[38;2;255;255;255m▀[38;2;216;216;216m▀[38;2;255;255;255m▀[38;2;216;216;216m▀[48;2;18;18;25m[38;2;255;255;255m▀[38;2;18;18;25m [38;2;92;92;92m˙[38;2;18;18;25m [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m█[48;2;18;18;25m[38;2;18;18;25m [48;2;17;17;22m[38;2;216;216;216m╱╱◆╲╲[48;2;18;18;25m[38;2;18;18;25m [38;2;92;92;92m˙[38;2;18;18;25m [48;2;25;25;32m[38;2;226;226;226m·[38;2;25;25;32m [38;2;190;190;190m•[38;2;74;74;82m│[38;2;25;25;32m [48;2;18;18;25m[38;2;18;18;25m [48;2;17;17;22m[38;2;17;17;22m [38;2;72;72;88m│[38;2;17;17;22m [38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [48;2;18;18;25m[38;2;18;18;25m [48;2;17;17;22m[38;2;74;74;82m▀▀▀[48;2;18;18;25m[38;2;18;18;25m [38;2;43;43;52m│[38;2;18;18;25m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;17;17;22m [38;2;120;120;120m▪[38;2;74;74;82m│[38;2;17;17;22m [48;2;19;19;28m[38;2;19;19;28m [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;216;216;216m╱╱╱◆╲╲╲[48;2;19;19;28m[38;2;19;19;28m [48;2;25;25;32m[38;2;74;74;82m█[38;2;120;120;120m▪[38;2;43;43;52m░[38;2;25;25;32m [38;2;74;74;82m│[38;2;226;226;226m·[38;2;25;25;32m [38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;17;17;22m [38;2;72;72;88m│[38;2;190;190;190m·[38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;74;74;82m▀▀▀▀[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;17;17;22m [38;2;226;226;226m•[38;2;74;74;82m│[38;2;226;226;226m·[38;2;17;17;22m [48;2;21;21;30m[38;2;21;21;30m [48;2;25;25;32m[38;2;74;74;82m█[38;2;52;52;60m▄▄[38;2;25;25;32m [38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;17;17;22m [38;2;216;216;216m│[38;2;74;74;82m│[38;2;43;43;52m░[38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;25;25;32m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;52;52;60m▄[38;2;190;190;190m•[38;2;52;52;60m▄[38;2;190;190;190m•[38;2;25;25;32m [38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;120;120;120m▪[38;2;48;48;55m▄[38;2;190;190;190m·[38;2;48;48;55m▄[38;2;120;120;120m▪[38;2;17;17;22m [48;2;21;21;30m[38;2;21;21;30m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;21;21;30m[38;2;43;43;52m│[38;2;21;21;30m [48;2;17;17;22m[38;2;74;74;82m█[38;2;43;43;52m░[38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;74;74;82m█[38;2;43;43;52m░[38;2;17;17;22m [38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m▀▀▀▀▀▀[48;2;28;28;37m[38;2;28;28;37m [48;2;32;32;40m[38;2;32;32;40m░[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;17;17;22m [38;2;120;120;120m▪[38;2;74;74;82m│[38;2;43;43;52m░[38;2;17;17;22m [38;2;74;74;82m█[48;2;28;28;37m[38;2;28;28;37m [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;226;226;226m•[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;48;48;55m▄▄[38;2;216;216;216m│[38;2;48;48;55m▄▄[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;25;25;32m [38;2;226;226;226m·[38;2;74;74;82m│[38;2;25;25;32m [38;2;74;74;82m█[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;120;120;120m▪[38;2;43;43;52m░[38;2;17;17;22m [38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [48;2;28;28;37m[38;2;28;28;37m [48;2;32;32;40m[38;2;32;32;40m░[48;2;28;28;37m[38;2;28;28;37m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;28;28;37m[38;2;28;28;37m [48;2;25;25;32m[38;2;25;25;32m [38;2;43;43;52m░[38;2;74;74;82m│[38;2;190;190;190m•[38;2;25;25;32m [48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;43;43;52m░[38;2;74;74;82m█[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;48;48;55m▄▄[38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;25;25;32m[38;2;74;74;82m▀▀▀[48;2;31;31;41m[38;2;31;31;41m [48;2;17;17;22m[38;2;17;17;22m [38;2;74;74;82m│[38;2;17;17;22m [48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m│[38;2;17;17;22m [38;2;226;226;226m·[38;2;74;74;82m█[48;2;31;31;41m[38;2;31;31;41m [48;2;25;25;32m[38;2;74;74;82m█[38;2;52;52;60m▄▄[38;2;25;25;32m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;226;226;226m•[38;2;74;74;82m│[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[38;2;120;120;120m·[48;2;25;25;32m[38;2;74;74;82m█[38;2;52;52;60m▄▄[38;2;43;43;52m░[38;2;52;52;60m▄[38;2;226;226;226m·[38;2;25;25;32m [38;2;74;74;82m█[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;17;17;22m[38;2;190;190;190m•[38;2;48;48;55m▄[38;2;43;43;52m░[38;2;48;48;55m▄▄[38;2;43;43;52m░[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;43;43;52m░[38;2;25;25;32m [38;2;74;74;82m│[38;2;25;25;32m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;43;43;52m░[38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m█[48;2;35;35;44m[38;2;35;35;44m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;226;226;226m·[38;2;74;74;82m│[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;48;48;55m▄[38;2;17;17;22m [38;2;48;48;55m▄[38;2;226;226;226m•[38;2;17;17;22m [38;2;48;48;55m▄[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;43;43;52m░[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░░[48;2;17;17;22m[38;2;74;74;82m█[38;2;120;120;120m▪[38;2;17;17;22m [38;2;74;74;82m│[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m│[38;2;25;25;32m [38;2;74;74;82m█[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;43;43;52m░[38;2;226;226;226m·[38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;190;190;190m•[38;2;52;52;60m▄[38;2;25;25;32m [38;2;52;52;60m▄▄[38;2;25;25;32m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;74;74;82m█[38;2;190;190;190m•[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;17;17;22m [38;2;208;208;208m░[48;2;32;32;40m[38;2;32;32;40m░[48;2;25;25;32m[38;2;208;208;208m░ [38;2;25;25;32m [48;2;38;38;48m[38;2;38;38;48m [48;2;17;17;22m[38;2;208;208;208m░ ░ ░ [38;2;17;17;22m [38;2;208;208;208m [48;2;32;32;40m[38;2;32;32;40m░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;17;17;22m[38;2;17;17;22m [38;2;208;208;208m [38;2;17;17;22m [38;2;208;208;208m ░[38;2;17;17;22m [38;2;208;208;208m░ [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;25;25;32m[38;2;208;208;208m ░[38;2;25;25;32m [48;2;32;32;40m[38;2;32;32;40m░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░░[48;2;17;17;22m[38;2;208;208;208m░[38;2;17;17;22m [38;2;208;208;208m░[38;2;17;17;22m [38;2;208;208;208m [38;2;17;17;22m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;25;25;32m [38;2;208;208;208m [38;2;25;25;32m [38;2;208;208;208m [38;2;25;25;32m [38;2;208;208;208m ░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;17;17;22m [38;2;72;72;88m│[38;2;208;208;208m░[38;2;17;17;22m [38;2;72;72;88m│[38;2;208;208;208m [38;2;17;17;22m [48;2;32;32;40m[38;2;32;32;40m░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;25;25;32m [38;2;208;208;208m ░[38;2;25;25;32m [48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;208;208;208m░ ░[48;2;32;32;40m[38;2;32;32;40m [48;2;17;17;22m[38;2;208;208;208m░ [38;2;17;17;22m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;13;13;18m[38;2;104;104;107m [38;2;226;226;226m•[38;2;244;244;244m•[38;2;104;104;107m [38;2;168;168;168m•[38;2;104;104;107m ─[38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m ˙ [38;2;244;244;244m•[38;2;226;226;226m•[38;2;104;104;107m [38;2;168;168;168m•[38;2;104;104;107m˙[38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m [38;2;244;244;244m•[38;2;104;104;107m ˙ [38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m [38;2;168;168;168m•[38;2;120;120;120m•[38;2;104;104;107m ˙[38;2;226;226;226m•[38;2;104;104;107m ˙[38;2;153;153;154m•[38;2;226;226;226m•[38;2;168;168;168m•[38;2;190;190;190m•[38;2;104;104;107m ˙ ─[38;2;190;190;190m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m˙ [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;11;11;15m[38;2;90;90;92m˙── [38;2;138;138;140m·[38;2;126;126;127m·[38;2;90;90;92m [38;2;88;88;89m·[38;2;90;90;92m ˙[38;2;138;138;140m·[38;2;90;90;92m ─ [38;2;126;126;127m·[38;2;138;138;140m·[38;2;90;90;92m [38;2;88;88;89m·[38;2;69;69;71m·[38;2;90;90;92m ˙ [38;2;138;138;140m·[38;2;90;90;92m [38;2;138;138;140m·[38;2;90;90;92m [38;2;126;126;127m·[38;2;90;90;92m ˙ [38;2;138;138;140m·[38;2;90;90;92m [38;2;127;127;129m·[38;2;90;90;92m [38;2;138;138;140m·[38;2;90;90;92m ˙[38;2;88;88;89m·[38;2;90;90;92m─ [38;2;138;138;140m·[38;2;90;90;92m ˙[38;2;138;138;140m·[38;2;88;88;89m·[38;2;108;108;109m·[38;2;90;90;92m˙ [38;2;138;138;140m·[38;2;90;90;92m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;9;9;13m[38;2;75;75;77m˙ [38;2;127;127;129m·[38;2;75;75;77m [38;2;127;127;129m·[38;2;75;75;77m ˙ ─ ─ ─ ─ ˙˙ ˙ ˙ [38;2;108;108;109m·[38;2;75;75;77m [38;2;91;91;92m·[38;2;75;75;77m ─ [38;2;108;108;109m·[38;2;75;75;77m ˙[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;7;7;10m[38;2;59;59;62m ˙ ˙˙ ˙˙˙[38;2;127;127;129m·[38;2;59;59;62m [38;2;69;69;71m·[38;2;127;127;129m·[38;2;59;59;62m ˙ [38;2;127;127;129m·[38;2;59;59;62m─ ˙ ─ ˙ · ˙ ˙ ˙ ˙[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208mBjork - Joga[39m [33m★ Favorite[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'mono/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;208;208;208mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'mono/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;208;208;208mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;208;208mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;208;208;208m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;176;176;176m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;208;208mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;208;208;208mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;208;208;208m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;208;208;208m2[39m [38;2;170;180;194mLongest streak: [38;2;208;208;208m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;208;208;208m2 days[39m [38;2;170;180;194mStations listened: [38;2;208;208;208m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;208;208;208m2/371[39m [38;2;170;180;194mStations counted after: [38;2;208;208;208m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;58;58;58m [48;2;9;13;20m [48;2;118;118;118m [48;2;9;13;20m [48;2;176;176;176m [48;2;9;13;20m [48;2;208;208;208m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'receiver accent' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;116;242;138m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [1m[38;2;116;242;138mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;116;242;138mPLAYING[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138mBjork - Joga[39m [33m★ Favorite[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'rose/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;143;199mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'rose/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;143;199mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;143;199mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;143;199m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;214;92;154m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;143;199mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;143;199mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;143;199m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;143;199m2[39m [38;2;170;180;194mLongest streak: [38;2;255;143;199m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;143;199m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;143;199m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;143;199m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;143;199m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;76;34;56m [48;2;9;13;20m [48;2;143;63;106m [48;2;9;13;20m [48;2;214;92;154m [48;2;9;13;20m [48;2;255;143;199m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'ruby/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;95;135mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'ruby/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;95;135mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;95;135mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;95;135m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;201;63;104m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;95;135mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;95;135mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;95;135m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;95;135m2[39m [38;2;170;180;194mLongest streak: [38;2;255;95;135m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;95;135m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;95;135m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;95;135m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;95;135m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;76;18;48m [48;2;9;13;20m [48;2;143;39;79m [48;2;9;13;20m [48;2;201;63;104m [48;2;9;13;20m [48;2;255;95;135m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'scene' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;116;242;138m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [1m[38;2;116;242;138mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;116;242;138mPLAYING[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;4;7;20m[38;2;4;7;20m [38;2;122;138;184m˙[38;2;4;7;20m [38;2;199;210;232m│[38;2;4;7;20m [38;2;122;138;184m˙[38;2;4;7;20m [38;2;242;246;255m•[38;2;4;7;20m [38;2;199;210;232m│[38;2;4;7;20m [38;2;138;148;184m˙˙[38;2;4;7;20m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;5;9;25m[38;2;5;9;25m [38;2;199;210;232m│[38;2;5;9;25m [38;2;199;210;232m│[38;2;5;9;25m [48;2;11;17;40m[38;2;58;90;158m▀▀▀▀[48;2;5;9;25m[38;2;5;9;25m [38;2;138;148;184m˙[38;2;5;9;25m [38;2;232;236;248m••[38;2;5;9;25m [38;2;138;148;184m˙[38;2;5;9;25m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;6;11;29m[38;2;6;11;29m [48;2;143;156;192m[38;2;237;243;255m•[38;2;143;156;192m [38;2;237;243;255m•[48;2;6;11;29m[38;2;6;11;29m [38;2;199;210;232m│[38;2;6;11;29m [48;2;11;17;40m[38;2;42;74;134m│[38;2;169;212;255m·[38;2;38;55;92m│[38;2;255;209;102m·[48;2;6;11;29m[38;2;6;11;29m [38;2;138;148;184m˙[38;2;232;236;248m•[1m●[22m[38;2;6;11;29m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;6;13;34m[38;2;6;13;34m [48;2;143;156;192m[38;2;143;156;192m [38;2;237;243;255m•[48;2;6;13;34m[38;2;6;13;34m [38;2;26;36;68m│[38;2;6;13;34m [48;2;11;17;40m[38;2;237;243;255m╱◆╲[48;2;6;13;34m[38;2;6;13;34m [48;2;11;17;40m[38;2;42;74;134m│[38;2;90;111;158m▪[38;2;38;55;92m│[38;2;26;36;68m░[48;2;6;13;34m[38;2;6;13;34m [38;2;232;236;248m••[38;2;6;13;34m [38;2;122;138;184m˙[38;2;6;13;34m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;7;15;38m[38;2;7;15;38m [48;2;143;156;192m[38;2;237;243;255m•••[48;2;7;15;38m[38;2;7;15;38m [48;2;14;23;50m[38;2;38;55;92m▀▀▀▀▀[48;2;7;15;38m[38;2;7;15;38m [48;2;11;17;40m[38;2;237;243;255m╱◆╲[48;2;7;15;38m[38;2;7;15;38m [48;2;14;23;50m[38;2;38;55;92m▀▀▀▀▀▀[48;2;7;15;38m[38;2;7;15;38m [48;2;11;17;40m[38;2;26;38;69m▄[38;2;255;209;102m·[38;2;26;38;69m▄▄[48;2;7;15;38m[38;2;7;15;38m [38;2;122;138;184m˙[38;2;7;15;38m [38;2;138;148;184m˙˙˙[38;2;7;15;38m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m▀▀▀[48;2;8;17;43m[38;2;8;17;43m [38;2;237;243;255m▀[48;2;11;17;40m[38;2;199;210;232m▀[38;2;237;243;255m▀[38;2;199;210;232m▀[38;2;237;243;255m▀[38;2;199;210;232m▀[48;2;8;17;43m[38;2;237;243;255m▀[38;2;8;17;43m [38;2;122;138;184m˙[38;2;8;17;43m [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m█[48;2;8;17;43m[38;2;8;17;43m [48;2;11;17;40m[38;2;199;210;232m╱╱◆╲╲[48;2;8;17;43m[38;2;8;17;43m [38;2;122;138;184m˙[38;2;8;17;43m [48;2;14;23;50m[38;2;255;209;102m·[38;2;14;23;50m [38;2;169;212;255m•[38;2;38;55;92m│[38;2;14;23;50m [48;2;8;17;43m[38;2;8;17;43m [48;2;11;17;40m[38;2;11;17;40m [38;2;42;74;134m│[38;2;11;17;40m [38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [48;2;8;17;43m[38;2;8;17;43m [48;2;11;17;40m[38;2;38;55;92m▀▀▀[48;2;8;17;43m[38;2;8;17;43m [38;2;26;36;68m│[38;2;8;17;43m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;11;17;40m [38;2;90;111;158m▪[38;2;38;55;92m│[38;2;11;17;40m [48;2;9;19;47m[38;2;9;19;47m [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;199;210;232m╱╱╱◆╲╲╲[48;2;9;19;47m[38;2;9;19;47m [48;2;14;23;50m[38;2;38;55;92m█[38;2;90;111;158m▪[38;2;26;36;68m░[38;2;14;23;50m [38;2;38;55;92m│[38;2;255;209;102m·[38;2;14;23;50m [38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;11;17;40m [38;2;42;74;134m│[38;2;169;212;255m·[38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;38;55;92m▀▀▀▀[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;11;17;40m [38;2;255;209;102m•[38;2;38;55;92m│[38;2;255;209;102m·[38;2;11;17;40m [48;2;10;21;52m[38;2;10;21;52m [48;2;14;23;50m[38;2;38;55;92m█[38;2;27;41;73m▄▄[38;2;14;23;50m [38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;11;17;40m [38;2;199;210;232m│[38;2;38;55;92m│[38;2;26;36;68m░[38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;14;23;50m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;27;41;73m▄[38;2;169;212;255m•[38;2;27;41;73m▄[38;2;169;212;255m•[38;2;14;23;50m [38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;90;111;158m▪[38;2;26;38;69m▄[38;2;169;212;255m·[38;2;26;38;69m▄[38;2;90;111;158m▪[38;2;11;17;40m [48;2;10;21;52m[38;2;10;21;52m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;10;21;52m[38;2;26;36;68m│[38;2;10;21;52m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;36;68m░[38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;36;68m░[38;2;11;17;40m [38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m▀▀▀▀▀▀[48;2;23;27;63m[38;2;23;27;63m [48;2;17;26;53m[38;2;17;26;53m░[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;11;17;40m [38;2;90;111;158m▪[38;2;38;55;92m│[38;2;26;36;68m░[38;2;11;17;40m [38;2;38;55;92m█[48;2;23;27;63m[38;2;23;27;63m [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;255;209;102m•[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;38;69m▄▄[38;2;199;210;232m│[38;2;26;38;69m▄▄[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;14;23;50m [38;2;255;209;102m·[38;2;38;55;92m│[38;2;14;23;50m [38;2;38;55;92m█[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;90;111;158m▪[38;2;26;36;68m░[38;2;11;17;40m [38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [48;2;23;27;63m[38;2;23;27;63m [48;2;17;26;53m[38;2;17;26;53m░[48;2;23;27;63m[38;2;23;27;63m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;23;27;63m[38;2;23;27;63m [48;2;14;23;50m[38;2;14;23;50m [38;2;26;36;68m░[38;2;38;55;92m│[38;2;169;212;255m•[38;2;14;23;50m [48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;36;68m░[38;2;38;55;92m█[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;38;69m▄▄[38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;14;23;50m[38;2;38;55;92m▀▀▀[48;2;30;30;69m[38;2;30;30;69m [48;2;11;17;40m[38;2;11;17;40m [38;2;38;55;92m│[38;2;11;17;40m [48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m│[38;2;11;17;40m [38;2;255;209;102m·[38;2;38;55;92m█[48;2;30;30;69m[38;2;30;30;69m [48;2;14;23;50m[38;2;38;55;92m█[38;2;27;41;73m▄▄[38;2;14;23;50m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;255;209;102m•[38;2;38;55;92m│[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[38;2;90;111;158m·[48;2;14;23;50m[38;2;38;55;92m█[38;2;27;41;73m▄▄[38;2;26;36;68m░[38;2;27;41;73m▄[38;2;255;209;102m·[38;2;14;23;50m [38;2;38;55;92m█[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;11;17;40m[38;2;169;212;255m•[38;2;26;38;69m▄[38;2;26;36;68m░[38;2;26;38;69m▄▄[38;2;26;36;68m░[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;26;36;68m░[38;2;14;23;50m [38;2;38;55;92m│[38;2;14;23;50m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;26;36;68m░[38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m█[48;2;37;33;74m[38;2;37;33;74m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;255;209;102m·[38;2;38;55;92m│[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;26;38;69m▄[38;2;11;17;40m [38;2;26;38;69m▄[38;2;255;209;102m•[38;2;11;17;40m [38;2;26;38;69m▄[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;26;36;68m░[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░░[48;2;11;17;40m[38;2;38;55;92m█[38;2;90;111;158m▪[38;2;11;17;40m [38;2;38;55;92m│[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m│[38;2;14;23;50m [38;2;38;55;92m█[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;26;36;68m░[38;2;255;209;102m·[38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;169;212;255m•[38;2;27;41;73m▄[38;2;14;23;50m [38;2;27;41;73m▄▄[38;2;14;23;50m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;38;55;92m█[38;2;169;212;255m•[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;11;17;40m [38;2;255;180;92m░[48;2;17;26;53m[38;2;17;26;53m░[48;2;14;23;50m[38;2;255;180;92m░ [38;2;14;23;50m [48;2;44;36;80m[38;2;44;36;80m [48;2;11;17;40m[38;2;255;180;92m░ ░ ░ [38;2;11;17;40m [38;2;255;180;92m [48;2;17;26;53m[38;2;17;26;53m░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;11;17;40m[38;2;11;17;40m [38;2;255;180;92m [38;2;11;17;40m [38;2;255;180;92m ░[38;2;11;17;40m [38;2;255;180;92m░ [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;14;23;50m[38;2;255;180;92m ░[38;2;14;23;50m [48;2;17;26;53m[38;2;17;26;53m░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░░[48;2;11;17;40m[38;2;255;180;92m░[38;2;11;17;40m [38;2;255;180;92m░[38;2;11;17;40m [38;2;255;180;92m [38;2;11;17;40m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;14;23;50m [38;2;255;180;92m [38;2;14;23;50m [38;2;255;180;92m [38;2;14;23;50m [38;2;255;180;92m ░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;11;17;40m [38;2;42;74;134m│[38;2;255;180;92m░[38;2;11;17;40m [38;2;42;74;134m│[38;2;255;180;92m [38;2;11;17;40m [48;2;17;26;53m[38;2;17;26;53m░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;14;23;50m [38;2;255;180;92m ░[38;2;14;23;50m [48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;255;180;92m░ ░[48;2;17;26;53m[38;2;17;26;53m [48;2;11;17;40m[38;2;255;180;92m░ [38;2;11;17;40m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;6;11;28m[38;2;93;101;120m [38;2;255;209;102m•[38;2;255;246;220m•[38;2;93;101;120m [38;2;255;68;85m•[38;2;93;101;120m ─[38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m ˙ [38;2;255;246;220m•[38;2;255;209;102m•[38;2;93;101;120m [38;2;255;68;85m•[38;2;93;101;120m˙[38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;246;220m•[38;2;93;101;120m ˙ [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;68;85m•[38;2;90;111;158m•[38;2;93;101;120m ˙[38;2;255;209;102m•[38;2;93;101;120m ˙[38;2;140;149;168m•[38;2;255;209;102m•[38;2;255;68;85m•[38;2;169;212;255m•[38;2;93;101;120m ˙ ─[38;2;169;212;255m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m˙ [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;5;9;25m[38;2;79;86;104m˙── [38;2;154;128;68m·[38;2;129;126;119m·[38;2;79;86;104m [38;2;129;37;52m·[38;2;79;86;104m ˙[38;2;154;128;68m·[38;2;79;86;104m ─ [38;2;129;126;119m·[38;2;154;128;68m·[38;2;79;86;104m [38;2;129;37;52m·[38;2;51;64;95m·[38;2;79;86;104m ˙ [38;2;154;128;68m·[38;2;79;86;104m [38;2;154;128;68m·[38;2;79;86;104m [38;2;129;126;119m·[38;2;79;86;104m ˙ [38;2;154;128;68m·[38;2;79;86;104m [38;2;142;118;64m·[38;2;79;86;104m [38;2;154;128;68m·[38;2;79;86;104m ˙[38;2;129;37;52m·[38;2;79;86;104m─ [38;2;154;128;68m·[38;2;79;86;104m ˙[38;2;154;128;68m·[38;2;129;37;52m·[38;2;94;119;148m·[38;2;79;86;104m˙ [38;2;154;128;68m·[38;2;79;86;104m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;4;8;21m[38;2;66;72;88m˙ [38;2;142;118;64m·[38;2;66;72;88m [38;2;142;118;64m·[38;2;66;72;88m ˙ ─ ─ ─ ─ ˙˙ ˙ ˙ [38;2;94;119;148m·[38;2;66;72;88m [38;2;81;88;104m·[38;2;66;72;88m ─ [38;2;94;119;148m·[38;2;66;72;88m ˙[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;3;6;18m[38;2;52;57;72m ˙ ˙˙ ˙˙˙[38;2;142;118;64m·[38;2;52;57;72m [38;2;51;64;95m·[38;2;142;118;64m·[38;2;52;57;72m ˙ [38;2;142;118;64m·[38;2;52;57;72m─ ˙ ─ ˙ · ˙ ˙ ˙ ˙[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138mBjork - Joga[39m [33m★ Favorite[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'settings selection' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mSettings[22m[38;2;138;150;168m ────────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mSettings › Appearance[39m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [38;2;116;242;138mDisplay color[39m[22m [38;2;116;242;138mgreen[39m [49m +[48;2;7;10;15m Receiver style [38;2;116;242;138mpulse-grid[39m [49m +[48;2;7;10;15m Transparent background [38;2;116;242;138moff[39m [49m +[48;2;7;10;15m ASCII-safe display [38;2;116;242;138moff[39m [49m +[48;2;7;10;15m Reduce motion [38;2;116;242;138moff[39m [49m +[48;2;7;10;15m Mouse and trackpad scrolling [38;2;116;242;138mauto[39m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m[38;2;170;180;194mSelected[39m[22m [49m +[48;2;7;10;15m[38;2;170;180;194mPress Enter to change this setting.[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'slate/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;159;180;207mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'slate/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;159;180;207mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;159;180;207mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;159;180;207m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;124;145;171m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;159;180;207mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;159;180;207mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;159;180;207m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;159;180;207m2[39m [38;2;170;180;194mLongest streak: [38;2;159;180;207m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;159;180;207m2 days[39m [38;2;170;180;194mStations listened: [38;2;159;180;207m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;159;180;207m2/371[39m [38;2;170;180;194mStations counted after: [38;2;159;180;207m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;42;51;64m [48;2;9;13;20m [48;2;79;97;120m [48;2;9;13;20m [48;2;124;145;171m [48;2;9;13;20m [48;2;159;180;207m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'teal/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;94;234;212mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'teal/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;94;234;212mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;94;234;212mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;94;234;212m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;45;212;191m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;94;234;212mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;94;234;212mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;94;234;212m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;94;234;212m2[39m [38;2;170;180;194mLongest streak: [38;2;94;234;212m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;94;234;212m2 days[39m [38;2;170;180;194mStations listened: [38;2;94;234;212m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;94;234;212m2/371[39m [38;2;170;180;194mStations counted after: [38;2;94;234;212m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;18;63;60m [48;2;9;13;20m [48;2;31;118;108m [48;2;9;13;20m [48;2;45;212;191m [48;2;9;13;20m [48;2;94;234;212m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'violet/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;140;80;240mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'violet/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;140;80;240mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;140;80;240mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;140;80;240m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;118;63;205m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;140;80;240mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;140;80;240mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;140;80;240m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;140;80;240m2[39m [38;2;170;180;194mLongest streak: [38;2;140;80;240m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;140;80;240m2 days[39m [38;2;170;180;194mStations listened: [38;2;140;80;240m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;140;80;240m2/371[39m [38;2;170;180;194mStations counted after: [38;2;140;80;240m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;62;22;118m [48;2;9;13;20m [48;2;84;39;153m [48;2;9;13;20m [48;2;118;63;205m [48;2;9;13;20m [48;2;140;80;240m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`receiver baseline / compact / ascii > braille-wave 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + + + + :::: .: .:. +.+-. .-::-:.-+. .*: :+:::--:-+. : +: +- +- +- -+::: + .*::*. .+-:+-. + + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > gallop-fine 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + + + .:-:. + :..:#*.-+. + .-+-:-: -# + .+:- ++*-:-@@-- + %*++*-. * + **+ + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > manhattan 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + + . o o | . oo + | | | .o@ +### | | #### oo . . +# #.### ooo /*\\ |.|o . # +# #.# # . ####### . . /*\\ . . .| ||. .. . # + ... . . . . //*\\\\ . . .| . .. . + oo o .o oo o.o -o .oo + ..-.. - . . . ..- . . . .. +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > pulse-grid 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > ultracode 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + + + + + + + + + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > braille-wave 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + + ⣀⣀⣀⣀ ⢀⣀ ⢀⣀⡀ +⢀⡴⠋⠁ ⠈⠙⠒⠒⠋⠉⠈⠙⢦⡀ ⢀⡞⠉ ⠉⠳⠤⠤⠤⠖⠋⠉⠙⢦⡀ ⣀ +⠉ ⠹⣄ ⣰⠋ ⠳⣄ ⣠⠞⠉⠉⠉ + ⠈⢧⣀⣀⡼⠁ ⠈⠧⣄⣀⡴⠋⠁ + + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > gallop-fine 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + ⢀⣀⣄⣀⡀ + ⣀⢀⡀⠰⡯⠟⢀⠦⢕⠄ + ⠠⡤⢖⠋⠉⡉⠉ ⢰⣼ + ⠐⢦⣀⡤ ⠳⣆⡧⠦⠤⠦⣿⣿⠲⣂ + ⢿⡺⢴⣌⠻⠚⠁ ⢼ + ⣬⢧⡎ + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > manhattan 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + ˙ • • │ ˙ •• + │ │ │ ˙•● +▀▀▀ │ │ ▀▀▀▀ •• ░ ░ +█ █░▀▀▀ ••• ╱◆╲ │░│• ░ ▀ +█ █░█ █ ░ ▀▀▀▀▀▀▀ ░ ░ ╱◆╲ ░ ░ ░│ ││░ ░░ ░ █ + ░░░ ░ ░ ░ ░ ╱╱◆╲╲ ░ ░ ░│ ░ ░░ ░ + •• • ˙• •• •˙• ─• ˙•• + ··─·· ─ · · ˙ ··─ · · ˙ ·· +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > pulse-grid 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > ultracode 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + + + + + + + +Bjork - Joga +" +`; + +exports[`receiver baseline / full / ascii > braille-wave 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| | +| | +| | +| | +| | +| .---:--:-. :-::-. -::-: ::: | +| .+- :-:::-:. -+. -+. .-+: .---. .-+. | +| .+- +: +: .-::-: +- :: | +| :+ *. .*. -+. | +| :+ .*. +: -+. | +| .*. -+ -+. -+. | +| :-+-. :-:-. | +| | +| | +| | +| | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > gallop-fine 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| | +| .+-+- | +| +#*++%:+-#+ | +| :#:.*%: *-.+++: | +| #@@#*: .%-#*+ # | +| -%*--+-+#+.:-. :. #: . | +| :.-+*+: . :#.-* | +| :*::%#::%... :- +@%+ | +| *#*:. -* :%@: @#%%**+*%#--*@-++@- | +| *#@#+- -@% # ...--%@%: .#% | +| **%%*%+-. ++%@%%%-. #@- | +| .+*+:-*%##. *+- +@@ | +| :@+. .#.- + | +| *@%+. | +| -+-+-+-+. | +| | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > manhattan 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . | . o | .. | +| | | #### . oo . | +| o o | |.|. .o@ | +| o | /*\\ |*|. oo . | +| ooo ##### /*\\ ###### #.## . ... | +| ### ####### . # # //*\\\\ . . o| | || ### | | +| #.# *| # # ///*\\\\\\ #*. |. # |.|| #.# #### | +| # #. . o|. ### # #. ||.# #.#o#o # *#.#* . .| #.# #. # | +| #.#. . ###### . #. *|. # # o#. ###|###. #. .| # *. || . . .|o #.# #### | +| # #.### | . . .# | .# ### #. # o|.#..###.#. # . o#.##. . . # . | #.# # # .# | +| #.#.# # # .| #. . .#.# #o ##. .# .#. ..#* |.#. # | # .# ..|| #. . #o# ## #.#o# # # | +| ... . . . . . . . . . . . . ... . . . . |. | . . . .. . . | +| oo o -o o . oo o.o o o o . o o oo .o .oooo . -o o. | +| .-- .. . .. - .. .. . . . . . . . . ..- . ..... . | +| . . . . - - - - .. . . . . - . . | +| . .. .... .. . .- . - . . . . . . | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > pulse-grid 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o @ @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| . o @ o o o @ o . . . o o @ o o o o @ o o o o o o o @ @ o o o o @ o . . . . o @ o o o o o | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > ultracode 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / unicode > alpine 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ˙ •˙˙ ˙˙˙ ˙ ˙ ˙ ˙˙ ˙ • ˙ • ˙ │ +│ ·˙˙ ˙ •••••˙ ˙˙ ˙˙ ˙ ˙ ˙ ˙ · ˙ · ˙ │ +│ ˙ ••●●●•• ˙˙˙˙ ˙˙˙ ˙ ˙˙ ˙ ˙ ˙ ˙˙˙˙ ˙˙˙˙ ˙ │ +│ · · ••••• ˙ ·˙ ˙˙ ˙ ˙˙ ˙˙ ˙ ˙˙ │ +│ • · ˙˙· · • ˙ ˙˙ ˙ ˙ ˙ │ +│ • •˙ ˙ ˙ ˙ ˙ ˙ ˙ ˙ ·· · ˙ ·˙ │ +│ ˙ · ˙ ▀▀▀▀▀▀▀ ░······· ˙ ˙ ˙ ▲˙ │ +│ ·········▲ · ····················· ·▀▓▓▓▓▓▓▓▓▓▓█▀ · · ·············· · ▓ ··· │ +│ ·· ··▲▓▲········ ▲ · ·············▀▓▀•▓•▀•▀•▓•▓•▀•▀•▓•▀▓▀ ········ ···▓▓▓······▓▓▓· · │ +│ ·····˙▓▓▓▓▓ · · · ▓▓▓ · · ·· ···· · ·▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ·······▲▓▲·· ▲▓▓▓▲ │ +│ · · ·▲▓▓▓▲ ░▲▓▲ · ··· ▓░░▌╋▐░░░░●░░▌╋▐░░▓ ░ ▓▓▓▓▓ ▓▓▓▓▓ │ +│ ░ ▓▓▓▲▓▓▓░ ░ ▓▓▲▓▓ ░ ░░ ░▓░░░░▓░░░░•░░░░▓░░▓ • ▲▓▲▓▲ ▲▓▓▲▓▓▲ │ +│ · │ ·˙│˙˙˙·˙˙˙˙˙˙˙˙˙˙ · · · · │ · │ │ +│ ·· · · · ˙˙˙˙˙·˙˙˙˙˙˙˙˙˙ │ +│ · · · · · · · · · · │ +│ · · · · ·· · · · · · · · · · │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > aurora 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ··│·│····│·┊ ··│┊║│▒│█│█│║║║║││·┊·│┊·║┊·┊ ┊│ ·┊│ │ +│ ··┊·│·┊┊┊│┊│·· ··││║║█║█░█░▓▒░║││┊│┊│┊·│·· ··│· ·┊│· │ +│ ····│·│┊│║│║┊│ ┊┊║║▓░█▒█▒█▒▓║║║││┊║┊│· ✦ ┊┊┊· ·┊║┊· │ +│ ··┊·│┊│││║│║┊│· ·││▒▒█▓███▓█░░║│┊│┊┊║·· ·┊┊┊│ ·┊║║┊·· │ +│ ···┊·│┊║║║║║║┊┊ ··║░███████░║│┊··┊║│┊║· ✦ ┊┊┊║· ·│││┊││ │ +│ ···│┊║│║║║░││·· ┊┊▒▓█████▓█│┊·····│┊┊║·· ···║┊·│ ··┊┊┊║║┊··· │ +│ ··┊││▒║▒║║│┊┊· ·││███████░║┊· ··│┊┊║┊┊│·· │·┊║┊·······│║│┊││·· │ +│ ✦·┊┊║║▓║║│┊┊·· ··│║█████▓█║║┊·· ···┊·┊║┊┊║┊┊·· · ·║││││····┊║│││║┊· │ +│ ··┊║║▓││·· ││▓▓███▒█║▒│┊│┊┊··· │·┊║│║║┊│· │││║║┊┊┊·┊║││┊┊· │ +│ ··││▓││· ┊│▒▒█▓█▒█║▒│││┊│┊┊· │·┊║│║║││· ││││║┊·┊·│║┊· │ +│ ·┊┊▒││· ┊┊║▒▓▒█░█║▒││║│║┊┊·· │✦┊║│║║││· │║││║┊··┊┊│ │ +│ ✦ ┊┊║┊│·· ┊┊║░▒░▓░▓║║││║│║│┊·· │·┊║││║┊│· ✦ │║│┊│··┊┊ │ +│ ·┊·║┊│·· ┊│║░░░▓║▓│║││║┊│┊┊·· │··║┊│║┊┊· ┊║┊·· │┊ │ +│ ·····│·│··· · ┊│║║░║║│║││┊║║┊│·· ···║┊┊║·· ┊│·· │· ✦ │ +│ ··│·┊··│ │ ·· ··┊││║║│║│║┊│┊┊║┊│·· · ·║┊┊│·· ✦ ·│· │· ✦ │ +│ │·│·┊· │ ┊ ·· ··┊┊││││║│▒┊││┊║┊│·· ·║┊┊║· ✦ ✦ ·║· │· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > braille-wave 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ⢀⡤⠖⠚⠉⠓⠲⠤⣄⡀ ⣀⣠⠤⠤⣄⡀ ⣠⠤⠤⢤⣀ ⣀⣀⣀ │ +│ ⢀⡴⠋ ⠉⠓⠒⠒⠒⠚⠉⠁ ⠙⢦⡀ ⣠⠞⠁ ⠈⠓⢦⣀ ⢀⣠⠖⠋⠁ ⠈⠙⢦⡀ │ +│ ⢀⡴⠋ ⠳⡄ ⣰⠃ ⠈⠙⠒⠒⠚⠉ ⠹⣄ ⣀⣀ │ +│ ⠘⣆ ⡼⠁ ⠈⢧⡀ ⣠⠞⠁ │ +│ ⠘⢦ ⢀⡼⠁ ⠳⡄ ⣠⠞⠁ │ +│ ⠈⢳⡀ ⣠⠞ ⠙⢦⡀ ⣠⠞⠁ │ +│ ⠉⠓⠶⠚⠁ ⠉⠓⠒⠋⠁ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > cascade 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ █ ┊ ▓▒▒▒▒ │┊┊█ ·██ ▓▓· ▒░▓┊ ║ │┊┊· ▓█ █┊▓│┊│ ▒ │ ││ ·┊ ┊█┊┊┊║┊┊ ┊ ┊║░ ┊ ║ ▓┊█│█┊ │ +│ ┊┊·█▓▓▒▓ ·┊║┊│· · █ ▓█ ▒▒▓┊ ║ ║┊┊┊ █ ·┊█│┊║ ▒ ║·║│··· ┊┊┊ ┊┊┊░│┊·┊·│║▒ ┊ ║ █┊ │█┊ │ +│ ┊┊ █▓▓▓▓┊ ┊║┊║ █ ▓▒█┊ ░ ░┊┊┊ █ │ ║┊░ ▓┊║ ░║· ·┊┊│ ┊┊┊▒│┊ ┊ ║░▒ │ ░ ┊·║ ┊ │ +│ ┊┊ ████┊ ┊░┊║ · · ┊ █▓ ┊ ▒·┊░┊┊┊ ║ ░┊░┊ █┊░ ░░· ┊┊║ ┊┊┊▒║┊ ┊ ║▒▓ │ ▒ ┊ ░ ┊ │ +│ ┊┊ ·█·┊ ┊▒┊░ ·· ┊ ██·┊ ▒ ┊▒││┊· ┊┊ ·║ ▒┊▒┊ ┊▒ ▒▒ │┊║ ││┊▓░┊ ┊ ░▒█·║┊▒ ┊ ░ ┊ │ +│ · ┊┊ ┊ ┊▓│▒ · · · ┊┊·█ ┊ ▓ ┊▓│║┊ ┊┊┊ · ░ ▒┊▓┊┊ ┊▒ ▓▒ ┊ │┊░ ║║┊█▒┊·│ ▒▓█┊░┊▓ ┊ ▒·┊ │ +│ ┊┊ ·┊·┊▓│▒ · ┊┊ ┊ █ ┊▓║║┊ ┊┊┊ ▒ ▓│█┊┊ ┊▓ █▓ ┊ ║┊▒·║║┊█▒┊ │ ▒█ ┊▒┊█ ┊ ▓·┊ │ +│ ·┊│ ┊ ┊█║▓ · · ·┊┊ · │ █ ┊█░░┊ ┊┊┊ ▒ █║█┊┊ ┊█ ██ ┊·░┊▒ ░░┊ ▓┊ ║ ▓ ┊▒┊ ┊·█ │ │ +│ │║ │ │ ░ ┊┊· ░ · ┊ ▒▒│·┊┊┊ ··█ ░ ┊┊·│ ┊ ▒│█ ▒▒║·█║ ░ · ┊█┊ ·║ ░ │ +│ ║░ · · │ ║ ▒ ┊┊ ░ ··│ ▓▓║ ┊┊┊·· · ▒ │┊ │ ··┊ ▓║ ▓▓║ ║ ▒· ┊█┊ ··║ · ░ │ +│ ║▒ ║ ║ ▓ │┊ ▒ │·██║ ┊┊┊ ▓ │┊ ║ · ┊ █░ ·██░ ░ ▓ · ┊·┊ ░ · ▒ │ +│ ░▒ ░ ░ █· ││ ▓ ║ █ ░ ┊││· · ▓ ║│ ░·· ┊ █░ ▒ ▒·█ · │ │ ▒ ▓ │ +│ ▒▓ ░·▒ █ ║│ ▓ ░ ·▒ ││║ █ ░│ ░ │ ▒ ▓ ▒ █ │ ║ ▒ ▓ │ +│ ▓█ ▒ ▒ · · ░║ · █ ░ ▓ ║║░ · ░║ ▒ · ║ ▓ ▓ ▓ · ║ ║ ▓ █ │ +│ · ▓█ ·▓ ▓·· · ▒░ · ▒ ▓ ░░░ ▒░ ▓ ░··█ █ █ ░ ░ █ │ +│ █··· █ █ ·· ▒░ ·▓ █·░▒▒ ▓░ ▓ · · ░ █· ·· ·░ ▒ █ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > caustics 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓▒░░· ·░░▒▓██▓▒▒░·· ··░▒ │ +│ ▒░·· ·░░▒▓██▓▓▒░░·· ··░░▒▓ │ +│ ░·· ··░▒▒▓██▓▓▒▒░░·· ···░▒▒▓█ │ +│ ░··· ··░░▒▒▓███▓▓▒▒░░░····· ·····░░░▒▒▓██ │ +│ ░░····· ···░░░▒▒▒▓▓████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▓▓▓██ │ +│ ░░░░░░░░░░░░░░░░░░················░░░░░░░░░▒▒▒▒▒▒▓▓▓▓███████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓███ │ +│ ▒▓▓▓▓▓▓▓▓▓████████████████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓█████ │ +│ ███████▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░·······░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓███████████████████ │ +│ ▓▓▓▒▒▒░░░░···· ····░░░░▒▒▒▒▓▓▓▓█████████████████████ │ +│ ▓▒▒▒▒▒░░░░░····· ····░░░░▒▒▒▓▓▓▓████████████████████ │ +│ ▓▓▓▒▒▒▒▒▒▒▒░░░░░░······ ·····░░░░▒▒▒▒▓▓▓▓███████████████████ │ +│ ▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░░░░·······················░░░░░░▒▒▒▒▒▓▓▓▓███████████▓▓▓▓▓▓▓ │ +│ ███████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓████████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓███████████▓▓▓▓▓ │ +│ ███▓▓▒▒▒░░░····· ·····░░░░▒▒▒▓▓▓▓██████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓████████▓▓▓ │ +│ ██▓▒▒░░·· ··░░░▒▒▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█████▓ │ +│ ██▓▒░░·· ··░░▒▒▓████▓▓▒▒▒░░░░░░░░░░░░░░░░▒▒▒▓▓▓██ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > chladni 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠁⠒⠠⢀⡀ ⠑⠠ ⣀ ⣀ ⠤⠂⠁ ⢀⡀⠄⠐⠈⠁ ⠈⠁⠂⠤⢀ ⠈⠂⠄⢀⡀ │ +│ ⠁⠒⠠⢄⡀ ⠉⠉⠁ ⢀⡀⠄⠒⠈ ⠈⠁⠂⠤⢀⡀ ⠈⠉ │ +│ ⠉⠒⠠⠄⡀ ⢀⡀⠄⠒⠈⠁ ⠈⠁⠒⠤⢀ │ +│ ⠉⠉ ⠒⠠⢄ ⠉⠐⠠⠄⡀ ⢀⡀⠄⠒⠈ ⡀⠄⠒ ⠉⠉ ⠈⠉⠉ ⠂⠠⢀ ⠈⠁⠂⠠⢀ │ +│ ⠑⡀ ⠉⠐⠢⣤⣀⣀⣤⠤⠒⠈ ⠎ ⢡ ⠈⠁⠒⠤⣄⣀ │ +│ ⢠⠃ ⢀⡠⠴⠚⠛⠛⠛⠢⠄⡀ ⢄ ⡸ ⣀⠠⠔⠛⠛ │ +│ ⣀⡀⠠⠄⠂⠁ ⢀ ⠄⠂⠈ ⠉⠐⠠⠄⡀ ⠑⠠⠄ ⣀ ⣀ ⠠⠐⠊ ⣀⠠⠐⠂⠁ │ +│ ⢀⡠⠄⠂⠉ ⠉⠐⠢⠄⣀ ⣀⠠⠐⠂⠁ │ +│ ⢀⠠⠄⠂⠉ ⡠⠄⠂ ⠉ ⠉ ⠐⠂⢄ ⠈⠐⠂⠄⣀ ⡀⠠⠐ ⠁ ⢀⠠⠐⠂⠈⠉ │ +│ ⣤⣤⠔⠂⠉ ⡎ ⠑ ⠈⠐⠢⣤⣤⣤⡤⠖⠊⠁ ⢠⠃ │ +│ ⠉⠙⠒⠤⢀⡀ ⢃ ⡰ ⡀⠤⠒⠛⠉⠉⠛⠢⠄⣀ ⠈⢄ │ +│ ⠁⠂⠠⢀⡀ ⠁⠂⠠ ⣀⣀⡀ ⣀⣀ ⠤⠐⠈ ⡀⠤⠐⠈⠁ ⠈⠐⠂⠄⣀ ⠑⠂⠤ ⣀⣀ │ +│ ⠁⠒⠤⢀⡀ ⢀⡀⠤⠐⠈⠁ ⠈⠐⠂⠤⣀ │ +│ ⣀⡀ ⠈⠁⠒⠠⢀⡀ ⡀⠤⠐⠈⠁ ⢀⣀⣀ ⠈⠑⠂⠤⢀ │ +│ ⠈⠁⠐⠠⡀ ⠁⠒⠠⢀⡀ ⢀⡀⠄⠐⠈⠁ ⢀⠠⠒ ⠉ ⠉ ⠂⢄ ⠈⠁⠂⠤⢀ │ +│ ⠈⡄ ⠉⠒⢦⣤⣤⣤⠒⠈⠁ ⠃ ⢱ ⠈⠑⠲⣤⣤ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > constellation 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ✦ │ +│ · │ +│ ✦ · ✦·······✦· │ +│ ··· ·····✦ ········ ····· │ +│ · · ······ · ··✦····· ······ ·· │ +│ ·· · ······ ·· ····················✦ │ +│ · ✦ ·· ···· ······✦······· │ +│ · · · ········ ······ · │ +│ ··· · ····✦··✦· │ +│ ·· ·✦ │ +│ ✦ ··· │ +│ ····· ···· │ +│ ····· ··· │ +│ ···✦·· │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > copper-bars 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ██████████████████████████████████████████████████████████████████████████████████████████ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ██████████████████████████████████████████████████████████████████████████████████████████ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ██████████████████████████████████████████████████████████████████████████████████████████ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > crossfade 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > cube 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ +----------------+ │ +│ @@@@@@@@@@@@@@@@@@@\\ │ +│ @@@@@@@@@@@@@@@@@@@@\\ │ +│ /@@@@@@@@@@@@@@@@@@@@\\ │ +│ @======================@ │ +│ ***********************| │ +│ ********************** │ +│ *********************| │ +│ |********************| │ +│ ******************** │ +│ +******************+ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > cyclone 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠠ ⡰⠐⠊⠈⠡⠁⢠⠁⠤⠁⣈⠂⢱⢄⡆⣀ ⠐ ⠠⢀⠐⠄⢀ │ +│ ⠂⠐ ⡠⠤⡐⠎⠌⠊⡂⠒⠠ ⠄⢀⠑⠠⡊⢪⠐⠂ ⠄⠄⠐⠤ ⡂ │ +│ ⡀⠂⠁⠠⠠⢐⡔⠈⡆⢲⡠⢆⣠⢀ ⠠⠂⠄⠂⡑⠌⢈ ⠄ ⡂⠃⠔⠄⠠⠂ │ +│ ⠄⠁⢀⠠⡐⠑ ⡀⡠⠠⠄⠅⡈⠈⠓⠂⡈⠠⢤⢁ ⠠ ⠰⠈⡠⠐ ⠤ │ +│ ⡠⠈ ⠄⠐ ⠄⡀⠠ ⠂ ⠠⢀⢰⢠⢀ ⢀⢃⣃⣒ ⠂⠐⠅⢀⠄⡂⠄⠄ │ +│ ⠠ ⡂⢈⡈⠠⠈ ⠆⠌ ⠂⠡⠸⠘⠜⢔⠬⡉⢣⣀ ⣳⠸⡠⠅ ⠊⢈⡣ ⠔⡀⠄⠁⠈ │ +│ ⡁⢀⠄⠂⠠⡈⡁⠃ ⠤⠥⢦⢢⣢⠈⢿⢤⠻⣨⠌⠁⣈⣒⠁⢤⠂ ⢌⠢ ⠁⠂ │ +│ ⠁⣀⠄ ⠠ ⠆ ⡠⠢ ⢔⡴⢚⣢⣯⣋⣂⡿⢖⡡⢎⡠⢕⣢⠂ ⡪ ⢔⠕⠂⠈ ⠂ ⠂ │ +│ ⠠ ⠘⢈ ⡊⠕ ⠂⢸ ⢀⡂⢨⣟⢈⢙⡌⠳⠄⡀⡄⡄⢆⠡⢈⠰ ⢀⠘⠐⠄ ⡆ │ +│ ⠁ ⠑⠁ ⡁⡃⠄⠁⠩⡨⡑⣂⠬⡥⢂⠢⢪⣃⠒⠠ ⠈⠂⠁⡀⣀⡐⢄⠸⠄⡀⠨⠆ ⠐⠈ │ +│ ⠂ ⠢⠉⢈ ⣁⠡⠂ ⠔⡂⠐⡈⢭⣅⢂⠈⠈⠑⠲⠒⠶⡢⠹⡒⠤⠢⠁⠁⢄⠐⠁ ⠂⠈⠄ │ +│ ⠙ ⡉⡀⡤⠈⡀ ⠐⢊⠠⠂⡀⠈⠓⠦⡀⡨⡂⢧⢹⠊⠶⢈⠁⠂⠈ ⠁⡀⡈⡀⠡ │ +│ ⠈⠎⠉⡀⡰⠄⠄ ⠁⠌⢀⠆⣀ ⠁⠁ ⠂⠘ ⠃ ⠁ ⠄⠐ │ +│ ⠈⠆⠁⣊⡁⢌⢂ ⠃⢐⠅⢌ ⡂ ⡀ ⡀ ⡀ ⠄ ⠁⠈ │ +│ ⠔ ⢉⠄⡀⠌⠄⡀ ⠐ ⠌ ⠂ ⢀ ⡠⠄ │ +│ ⠠ ⠈⠠⢔⠄⢀⠃ ⡀ ⠡⠃⠈⠕⠈⠨ ⠈ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > equalizer 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ·· ██ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ██ ██ ██ ·· ·· ·· ·· ◆◆ ◆◆ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ██ ██ ██ ██ ██ ◆◆ ◆◆ ◆◆ ·· ·· ◆◆ ·· ·· ·· ·· ·· ██ ██ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ◆◆ ·· ·· ·· ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ·· ◆◆ ◆◆ │ +│ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ◆◆ ·· ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ◆◆ ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ◆◆ ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ██ ██ ██ ██ ██ ██ ██ ██ ██ ◆◆ ◆◆ ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fern 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⣠⣾⣿⠆ │ +│ ⢀⢤⢳⣿⣭⡊ │ +│ ⡄⡝⡏⡬⡑⡽⠁ │ +│ ⢀⠠⠃⡦⠗⡘⡡⡌⢗ │ +│ ⠰⠩⢦⠑⢅⢃⠒⠄⣃ │ +│ ⡾⠜⡦⠑⠎⡈⡀⠪⠔⡢ │ +│ ⢀⡦⢰⡆⠮⢞⢈⠃⡐⢪⠐⢆ │ +│ ⢀⠈⡉⡉⣔⡐⠄⠞⠈⢀⡐⢌⡅ │ +│ ⢠⡄⠠⡠⠔⡮⣄⠃⠐ ⢀⠐⠦⡑⠛⣠ │ +│ ⢸⢟⡄⠹⠁ ⡀⢁⣘⠈ ⠠⢀⣁⡀ │ +│ ⡴⢀⠐⡎⣤⠐ ⠈ ⢀⠈⠃⠤⠁⢁⠔ │ +│ ⠘⡵⠂ ⠁⢀⡇⠈ ⠤⡀⣁⡀ │ +│ ⠐⡀⠨ ⠁⠂ ⠐⡰⠠⡈⠉ │ +│ ⠈ ⠄⠠⠈ ⢀⠁ │ +│ ⠃ ⡀⠈ │ +│ ⠁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fire 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ░░░ │ +│ ░░░ │ +│ ░▄▄▄ ░░░ │ +│ ▄▄▄▄ ░░░▄ · ▄ │ +│ ░░ ▄▄░░ ▄▄▄▄ · ▄▄▄ │ +│ ░░ ░░░░░ ▄▄▄░░ ░░▄ ▄░░░ │ +│ ▄▄░ ░░░▄▄▄▄ ░░░▄▄ ░░░ ▄▄░░░ ▄▄▄ ░░░▄▄ │ +│ ░░░░░░▄▄▄▄▄▄░░░ ░▄▄▄▄▄ ▄▄▄ ▄░░░░░░ ░•░░░░▄▄▄▄▄ │ +│ ░░▄▄▄▄▄▄░░░░░░░▄▄▄▄▄▄░░░ ▄▄▄▄░░░░░░▄▄▄ ░░░░▄▄▄▄▄▄▄░ │ +│ ▄▄▄▄▄▄░░░░▒▒▒▄▄▄▄▄░░▒▒░░ ▄▄▒▒░░░░▄▄▄▄▄▄ ░░░▄▄▄▄▄▄░░░░ │ +│ ▄▄▄▄░·░░░░▄▒▒▒▄▄░░▒▒▒▒▒▒▄▄▄ ▄░▒▒▓▒▒▄▄▄▒▒▄▄░ ░▄▒▒▄▄▄░░░░░░▄ │ +│ ▄░░░░░░▄▄▒▒▒▒▒░░░▒▒▓▓▒▒▒▄▄░░▒▒▒▓▓▒▒▒▒▒▒▒▒░░░░▄▄▄▒▒▒▒░░░░░░▄▄▄ │ +│ ▀▀▀█▀▀▀▒▒▒▒▀▀█▀▀▀▀█▀▒▓▓▒▀▀▀▀█▀▀▀▀▓▒▒▒▀█▀▀▀▀█▀▀▄▄▒▒▀▀▀█▀▀▀▀█▄▄▄▄█▀▀▀ │ +│ ▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fireworks 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · · *· · ·· │ +│ ··· ✦ +· · · · │ +│ · ··· · · · ··· +· · │ +│ · ✸✦● ···· ✦ ··*· ●· │ +│ ✦· ✦●✸ ★ · x· · │ +│ · ·★· · ●✸✦ ·· +· │ +│ · ··· · · · · · · ·x │ +│ · · · · · ·· · │ +│ · ·· ···· ·· · · │ +│ · · ● ·· ···· · │ +│ · ·· · · │ +│ · ●· · · · │ +│ │ · · │ +│ │ │ +│ · · │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > flyover 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·····+·+·=:x:x:X:X:X:$:$:$:$:$:$:$:X:X:X:X:x:x:x:x:=:= │ +│ · : :·;·+·+·=:x:x:x:X:XXX:XXX:X:x:x:x:x:=·=·=·+·+·+·+·+·+· │ +│ ···+···=·=:x:x:x:x:x:x:=:=·=···+···+·················· │ +│ : · · :·;·+·+·=·=·=:=:=·=·=·=·+·+·+·;·;·;·;·;·;·;·;·;·;·;·:·: │ +│ ·· ···;···+·=·=·=·=···+···;········ │ +│ +·;·; : · :·;·+·+·+·=·=·+·+·+·;·;·;·: : : : : : · │ +│ ···+···· ·······+···+···+······· │ +│ x:=·=·+·;·: · : :·;·+·+·=·=·+·+·+·;·;·: : : : : : · · · │ +│ x:x:x:=·=·+·; : : :·;·+·=·=·=·=·+·+·;·;·:·: : : · :·;·+·=· │ +│ :x:x:x:=······ ···+···=···=···+········· ···+···+·· │ +│ =:=:x:x:=·+·;·: · · · :·;·+·=·=:=·=·+·+·+·;·;·;·: : · :·;·+·+·;·;·: │ +│ ·+·=·=·=·=···;· ··+·=·=·=·=·=·+···+···;··· ···;···;·· │ +│ ;·+·+·+·+·+·;·: · · :·;·+·=·=·=·=·+·+·+·+·;·;·: · :·;·;·;·;·: : · │ +│ ·······+······ ····+···=···+···+······· ········ │ +│ : :·;·;·;·;·; : · · : ;·+·+·+·+·+·+·+·+·+·;·: · : :·;·;·;·: : · · │ +│ ······· ·;···+···+···+···+··· ·;···;·· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fractal-tree 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ⢀⣄⣤⣤⣠⣤⣤⣤⣤⣤⣠⣤⣄⣄ │ +│ ⢀⣀⣤⣶⣾⣿⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣶⣦⣄⣀ │ +│ ⢀⣴⣿⣽⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣽⣷⣄ │ +│ ⣴⣾⡿⣿⣿⣟⢡⣿⣟⣿⠻⣿⣿⣿⣿⣻⣿⣿⣿⡿⢻⣟⣿⣧⢙⣿⣿⡿⣿⣶⡄ │ +│ ⢀⣾⣿⣿⣿⣿⣿⣽⡿⣿⣿⣿⡰⠿⡿⣿⣿⣹⣿⡿⡿⠷⣸⣿⣿⡿⣿⣽⣿⣿⣿⣿⣿⣆ │ +│ ⢰⣿⡙⣿⠛⠻⢍⣟⡿⣛⡞⠛⠺⢄⢸⡏⢏⣏⠏⣿⢀⠼⠚⠛⣞⡻⣟⣏⠽⠛⢻⡟⣹⣷ │ +│ ⢾⣟⣿⡿⠖⠒⠊⠉⠑⠒⠻⢄ ⢹⢇⠈⡏⢀⢿⠁ ⢀⠼⠓⠒⠉⠉⠒⠒⠾⣿⣟⣿⠆ │ +│ ⠁ ⠘⡄ ⡇ ⡜ ⠁ │ +│ ⠱⡀⡇⡰⠁ │ +│ ⠱⡷⠁ │ +│ ⡇ │ +│ ⡇ │ +│ ⡇ │ +│ ⡇ │ +│ ⡇ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > galaxy 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀⢀⣀⠤⠐⠂⠃⠁ ⠁ ⠄ │ +│ ⠁ ⡀ ⠚ ⡀⡤⠤⠤⠶⠲⠖⠁⠢⠒⠦⠦⠦⠄⢠⢀ ⢄ │ +│ ⠞⠋ ⢀⣠⡴⢶⠈⠁⠁ ⡀⣀⡀⣀⣀⢠ ⠁⠘⠒⠤⢀⡀ │ +│ ⠠ ⡐⠃ ⡀⢠⠺⠟⠉⠁ ⣒⢄⠤⢶⡒⢻⠛⠛⡺⠋⠁⡈⠩⠈ ⠐⠾⠦⣤⡀ ⠉⠑⠠⣀ │ +│ ⢀⠞⠁ ⢤⠟⢃ ⡂⢀⣡⢾⠍⠛⠐⢠⣄⣥⡭⣢⣴⣧⢴⢦⢤⣰⣄⣄⢐ ⡀⠌⠉⠲⣠⡀ ⠈⠁⢀ ⡀ │ +│ ⡌ ⠄ ⡖⠃⡀ ⢽⢛⠩⡀⡐⢭⡵⡾⠈⠋⢑ ⠠⠁⣠⢐⢊⠑⠜⠊⣻⡿⡬⣀⠁⡄⠈⠙⠷⡄ ⠈⠠ │ +│ ⠠ ⢰⠁ ⠐⡇⠪⢢⠄⢠⡏⢆⣀⠕⣠⡿⡈⢒ ⣨⣶⣛⡛⠌⢹⡟⠛⠷⢷⣄⠨⡀⠐⡉⢳⣔ ⠈⢣⡀ ⠘⣀ ⠄ │ +│ ⠠ ⠁⢸⡄ ⢸⡇⠰ ⠈⠼⣧⡔ ⣂⡾⣯⣖⣠⣾⣿⣿⣿⣿⣿⣧⣧⡊⠨⢐⠭⠷⣧⡁⠄ ⠸⣧⡀ ⢲ ⠈⡄ │ +│ ⠐ ⠈⢣⡀⢀ ⠙⣹⣮⣌⠐⡀⡈⠉⠳⠧⣬⣶⣈⣃⣬⡨⢿⡏⠤⠌⢈⣿⠗⠢⡤⢀⣾⠃⠃⡀⠄⣸⡅ ⡇ │ +│ ⢀ ⠈⢦ ⠦⡀ ⠐⢉⠟⢻⠤⣠⣢⠒⡄⢈⢄⢥⠩ ⠁⠄⣂⣴⣵⠟⢣⡀⢒⢀⡾⠏⠘⢃⣀⢡⡶⠁ ⡸ ⠐ ⠄ │ +│ ⠁ ⠠⡀ ⠉⠻⣦⢀⢀⡀⠅⡈⣙⢽⡓⠺⠟⠗⠶⠇⢺⢻⠏⡉⠂⢐⢌⣴⡾⡯⢡⠈⠒ ⢔⠛ ⡴ │ +│ ⠁⠱⢤⢀ ⠈⠘⠛⠃⠪⣷⣉⢈⡨⣉⣠⣤⡤⣩⣠⢠⡴⠶⡞⠊⠉ ⣠⡜⠛ ⣄⠊ ⠄ │ +│ ⠈ ⠂ ⠄ ⠑⠒⠄⣄⢀ ⠁⠙⠁⠉⠁⠉⠉ ⣀⡤⠼⠖⠊ ⢠⠤⠁⠁ │ +│ ⠠⠐⠁ ⠉⠈⠈⠒⠂⠲⠰⠨ ⠦⠠⠰⠆⠒⠚⠋⠁⠁ ⣀⠠⠒⠋ │ +│ ⠄ ⢀⢤⠄ ⠐⠊⠉⠁ ⠄ │ +│ ⠁ ⠠ ⡀ ⠈ ⠈ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > gallop-fine 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ⢀⣤⣠⣤⣄ │ +│ ⣤⢾⣓⡋⣰⣾⠉⠛⠙⢻⣤ │ +│ ⢠⣞⠃⠈⣱⡿⠃ ⢳⡆⠈⠹⠳⢦⡄ │ +│ ⠿⣿⣿⣶⠻⠉ ⠠⣽⠓⠿⢶⣆ ⢽ │ +│ ⣠⣾⠾⠙⠚⡙⠚⠳⠿⠳⠂⠘⠋⠁ ⠘⠂ ⣼⠃ ⠈ │ +│ ⣀⡀⣄⣤⣦⠞⠃ ⠁ ⠘⣳⠄⢨⡏ │ +│ ⠰⠟⠉⠉⣽⡟⠉⠰⣷⠄⢀⡀ ⢠⡆ ⢸⣿⣾⣅ │ +│ ⠻⢷⣩⣀⡀ ⣠⡏ ⠘⢿⣿⡄ ⣿⠿⣾⡿⣴⢶⡴⠾⢿⣗⣠⡌⢹⣿⠚⠛⠺⣿⠍ │ +│ ⠟⠿⣿⢾⠞⠋ ⢰⣿⣯ ⢯ ⢀⡀⢀⠙⠓⢿⣿⢿⠆ ⠈⣹⣷ │ +│ ⢼⠟⢿⣷⣜⣷⣤⣄⡀ ⠹⠞⢿⣿⣷⣷⡿⠋⠈ ⢽⣿⡆ │ +│ ⠈⠳⣇⣤⡈⠙⢹⣟⣹⢷⡀ ⠟⠛⠋ ⢩⣿⣿ │ +│ ⠘⣿⢇⡀ ⠈⠿⠂⠙ ⠛ │ +│ ⠻⣿⣷⣆⡀ │ +│ ⠓⠛⠓⠛⠙⠛⠙⠛⠁ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > golden-gate 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ●●●•·· ~ ˙·•●•●•●●●•●●●••●•··· ˙ ˙ • ˙ ˙~•••●•~●••••· ˙ ˙ │ +│ ●●••●·˙ ˙ ▀ ··▀• · · ˙ ˙ ˙ ··●●●••●·▀· ˙▀ ˙ •●●●•˙˙ ˙ ˙ ˙ │ +│ ·• ˙ ˙ ˙ ˙ ˙ ˙██████ ˙ ·●•●●●•●●██████˙ ˙ ˙ ˙ •••˙ ˙ ˙ ˙ │ +│ ˙ ˙ ˙ ˙ ••██│ ██ ˙ ˙ ˙ ˙ ˙ ˙██•│██•••••˙ ˙ ˙ ˙ ˙ │ +│ ˙ ••••│ ██████•••˙ ˙ ˙ ˙ ˙ ˙ ••██████˙│ │ •••••~ │ +│ ˙ ˙ ˙ ••••│ │ │ ██│ ██│ │•• ˙ ˙ ˙ ˙ •• │██ │██ │ │ │ │ │•••••• ˙ ˙ │ +│ · ░░•••░│·│ · │ │ ██│˙·█│·· ·••••░ ˙ ˙ ░░˙░•••• │ │·█ │██ │ │ │ │ │˙│ │ │••••• ˙ ˙ │ +│ ░░•·░•│░│░│░░░░░│░··░██░██│ │ │ │ │••••••••·•░• │ │ │ │·█████ │ │ │ │░│░│░│░│░│░│░•••••░░░ │ +│ ▀░▀·▀░▀▀····░▀▀▀▀·▀▀████░█▀▀▀▀░·▀▀▀▀·▀▀▀░▀▀░·▀░··░▀▀▀▀▀██████▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ +│ ░ · ··█░· ˙ ██╲╱██·· ˙ ~ ░ ·░·│·~~ ···· ░˙ ██╲╱██· · · · ·· ~ · │ +│ ˙ ░ · ˙░~˙ ˙ ˙· ██·•██˙··░ ░ ~·▲│▲ · ·· █░ ██• ·~ ˙ ˙ │ +│ ▒ ˙ ██╲╱██ · ˙·▄▄▄ ˙ ˙ · ██╲╱██ ˙ ~ · ˙ │ +│ ▒▒˙ · · ˙ · ··· · ˙ · · · ˙ · · ·· ˙ ˙ ˙ ˙ ·˙ ˙ │ +│ ▒▒▒░░ ˙ · · · ˙·˙ ˙· ˙ · ·˙· · ·˙·˙ · │ +│ ▒░ ▒ ░░░˙˙ ˙ · · · ··˙ · · · ·˙ ˙ ·˙· ˙ · · ˙ ·˙˙ ·· │ +│ ░ ░▒░ ▒ · ˙ ˙ ˙˙·˙ ˙ ˙ ˙ ·˙ ·· · ˙ · │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > goniometer 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀ ⡇ ⡀ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄⡧⠊ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⠈ ⡇ ⠁ │ +│ ⡇ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > harmonograph 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀ ⢀ ⢀ ⠄ ⢀ ⠂ ⠄⠐ ⠠ ⠄ ⡀⢀ │ +│ ⢀⠂⠁ ⠈⢀ ⠤ ⠠ ⠄ ⡀ ⠠ ⢀⢀ ⢀ ⠂⢀ ⠁ ⠄⡀ ⠠⠐⢱ │ +│ ⠄ ⡀⡨⠂⠂⠂⠄ ⡈ ⢀ ⡀ ⠔ ⡀⢀⢀ ⠰ ⢀⠨ ⢀ ⠠⠈ ⠐⠐ ⠁ ⠁ ⠁ ⠐ ⠠⢀ ⠄⠶⡀⡀ ⠁ ⠂ │ +│ ⠐ ⠂ ⠐ ⢰⢒⠠⠂ ⠁ ⢀ ⢀⢀ ⢉⢂⠦⢌⢠⢀ ⠁⡀⠐⠐ ⠄ ⢀ ⠄ ⠊⠈⡀⠬⠠⠢⠠⢀⠁ ⠂ ⡠⡈⠐⠂ ⠁ │ +│ ⠂ ⠂ ⠈⡀ ⠄⠡ ⠁⠨⠳⡄⣀⠠⡂⢐⠁⠂⢠⠁⢄⠠⠄⠤⢄⠨⠊⠔⠔⡄⣠ ⡀ ⠐⠁⢀⢠ ⠢⢘⣙⡠⢁⠁⠪⡠ ⡒ ⠂ ⠆ ⠈⠂ │ +│ ⠐ ⠈ ⠄ ⠂ ⠈⢀ ⠵⢃⠺⣊⠌⠢⡨ ⡂ ⠄⠁⠔ ⢀ ⠠⠋ ⡤⠄⠌⢈⡀⠨⠈⠈⠂⠄⢘ ⠡ ⢂ ⠐⡂⠡ ⢀⠈ ⠄⠁ ⠐⠄ │ +│ ⠠⠁ ⢀⠑ ⠠⠄⠃⢠ ⠰⠛⡄⠈⠄⠁⠠ ⠄ ⠂⠠⠡⡠⠈⠂⢘⠚⠄⢀ ⠂⠈⢄⠍ ⠅⠄ ⠠ ⠐⡂ ⠁⠐ ⠄⡀ │ +│ ⠢ ⡀ ⠐⢀⠠ ⠐ ⡈⠁⠠⡅⠁⠁⠏⡀⡄⠤⠈ ⠌ ⢀ ⠈ ⠄ ⠅⡀⡀⠨⠒ ⡐ ⠙ ⡔ ⢜ ⠄⠡⢁⡁ ⠂ ⠌ ⠁⠂ ⠂ ⡀ │ +│ ⠂ ⡀ ⠨ ⠆ ⠄⢂⠁ ⡐⠨⠐ ⠐ ⠠⠐⣂⠠⠆⠂⠄⡘⠐⠄⢐⢀ ⠐⠁ ⡄⠎⢁⡀⢃⣀⠆⠁⠄⠠⠂ ⠄ ⠂ ⠈ ⠐ │ +│ ⠂ ⠂⠐⠠⠂⠐ ⠡⠐⡀⠁⢂⢀⠡⢀⡀ ⠐⡈⠐⢠⡀⠨⠁⠸⠁⣠⣀⢂ ⠂⠠⢄ ⣄⢰⢀⠁⡀⠔⢈⠰⠪⡐⢬⠗⡄ ⡀⠐ ⠂ ⠐ ⠂ ⠁ ⠠ │ +│ ⠄⠐⠆ ⠁⠈⢅ ⠲⠂⢄⢈⠒⠤⡐⠵⢉⠒⠨ ⠉ ⠂ ⡄ ⠂⠑⠜⢁⣅ ⡄⠠⡠⠠ ⠂⢨⢁⠂⠄⠁⠻⡀⠐⢇⣄ ⠡ ⡀⠁ ⠂ ⠂ ⠠ │ +│ ⠄⠠⢀⠁ ⠂ ⢩⡐⡈⣀⡀⠘⠈⠈⠊⠂ ⠁ ⠐ ⠠⡀ ⠈ ⠈ ⠔ ⠄ ⠇ ⢢ ⠨⠂⠦ ⠠ ⡐ ⠂⠉⠐ ⡀ ⢡ ⢈ ⠐ ⢀ │ +│ ⠂⠠ ⡂ ⡀⠐⠁ ⠄ ⠈ ⠔ ⠐ ⠂ ⠁ ⢐ ⠰ ⠠ ⠄ ⢀⠂ ⠐ ⠈ ⠄⠐⢀ ⣀ ⡀⠈⠄⠠⠉⠁ ⢀⠄ ⡀⠁ ⡀ │ +│ ⡀⡂⠂ ⠈⠁⠁ ⠈ ⠐ ⠅ ⠤ ⢂ ⡀ ⢀ ⠂ ⠈ ⠄⠄ ⠈ ⠐ ⠂⢁ ⠠ ⠠ │ +│ ⠒⠂ ⠈ ⠁⠁ ⠌ ⢀ ⢀ ⠠ ⠁ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > hex-pulse 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ··▒░░ ░░░░ ░░░ ░░▒▒····▒▒█░░░░████░░░░████░░░░█▒▒····▒▒░░ ░░░ ░░░░ ░░▒·· │ +│ ·▒▒░░░ ░░░░ ░░░░░ ░░▒▒···▒▒▒██░░░████░░░░████░░░██▒▒▒···▒▒░░ ░░░░░ ░░░░ ░░░▒▒· │ +│ ▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒██████████████████████████▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒ │ +│ ░░···░░░░░ ░░░░░ ░░▒▒▒··▒▒▒██░░░█████░░░████░░░█████░░░██▒▒▒··▒▒▒░░ ░░░░░ ░░░░░···░░ │ +│ ░·····░░░ ░░░ ░▒▒····▒▒██░░░░███░░░░████░░░░███░░░░██▒▒····▒▒░ ░░░ ░░░·····░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒██████████████████████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ │ +│ ▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒███████████████▓▓▓▓▓▓███████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒ │ +│ ·▒▒░░ ░░░░ ░░▒▒···▒▒██░░░░████░░░██▓▓····▓▓██░░░████░░░░██▒▒···▒▒░░ ░░░░ ░░▒▒· │ +│ ▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒███████████████▓▓▓▓▓▓███████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒ │ +│ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒██████████████████████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ │ +│ ░·····░░░ ░░░ ░▒▒····▒▒██░░░░███░░░░████░░░░███░░░░██▒▒····▒▒░ ░░░ ░░░·····░ │ +│ ░░···░░░░░ ░░░░░ ░░▒▒▒··▒▒▒██░░░█████░░░████░░░█████░░░██▒▒▒··▒▒▒░░ ░░░░░ ░░░░░···░░ │ +│ ▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒██████████████████████████▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒ │ +│ ·▒▒░░░ ░░░░ ░░░░░ ░░▒▒···▒▒▒██░░░████░░░░████░░░██▒▒▒···▒▒░░ ░░░░░ ░░░░ ░░░▒▒· │ +│ ··▒░░ ░░░░ ░░░ ░░▒▒····▒▒█░░░░████░░░░████░░░░█▒▒····▒▒░░ ░░░ ░░░░ ░░▒·· │ +│ ▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒███████▒██████▒███████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > hologram 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒█▒▒▒▒▒▒░░░░ ▓ ▓ │ +│ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░ │ +│ ░▓░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░█░░░░░░ │ +│ ░░░░░░░ │ +│ ░░░░ ▓ ▓ ░░░░ │ +│ ░░░░░▒▒▒░░░░ ▓ ░░░█▒░░░░ ░░░░▒▒▒░░░░░ │ +│ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░▓ ░░▒▒▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░ │ +│ ░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒█░░▓ ░░░░░ ▓░▒▓▓▓▒░ ░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒░░ │ +│ ░░█▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░░░░▓ ░▒▓▓▓▒░ ░░░░░ ░░░▒▒█▓▓▓▓▓▓▓▒▒▒▒░░ │ +│ ░░░█▒▒█▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░█░ │ +│ ▓ ░░░░░▒▒▒░░░░ ░░░░▒░░░░ ▓ ▓ ░░░░█▒▒░░░░░ │ +│ ░░▓░ ▓░░░░ │ +│ ░░░░░░░ │ +│ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ │ +│ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░█░ │ +│ ▓▓ ▓ ░▓░░▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > julia 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ······························ │ +│ ······░░░░░░░░················░░░░░░░░······ │ +│ ················································ │ +│ ·····░░░░░░··························░░░░░░····· │ +│ ············································ │ +│ ········································ │ +│ ···································· │ +│ ·································· │ +│ ···································· │ +│ ········································ │ +│ ············································ │ +│ ·····░░░░░░··························░░░░░░····· │ +│ ················································ │ +│ ······░░░░░░░░················░░░░░░░░······ │ +│ ······························ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > kaleidoscope 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ████◆✦*∘· ··∘*✦◆◆◆✦✦*******✦◆◆◆████████◆✦****✦◆████████◆◆◆✦*******✦✦◆◆◆✦*∘·· ·∘*✦◆████ │ +│ ████◆✦*∘· ··∘**✦✦✦✦✦✦****✦✦◆◆███████◆◆◆✦*∘····∘*✦◆◆◆███████◆◆✦✦****✦✦✦✦✦✦**∘·· ·∘*✦◆████ │ +│ ███◆◆✦*∘····∘∘***✦********✦✦◆◆███◆◆◆◆✦✦**· ·**✦✦◆◆◆◆███◆◆✦✦********✦***∘∘····∘*✦◆◆███ │ +│ ███◆✦**∘∘·∘∘∘*****∘∘∘∘**✦✦◆◆◆✦✦✦✦**∘∘∘∘*∘· ·∘*∘∘∘∘**✦✦✦✦◆◆◆✦✦**∘∘∘∘*****∘∘∘·∘∘**✦◆███ │ +│ ███◆✦**∘∘∘∘*****∘∘∘··∘∘*✦◆███◆✦*∘· ··∘∘··∘∘··∘∘·· ·∘*✦◆███◆✦*∘∘··∘∘∘*****∘∘∘∘**✦◆███ │ +│ ◆◆◆◆✦***********∘·····∘*✦◆████✦*∘· ·∘***✦✦***∘· ·∘*✦████◆✦*∘·····∘***********✦◆◆◆◆ │ +│ ◆◆◆✦✦*****✦✦✦✦**∘· ··*✦◆████◆✦*· ∘*✦***✦✦***✦*∘ ·*✦◆████◆✦*·· ·∘**✦✦✦✦*****✦✦◆◆◆ │ +│ ◆◆✦✦*****✦✦◆◆✦✦*∘· ·∘*✦◆███◆✦✦*∘∘∘∘∘∘**◆◆∘∘◆◆**∘∘∘∘∘∘*✦✦◆███◆✦*∘· ·∘*✦✦◆◆✦✦*****✦✦◆◆ │ +│ ◆◆◆✦✦*****✦✦✦✦**∘· ··*✦◆████◆✦*· ∘*✦***✦✦***✦*∘ ·*✦◆████◆✦*·· ·∘**✦✦✦✦*****✦✦◆◆◆ │ +│ ◆◆◆◆✦***********∘·····∘*✦◆████✦*∘· ·∘***✦✦***∘· ·∘*✦████◆✦*∘·····∘***********✦◆◆◆◆ │ +│ ███◆✦**∘∘∘∘*****∘∘∘··∘∘*✦◆███◆✦*∘· ··∘∘··∘∘··∘∘·· ·∘*✦◆███◆✦*∘∘··∘∘∘*****∘∘∘∘**✦◆███ │ +│ ███◆✦**∘∘·∘∘∘*****∘∘∘∘**✦✦◆◆◆✦✦✦✦**∘∘∘∘*∘· ·∘*∘∘∘∘**✦✦✦✦◆◆◆✦✦**∘∘∘∘*****∘∘∘·∘∘**✦◆███ │ +│ ███◆◆✦*∘····∘∘***✦********✦✦◆◆███◆◆◆◆✦✦**· ·**✦✦◆◆◆◆███◆◆✦✦********✦***∘∘····∘*✦◆◆███ │ +│ ████◆✦*∘· ··∘**✦✦✦✦✦✦****✦✦◆◆███████◆◆◆✦*∘····∘*✦◆◆◆███████◆◆✦✦****✦✦✦✦✦✦**∘·· ·∘*✦◆████ │ +│ ████◆✦*∘· ··∘*✦◆◆◆✦✦*******✦◆◆◆████████◆✦****✦◆████████◆◆◆✦*******✦✦◆◆◆✦*∘·· ·∘*✦◆████ │ +│ █████◆*∘· ·∘**✦✦✦✦✦**∘∘∘∘∘∘∘***✦✦◆██████◆✦✦◆██████◆✦✦***∘∘∘∘∘∘∘**✦✦✦✦✦**∘· ·∘*◆█████ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > lava-lamp 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ░▒▒▒▒▒░░ │ +│ ░▒▓█████▓▒░ ░░░░░░░░░░░░░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > leds 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ 150 Hz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□] │ +│ 400 Hz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□] │ +│ 1 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□] │ +│ 2.5 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□] │ +│ 6 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ 15 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ 20 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 9 │ [■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 11 │ [■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 12 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 13 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 14 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 15 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 16 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 17 │ [■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 18 │ [■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > liftoff 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·· · · · + · + + · ++ │ +│ + · · ·+ · · · · · + · │ +│ · + · · · ·· · · ·· │ +│ · · · + ·· · ·· · + · · │ +│ ··· · + · · · ·· + ·· · · · + │ +│ · · · · · + + · · │ +│ + · · · · · · · · │ +│ · · ·· · · · │ +│ · + · + ░░░░ ▒▒▒▒▒▓·▓·▓██ ██▲\\ █ ▓▓▓ ▒·▒▒·░ ░░░░ · ·· · │ +│ · · · · · ·░·░░▒ ▒▒▓▓▓▓█ █/██\\ █ ▓·▒▒▒▒░░ · + · · │ +│ + · · + · · ·· ░░ ░▒▒▒═▓▓██|██|█ ▓ ▒▒░░ · · · · · │ +│ · ··· · ░ ░▒▒ ▓▓▓█|▓▓|▓▓▓▒▒▒░░ · · · · · · · │ +│ · · · ·░║═ ▓▓█|██| ▓▓ · + · + + + · │ +│ · ░ ░▒ ▓▓/| █|\\ ░ · + · · │ +│ · · · ░═ /_|██|_\\ ░ · │ +│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/__|__|__\\▀▀▀▀▀▀▀▀▀▀▀▀▀▀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > lissajous 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·························· ·· ●● ●●●●●●●●●●●●●●●●●●●●●●●●●● │ +│ · ●●·●●●●●●●·●●●●● • │ +│ ···· ●● ●●●●●●● ······· ·· •••• │ +│ ······ ●●●●●●● ······· •••••• │ +│ ●●●●●●··· ••••••··· │ +│ ●●●●● ······· ••••••• ····· │ +│ ●●● ······· ••••••• ··· │ +│ ● ······ •••••• · │ +│ ●●● ••••••• ●●●●●●● ··· │ +│ ●●●●● ••••••• ●●●●●●● ····· │ +│ ●●●●●●••• ●●●●●●··· │ +│ •••••• ●●●●●●● ••••••• ●●●●●● │ +│ •••• ●● ●●●●●●● ••••••• •• ●●●● │ +│ • ●●•●●●●●●●•●●●●● ● │ +│ •••••••••••••••••••••••••• •• ●● ●●●●●●●●●●●●●●●●●●●●●●●●●● │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > lorenz 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀ ⠂⠐⠐⠠ │ +│ ⠄ ⠈ ⠈ │ +│ ⢀ ⠁ ⡀⡀⣀ ⢀⠁ │ +│ ⢠⠔⠂⠂⠄⠄⡀ ⡀ ⠂ ⡀⠠ ⠂⠁ ⢑ │ +│ ⠨ ⢠⠊⣑⠴⣂⡌⡐⡠ ⡀ ⠠ ⠈ ⡀⠠ ⠁ ⡂ ⠈ │ +│ ⠡⠘⡀⡧⣫⣻⣿⣾⣮⡴⣠⢂⠄⡀ ⡀⠠ ⠂ ⠁ ⠂ ⠐ │ +│ ⠡⠈⢕⠿⣿⣿⣿⣿⣿⣿⣷⣕⣦⠅⡢⣀⡠⠐⠐ ⠁ ⠠⠈ ⠄ │ +│ ⠈⡀⠪⠻⢿⣿⣿⣯⣿⣿⣿⣿⣯⣫⡢⡁⠢⢄ ⡀⠂ ⠠ │ +│ ⠡⢈⠪⢙⠽⣻⡿⠿⠵⣣⠇⡽⠈⡇ ⠈⣆ ⠄⠁ ⡀ │ +│ ⠐⠄⡉⠒⠬⠭⠭⠤⠞⠁⢀⡇ ⠘⡄ ⡀⠄⠁ ⢀ │ +│ ⠈⠈⠒⠠⠤⠤⠤⠴⠊ ⠙⢄ ⣀⠠⠂⠁ ⠄ │ +│ ⠉⠒⠒⠒⠊⠈ ⠐ │ +│ ⢀⠠ ⠁ │ +│ ⢀⠠⠐ │ +│ ⣀⣀⣀⣀⠤⠤⠒⠈ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > manhattan 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ˙ │ ˙ • │ ˙˙ │ +│ │ │ ▀▀▀▀ ˙ •• ˙ │ +│ • • │ │·│· ˙•● │ +│ • │ ╱◆╲ │▪│░ •• ˙ │ +│ ••• ▀▀▀▀▀ ╱◆╲ ▀▀▀▀▀▀ ▄·▄▄ ˙ ˙˙˙ │ +│ ▀▀▀ ▀▀▀▀▀▀▀ ˙ █ █ ╱╱◆╲╲ ˙ · •│ │ ││ ▀▀▀ │ │ +│ █·█ ▪│ █ █ ╱╱╱◆╲╲╲ █▪░ │· █ │·││ █·█ ▀▀▀▀ │ +│ █ █░ ░ •│· █▄▄ █ █· ││░█ █·▄•▄• █ ▪▄·▄▪ ░ ░│ █░█ █░ █ │ +│ █·█░ ░ ▀▀▀▀▀▀ ░ █· ▪│░ █ █ •█░ █▄▄│▄▄█░ █· ·│ █ ▪░ ││ ░ ░ ░│• █░█ █▄▄█ │ +│ █ █░▀▀▀ │ ░ ░ ░█ │ ·█ █▄▄ █░ █ •│·█░·█▄▄░▄· █ ░ •▄░▄▄░ ░ ░ █ ░ │ █░█ █ █ ░█ │ +│ █·█░█ █ █ ·│ █░ ░ ░█·▄ ▄• ▄█░ ░█ ░█░ ░░█▪ │·█░ █ │ █ ░█ ░·││ █░ ░ █•▄ ▄▄ █░█•█ █ █ │ +│ ░░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░░░ ░ ░ ░ ░ │░ │ ░ ░ ░ ░░ ░ ░ │ +│ •• • ─• • ˙ •• •˙• • • • ˙ • • •• ˙• ˙•••• ˙ ─• •˙ │ +│ ˙── ·· · ˙· ─ ·· ·· ˙ · · · ˙ · · · ˙·─ · ˙···˙ · │ +│ ˙ · · ˙ ─ ─ ─ ─ ˙˙ ˙ ˙ · · ─ · ˙ │ +│ ˙ ˙˙ ˙˙˙· ·· ˙ ·─ ˙ ─ ˙ · ˙ ˙ ˙ ˙ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > matrix 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ # ( ) F R T 2 4 T ( H ) │ +│ S # 4 ) ( 4 2 I E 1 % * ) │ +│ G 2 * ) Q * ( % 9 ) 6 W │ +│ T 4 R E % D J * ) L X 8 │ +│ G ( E $ ( 0 % 9 O 8 M 8 │ +│ F * I @ * 6 9 ^ & Z B W │ +│ R 2 U * I 0 L ) A 7 C ) K * │ +│ 2 R 5 % ( B L ) ) ^ Z % 5 I * │ +│ A * E 0 V 6 ^ 8 ) C Q ) G # 2 Q 5 │ +│ A ) ) ) ) ) ) ( ( ( * * & & │ +│ A I 6 Y & ( R G # Q ) ^ A │ +│ A * C M Z ( $ 2 % V 7 N & │ +│ U 9 & C ( 3 Q 9 ) N 6 │ +│ @ ) V I ( 2 C K 8 K │ +│ X E ) 5 │ +│ ) R X * │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > mesh 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ────── │ +│ ────────── ───────────────── │ +│ ────────────────── ──────── ───────── ──────────────────────────── │ +│ ───────────────── ────────────────── ─────── ──────────────────────────────────────── │ +│ ──────────────────────────────────────── ────── ───────────────────────────────────── │ +│ ──────────────────────── ────── ────── ──────────────────────────────── │ +│ ──────────────────────────────────────── ───── ────────────────── ─────── ──── │ +│ ── ───── ───────────────────── ─────── ─────── ─────────────── ────── ─── │ +│ ─── ───── ───────────────────────── ────── ─────────── ────── ──── ── │ +│ ─── ────── ──────────────────────── ─────── ─────────────── ──── ─── │ +│ ── ─── ───────────────────── ──────────── ────── ─── ─── │ +│ ── ───────────────────────────────────────────── ──── ─── │ +│ ── ─────────────────────── ───── ── │ +│ ────────────── ───────────────── ──── │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > moire 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ··░▓▓▒░·░▒▓▒░··░▒▒░· ·░▒░· ·░▒▒▒░░░░░░░▒▒▒░░·· ·░▒▒▒░···░▒▓▓▒░·░▒▓▓▒░░▒▓█▓▒░░▓██▒░░ │ +│ ·░▓▓▒░·░▒▓▓░··░▒▓▒· ·░▒▒░· ·░░░·· ··░▒▓█████▓▓▒░░· ·░▒▓▒░··░▒▓▓▓░░░▒██▒░░▒▓█▓▒░▒▓█▓▒░ │ +│ ░▒▓▓▒·░▒▓▓▒··░▓▓▒···▒▓▒░· ·░▒▒· ·░▒▓▓▓▓▓▓▓▒▒░░░░░▒░░····░▓▓▓▒░░▒▓█▓▒░░▓██▒░░▒██▓░░▒██▓░ │ +│ ▒▓▓▒░░░▓▓▒░·░▓▓▒░·░▒▓▓░··░▒▒▒· ·░░░░····░░▒▒░·· ·░▒▒▒░··░▒▓▓▒░░▒▓█▓▒░▒▓█▓▒░░▓██▒░░▓██▒ │ +│ ▒█▓▒░░▒█▓▒░░▒▓▓▒░░▒▓▓▒··░▓▓▒···░▒▒░· ·░▒▓███▓▒░·· ··▒▓▓▒░░░▓██▒░░▒██▓░░▒██▓░░▒██▓░░▒██▓ │ +│ ▓█▓░░▒▓█▓░░▒▓█▓░░▒▓█▓░·░▓▓▓░·░▒▓▒░· ·░░▒▓▓██▓▒░░░▒▒▒░·░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓ │ +│ ▓█▓░░▒▓█▓░░▒██▒░░▒██▒░░▒▓▓▒░░▒▓▓▒··░▒▒▒░··░░▒░· ·░▓▓▓▒░░▒██▓░░▒██▓░░▒██▓░░▓██▒░░▓██▒░░▓██ │ +│ ██▓░░▒██▒░░▓██▒░░▓█▓▒░░▓█▓▒░░▓█▓▒░░▓▓▓░· ░▒░· ·░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒██▓░░▒██▓░░▒██▒░░▓██ │ +│ ██▓░░▒██▒░░▓██▒░░▓██▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░· ·░▒░ ·░▓▓▒░░▒▓█▓░░▒▓█▓░░▒▓█▓░░▒██▒░░▒██▒░░▓██ │ +│ ▓█▓░░▒██▓░░▒██▓░░▒██▓░░▓██▒░░▒██▓░░▒▓▓▓▒· ·░▒▒░░░░▒▒░··░▒▓▓▒░░▒▓▓▒░░▒█▓▒░░▒█▓▒░░▓█▓▒░░▓█▓ │ +│ ▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▓█▓▒░··░░▒▒▒▒▓████▓▒░· ·░▒▒░··░▓▓▒░·░▓▓▓░░░▓█▓░░░▓█▓░░▒▓█▓ │ +│ ▓██▒░░▓██▒░░▓██▒░░▒██▓░░▒▓█▓▒░░▒▓▓▒░· ··░▒▓▓▓▓▒░· ·░▒░· ·░▒▓▒░·░▒▓▓▒·░▒▓▓▒░░▒▓▓▒░░▒▓▓▒ │ +│ ▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▓██▒░░▒▓▓▓▒··░░▒▒▒░· ··░░▒░░░░░░▒▒░· ░▒▒▒···░▓▓▒··░▓▓▒░·░▓▓▓░·░▓▓▓░ │ +│ ░▓██▒░░▒██▓░░▒▓█▓▒░░▒▓▓▒░·░▒▓▓▒░· ··░░░▒▒▒▒▓▓▓████▓▓▒░· ·░▒░░ ·░▒▒░··░▒▓▒░·░▒▓▓▒·░▒▓▓▒░ │ +│ ░▒▓█▓▒░░▓██▒░░▒▓█▓░░░▒▓▓▒░··░▒▒▒░· ·░░▒▓▓███▓▓▒░· ··░░░· ·░▒▒░· ·▒▓▒░··░▓▓▒··░▓▓▒░· │ +│ ░░▒▓█▓▒░░▓█▓▒░░▒▓▓▓▒··░▒▓▓▒· ·░░▒░░·· ··░░░░▒░░░░▒▒▒▒▒░· ░░▒░· ·▒▒▒···░▒▓▒··░▒▓▒░·░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > motion-contour 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ •• •• •• •• │ +│ • ····· •••• ·········· •••• ····· • │ +│ •· ∙∙ ∙∙∙ · ∙∙∙∙∙∙∙∙∙∙ · ∙∙∙ ∙∙ ·• │ +│ •· ∙·· ···· ·· ···· ··∙ ·• │ +│ ••··∙· • ∙ • ∙ ∙ • ∙ • ·∙··•• │ +│ ••··∙∙ · • •••••••••• • · ∙∙··•• │ +│ •• ·· ∙∙ ·· • ∙∙•• ••∙∙ • ·· ∙∙ ·· •• │ +│ •• ··· ∙∙ ·· • ∙ • • ∙ • ·· ∙∙ ··· •• │ +│ • · ∙ ·· ••••∙ ∙•••• ·· ∙ · • │ +│ ••·· ∙∙∙∙∙∙∙ · • • · ∙∙∙∙∙∙∙ ··•• │ +│ •••••••••·····∙ ∙·····••••••••• │ +│ ••·∙ ·· ∙·•• │ +│ · ∙∙ · │ +│ • ·· • │ +│ •••• │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > nebula 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·····░░░░░░░▒▒▒▒▒▒▒▒▒▒▒✦▒▒▒░░░░░░░······ │ +│ ✦······░░░░✦░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░·····✦ │ +│ ·······░░░░░░░░░░░░░░░░░░░░░░░░░░░······ ··· │ +│ ···········░░░░░░░░░░░░░░░░░░░░░········ ··· │ +│ ··················░░░░░░░░············ ✦ ·· │ +│ ✦ ································ ✦ │ +│ · ·························· │ +│ ·· ····························· ✦ │ +│ ··· ··········░░░░░░░░░░░░░░······✦·· │ +│ ··· ✦ ··········░░░░░✦░░░░░░░░░░░········ │ +│ ···········░░░░░░░░░░░░░░░░░········ │ +│ ····················· ························░░░░░░░░░·········· │ +│ ·································································· │ +│ ··········░░░░░░░░░✦░✦░░░░░········✦······················ │ +│ ······░░░░░░░░░░░░░░░░░░░░░░░░░░··········· ✦ │ +│ ·····░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░······· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > neural-net 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ⢠⣤⣀⣀⣀⣀⣀⡠⠤⠤⠤⠤⠤⠤⠒⠒⠒⣒⢶⡾⠿⢶⣶⣶⠶⣒⣒⡒⠒⠒⠒⠒⣒⣒⡲⢶⣶⡶⠾⠿⠶⡶⣒⡒⠒⠒⠒⠒⠒⠒⠒⠤⠤⠤⣤⣤⣤⣴⣶⠤⢄⣀⣀⣀⣀⡀ │ +│ ⠈⠉⠻⣿⣫⠶⣒⡒⠒⠤⠤⠤⢤⣠⣔⣊⣩⢒⠕⢁⣀ ⢙⣳⣫⣶⠦⢾⡟⠋⠛⣛⠦⢶⣮⣓⣉ ⢀⣀ ⠈⣑⣪⣉⠵⠶⠤⢖⣒⠒⣉⡩⠝⠒⠉⡱⠁ ⠈⠉⠉⠉⠉⠑⠒⠒⠒⠒⠒⢴⣶ │ +│ ⠈⢷⡫⡒⢎⣉⠶⠲⢍⣁⢀⣔⡪⡴⢭⣻⠟⠛⠻⠿⣭⠭⢯⣝⣯⢝⠛⢛⢭⣟⣉⠭⠭⠝⠛⠛⠛⠛⠛⠒⠒⠤⢵⣦⢤⣔⣊⣉⣑⠒⠤⣤⣊ │ +│ ⣀⡠⣝⣮⡵⢬⡓⠲⣮⡛⠉⣱⠾⢶⢕⣁⢀⣀⣀⣠⣔⡫⠽⠦⣗⣿⡋⠻⢗⡢⠬⠭⣒⣤⣀⣀⣀⣀⡠⠤⠒⠉⠁ ⠉⠢⢤⡀⢉⠝⠉⠉⢛⡿⠿ │ +│ ⠸⠿⢿⣿⡿⣒⣒⡻⣲⡞⠻⣶⢞⠟⢭⣝⣉⠭⡽⠟⠛⠉⠉⠉⡩⠝⠋⠉⠉⠉⠻⣯⢍⣛⠭⡉⠉⣉⠝⠛ ⡸⠓⢤⣠⠶⠋ │ +│ ⠉⠚⠵⣲⢮⣩⡻⣻⣖⢷⠶⡯⢭⣝⣚⣤⣀⣀⡠⠔⠉ ⠑⢄⠉⡺⠿⢤⣀⣀ ⢠⠊⡠⢒⠕⠉⠑⠤⣀⣀ ⢀⣀ │ +│ ⢠⣤⣾⣿⣮⣓⣒⣲⣫⣋⣩⣶⣽⣄⠙⢍⠒⠬⢦⣄⣀ ⠉⠢⢄⡀ ⣠⡲⠕⣒⠭⠊⠉⠒⢄⣀ ⠈⠒⠤⣀⡴⠋⡠⣊⡡⠔⣒⠭⠊⠁ ⢀⡠⠔⠊ │ +│ ⠈⠉ ⢀⡴⢋⠔⠊ ⠈⠻⡭⣙⢍⠉⠙⠛⠛⠲⢖⡢⢤⡬⣖⣶⡋⣠⣔⡪⠤⠤⣒⠶⠚⠛⠛⠛⠫⣭⣕⢚⡾⡷⠺⢟⡢⠴⠮⠤⠤⠤⢄⣀⣠⣤⡀ ⢀⠤⠒⠁ │ +│ ⣴⡫⠊⠁ ⠈⢆⠑⠳⢄⣀⣠⣤⠶⠭⡽⢖⡷⢝⡉⣙⠵⣖⠚⠭⠤⢄⣀⣀⣀⣤⣮⣓⣝⠽⢿⣶⠴⢟⡛⠒⢶⣉⠭⠝⠛⠉⠉⠉⠈⠉⠉⠑⠒⡲⠴⠮⢅⣀⣀ │ +│ ⡠⠞⠁ ⠑⢄⠘⠛⠉⢙⣿⡻⣿⡭⣝⣋⡉⣉⣛⡭⣽⡿⣛⠛⠉⠙⠛⠉⣩⢞⡳⠒⢥⣤⠤⣔⣚⣉⠓⠪⢕⣢⢄ ⢀⠤⠒⠉ ⢉⣉⣑⣒⣒⣢⢴⣶ │ +│ ⠸⠿ ⢣⣤⣴⣿⣷⣭⣒⣒⣊⣉⣉⣉⣉⣒⣒⣪⣵⣿⣦⣤⣤⣿⣯⣓⣊⣉⣁⣀⣀⣀⣀⣀⠭⠭⠭⠶⠿⠿⢶⣶⠮⠕⠒⠒⠒⠒⠊⠉⠉⠉⠉⠁ │ +│ ⠈⠉ ⠈⠉ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > newton 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ███████████████████████████████████████████████████████████▓▓▓▒░▓▓▓▓██▓▓▓░▒▒▒▓▓▓▓▓████████ │ +│ ████████████████████████████████████████████████████████▓▓▓▒▒▒▒░▒▒░░▒▓▓▓▓▓▓▓██████████████ │ +│ ██████████████████████████████████████████████████████▓▒·▓▓▓▓▓▓░▓▓▓▒▓▓████████████████████ │ +│ █████████████████████████████████████████████████████▓▒▒▓█████▓▓▒▓▓███████████████████████ │ +│ ███████████████████████████████████████████████▓▒▓▓▓▓▒░░▒▒▓▓▓█████████████████████████████ │ +│ █████████████████████████████████████████████▒▓▒██████▓▓██▒▓██████████████████████████████ │ +│ ▓▓▓▓▓█████▓▓███████████████████████████████▓▓▒▓███████▒▓▓█████████████████████████████████ │ +│ ▓▓▒░▒▒▒▒▒▓▓▓▓▒▓██▓▓▒▒▓▓▓░▓▓█▓▓██████▓▓▓▒▒▒▓▒▒▒▒▓▒▒▒▓▓█████████████████████████████████████ │ +│ ▓▓▓▓▓█████▓▓███████████████████████████████▓▓▒▓███████▒▓▓█████████████████████████████████ │ +│ █████████████████████████████████████████████▒▓▒██████▓▓██▒▓██████████████████████████████ │ +│ ███████████████████████████████████████████████▓▒▓▓▓▓▒░░▒▒▓▓▓█████████████████████████████ │ +│ █████████████████████████████████████████████████████▓▒▒▓█████▓▓▒▓▓███████████████████████ │ +│ ██████████████████████████████████████████████████████▓▒·▓▓▓▓▓▓░▓▓▓▒▓▓████████████████████ │ +│ ████████████████████████████████████████████████████████▓▓▓▒▒▒▒░▒▒░░▒▓▓▓▓▓▓▓██████████████ │ +│ ███████████████████████████████████████████████████████████▓▓▓▒░▓▓▓▓██▓▓▓░▒▒▒▓▓▓▓▓████████ │ +│ ███████████████████████████████████████████████████████████▓▓▓▒▓▒▓▓▓██▓▓▓▒▓▓▓▓▒▒▓▓▓▓▓▓▓▓██ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ordered-dither 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ wOkwd0qCLvYxvfvjufcrcrYvUz0Cm0dZqQZYXfr1(]|1ffJCddW*Wka0Cjf[[<]-(|XUpp&#&hoOQnn))-)[\\|zc0L │ +│ ?(}t/vrXvUxv/t-?li^I:~<)\\zcOQqJLnx]-;I "'!l})vvQLwJLuu{{<~,!;~i[}f\\uxXrvjn|t)\\[)])-}-{+[-) │ +│ [{xnQCZJQux[[I; ^ :,?]fjJYQXCnx1)~~:i:>l]-){j/x/ntj(j)\\}|[1_}+-i->-~1}t\\cuYuUxx1)<>^;',^~+ │ +│ xCCwCOYUff{1~?~]+{}t(rfvjvrcjvjvtx/r1|[1+]+}_)1xjYYZCOUCjj}[l!,!:+-tfJCdZdOZun1}l!"l,~_\\\\z │ +│ 0JmQwQpZp0p0ZYLvctx\\j\\vuLCkbWo%o*mmzu)|]{?ttXUkh8W@W&ppYc|/[1]t/zXqwok&aowdCCuzjntcxzuCYQU │ +│ rJUpb&#BM&pqUX||]1-|\\zXqb8M@&%bbCJtf1|[//zvOZad*k*wdQOzUuXrcnYnYzLzLJZCwObmbwk0mJCxn\\j)f/c │ +│ JLdpMhomwXv(\\?[~{[/\\zcQCq0mCOXUxzjx\\x/r\\x/r/ujcnJY0Cw0mJ0cc\\t[}+}?||zXmm*h*pbCY//--l_<}{uc │ +│ (rtn\\j|t}([)_[_[<->-i_~}-()xtvnXjx||~~:I "'!I]{nxCJOXUrf-+,, . :,-[rjUY0zYrr]]!>^;"iI_-|1f │ +│ /YUpp*dhZZnr{}i}1vzOOkqdLQnr}1~_!?+{{xrXcQYLcJuvtn\\t1t)\\{/1\\{t(j/cnUz0UQzJxr{)+_l_>[[ │ +│ )vnJXCnu||~~:l\`;,_i_l+>]+11j/vxXjn||~~,; \`.I:?[rrJUOXUrf-+,, ,"+?f │ +│ ujvjcrufufx|r(\\[(?[~[_}]t/vuQJ0UQvu))~<"l,ii1)ucmOdOqUX\\|~<"I"!l}{rxQCZCZXXtf[}-~1[\\(nf │ +│ (nuQCppopkZZvu))~_>]-/fCCbk&a#ppXc||+-<[-\\tYXmmaqkmwYYrn(/)t)ftvjzvCcCYQXQU0XLXJxvtr1|{\\{f │ +│ ZwWW@B@&Wwwzv|f1/(vvQQakWo8aowdLQcJuXnUcJX0JOCq0pZkqhqaqdQmYYrvtr/cvQQaa%W@&8bbCY/f{(}ffUJ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > phosphene 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·░░░░░░░····░░▒▓████████▓▓▓▓▓▓▓▒▒▒▒▒░░░░▒░░░░░░░░░············░░░░▓▓▓▓▓▓▓▓▓▒▒▒▒░░░▒▒▒░▒▒▒░ │ +│ ░░░····░░░▓▓▓▓▓█▓▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒········░░░░░░░░░░░░░░░·░░░▒▒▒▒▓███████▒▒▒░░░·░░░ │ +│ ···░░░▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░··░░░░▒▒▒▒▓▓▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▓▓▓█████▓▒▒░░·· │ +│ ░▒▒▓████▓▒▒▒░░░▒░░░░░····░░░░▓▓▓▓███████████▓▓▓▓██████████████████▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓███▓▓▒░░ │ +│ ▓█████▓▓▒▒▒▓▒▒▒▒▒░░░▒▒▒▓▓▓▓▒▒▒▒░░░░▒▒▒▒▓▓▓█▓▓▓▒▒░░░░░▒▒▒▓▓██████████▓▓▓▒▒▓▒▒▒▒▒░▒▒▒▒▓▓▓▓▓▓ │ +│ ████▓▓▓▒▒▓▓▓▓▓▓▓▓▓▓▓█████▓▒░░···░░░░░▒▓▓███▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▓██████▒▒▒░░▒░░░░▒░░░▒▒▓▓▓▓ │ +│ ███▓▒▒▒▒▓▓▓▓▓▓▓█████████▓▓▒░░░░░░░░·░░▒▓▓████▓░░░▒▒▒▒▒░▒░░░░░····░▒▒▒▓▒▒░░░··░░░░░░░░▒▒███ │ +│ ▓▓▒▒░░░▒▒▒▒▒▒▒▓▓███████▓▓▒▒▒▒▒▒▒▒░▒▒▓████████▒██████▓▒▒░░░░░░░░··░░░▓▓▓▓▒░░░··░░░░▒░▒▒▓▓██ │ +│ ▒▒▒·····░░░░░··░░▒▓█████▓▒▒▒░▒▒▒▓▓▓▓▓████▓▓▓███▓▒▒░░··░▒▒▒▓▓▓▒▓▓▓███████▓▒▒░░▒▒▒▒▒▒▒▓▓████ │ +│ ▒▒▒▒░····░░░░░░··░░▒███████▓▓▓▓▓█▓▓▓▓▒▒░░░░··░░▒▒▒▒░░░░░░▒░░▒▒▓▓██████▒▒▒░░░░░░▒▒░▒▒▓█████ │ +│ ▓▓▓▓▓▓░░···░░░░░░·░░▒▒▓████████▓▓▒▒░░·····░░░░▒▒░░▒▒▒▒▒▒▒▓▓███████▓▒░░░··░░░░░░░░▒▒▓██████ │ +│ ▒▒▓█████▓▒▒▒░░░░░░░░▒░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░▒▒▒▓▓█████████████▓▒▒░░···░░░░░░····░▒▒▓████▓▓▓ │ +│ ▒▒▓▓▓███████▓▓▓▒▒▒░▒▒▒▒▒░░░·░░░░░░░░░░░░▒▓▓██████████████▓▓▒▒░░░░░░░░░░······░▒▓▓▓▓▓▒▒▒▒▒░ │ +│ ▒▒▓▒▒▓▓████████████▓▓▒▒▒░░▒▒░░░░░░░░░░░░▒░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒░░········░▒▒▒▒▒▒▒░░░░░░░░░░ │ +│ ░▒▒▒▒▓▓▓▒▓▓▓████████████▓▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░···░░░░░░░· │ +│ ▒▒▒░░▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▓▓▓█████████▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓█████████████▓▓▓▒▒░░░░░···░░░░░░░░·░░░░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > phyllotaxis 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀⢀ ⢀ │ +│ ⠠⠠ ⠌⠌⡀⠂⠌⡠⢈⠠⢐⠄⠄ │ +│ ⢀ ⠅⠅⠌⠨⠨⢀⠂⢌⠂⠄⠢⠐⠄⢂⠡⠡⠐ │ +│ ⢀⠨⠐⡀⠅⠅⠅⠅⡂⠂⠌⠂⡈⠄⠡⡈⠌⠄⢂⠡⠡⠨⠈⠄ │ +│ ⠨⠐⡀⠅⢂⢂⢈⢈⠐⠠⢁⢂⠑⠈⠌⠄⠄⠡⠨⢐ ⡂⡁⠅⡁⡂ │ +│ ⠅⠡⠐⡈⠄⠂⡐⡐⠠⢑⢀⠂⡐⠡⠂⡁⠌⠌⢈⠠⢀⠂⡂⡂⡂⠄⠄ │ +│ ⠠⢈⢂⠑⢈⠄⡂⠅⠄⠄⠨⠐⡐⡐⢀⠡⠡⠐⡈⡀⡂⢊⠂⡐⢐ ⠄⠅⡂⠄ │ +│ ⠠⢀⠂⠔⠠⢁⠠⢐⠈⢌⠂⡁⠄⢂⠂⠌⠌⡐⢀⠂⢄⠡⠈⠄⢂⠌⡐⠠⢐ │ +│ ⠈⢐⠠⠡⢁⠂⡂⡐⠠⢂⠂⠂⢂⠠⡐⠠⢐⢐⢐⠐⡀⠂⡂⡁⠄⠢⠐⠄⠢ │ +│ ⠠⡈⢐⢐⢐⢐ ⡁⠂⠌⠨⠠⢂⠄⢂⠂⡐⡐⠠⠠⠁⠄⠂⢊⠠⡈⠌⠄ │ +│ ⠐⠐⠠⠠⠐⡀⡂⠅⠅⢁⠨⠠⡐⠠⠂⠔⠠⠨⢈⠈⡈⠌⠄⢂⢐⠈ │ +│ ⠈⠌⠌⠄⠄⢂⠡⠈⠄⠡⠡⢀⠢⠁⠂⠅⡁⡂⡂⡂⢁⠡⢐ │ +│ ⠐⡈⠌⠄⠂⡑⢈⠠⡁⢂⠐⠌⢈⢐⢀⢂⠂⡂⡐⠈ │ +│ ⠁⠑⢈⠠⢐⠠⢁⠊⠠⢂⢂⠂⠐ │ +│ ⠈ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > pixel-crush 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓▓▓▓████████████████████████████████████████████▓▓▓▓████▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓ │ +│ ▓▓▓▓████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓▓▓▓▓████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓▓▓▓▓████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒ │ +│ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░ │ +│ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▓▓████████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░ │ +│ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▓▓████████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░ │ +│ ▒▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░ │ +│ ▒▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░ │ +│ ░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓████▓▓▓▓▒▒▒▒▓▓▓▓░░░░░░░░░░░░ ░░░░ ░░░░ │ +│ ░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓████▓▓▓▓▒▒▒▒▓▓▓▓░░░░░░░░░░░░ ░░░░ ░░░░ │ +│ ▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░▒▒ │ +│ ▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > planetarium 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ······░░░░░░░░░░░░░░░░░░░░░··························▓█▓······+ * ˙ ●· ····+·· │ +│ ·····░░░░░░░░░░░░░░░░░··░················ ··˙· ·· · · ····· ···· ·· · ·˙······· │ +│ ····░░░░░░░░░░░········░········ ·· ··········* ···+···* ······· ·· ···· ·· · · ·*···░░░· │ +│ ····░░+░░░·········░·········· ······+···· ····*···················▓███▓════····░·░░░░░░░░ │ +│ ··········░···░·····▒▓███▓▒················▒▒▒·········*······════▓█████▓════·······░░˙˙░˙ │ +│ ···················▓███·█▓··········+·▒·+▓▒·▒▒··▓······+····═════·▓·██▓════··········░░·░ │ +│ ·· ·· ······+··▒▓███▓▒·++···· ●···▒▓▒▓▓▓▓▓▓▒▓▒······*·····═════···············*····░░ │ +│ · · ····+··░·····░······· ·· ·▒▓▒▒▓▓███▓▓▒▓▒·+░··░······✦·· ················+····· │ +│ ·······█·█▓···░···░····░░···●····+·█·█·▓▓▒▓▓▓▓▓▓▒▓▓▒········+·······················*····· │ +│ · ····▒▓▒·░···░░····░·············+·····▓▓···▒▓·+···+········˙········▒▓▒·══··········· │ +│ ·········+···*·····+···········+····▒▓·▒·█·█·+●·····✦·······══·▓·██▓·══······ │ +│ · ····*+········· ······ ·······*····+·······═·˙·· ········═══·▒▓▒······ + │ +│ ···········░*···········˙···· ··+ ··········· ··*··+·· ·˙ ·· ···· ········ · + │ +│ · ·*·*································ · · ·· ··✦··· · ······· ·· ·· ˙ ● ˙ ˙ · │ +│ ·˙✦ ·· ··˙ · ˙˙ ·✦·· · · ·· · · · ·✦·*· · + · ˙ · │ +│ +· ˙˙ * · · · · ˙ ·˙ * ˙ + │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > pulse-grid 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ · ∘ ● ◉ ◉ ◉ ● ∘ · · · ∘ • ● ◉ ◉ ◉ ◉ ● • • ∘ ∘ ∘ ∘ • ● ● ◉ ◉ ◉ ◉ ● • · · · · • ● ◉ ◉ ◉ • ∘ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > radial-eq 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ⠐⣄⢤ │ +│ ⠙⣌⢎⢧⢧⢠⢀ ⡄⡠ │ +│ ⠘⢮⣫⣣⣯⣟⣾⣱⡇⡄ ⣠⣼⣜⡝⠁ │ +│ ⠐⢕⢵⡽⣻⣿⣿⣇⣷ ⣶⣟⢯⡮⡊ │ +│ ⠠⣜⣒⠭⣗⡬⣶⣕⡷⣵⣿⣿⣿⣿⠿⠿⣿⣿⣿⣿⣮⢄⣀⣀ │ +│ ⢒⡪⠭⢖⣻⣽⣿⣾⣿⣿⡟⠉ ⠉⢻⣿⣿⣷⣿⣯⣗⡀ │ +│ ⠐⠫⠭⠭⠵⠾⠿⢿⣿⡟ ⢻⣿⡿⠿⠷⠮⠭⠭⠔⠒ │ +│ ⠉⠉⠈⢉⣿⣿⣧⣀ ⣀⣼⣿⣿⡿⣿⣟⣯⠵⣒⡪⠭⢍⡁ │ +│ ⢀⡠⡷⡻⣿⣿⣿⣿⣶⣶⣿⣿⣿⣿⢟⢾⢝⡿⣚⠽⠒⠉⠉ │ +│ ⢀⡴⡪⡪⡺⠳⢽⣿⣿⠏⣿⣿⣿⣯⣞⢗⢕⢕⢯⡺⠁ │ +│ ⠊⠘⠁ ⠁⠁ ⢸⢏⡿⣽⣻⢫⡋⠁⠁⠁ │ +│ ⠈⢸⢱⢣⢳⢳⡀ │ +│ ⠘⠈⡎⣇⢧ │ +│ ⠑⠸⠈ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ribbon 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒▒▒▓▓▓▓▓▓████████ │ +│ ▒▒▒▒▓▓▓▓████████████ │ +│ ▒▒▒▓▓▓▓███████████████ │ +│ ▒▒▒▓▓▓▓████████████▓▓▓▓▓ │ +│ ▒▒▒▓▓▓██████████▓▓▓▓▓▓▓▒▒▒ │ +│ ▒▒ ▒▒▒▓▓▓█████████▓▓▓▓▓▒▒▒▒▒▒▒▒ │ +│ ▓▓▒▒▒▒▒ ▒▒▓▓▓█████████▓▓▓▓▓▒▒▒▒▒▒▒ │ +│ ██▓▓▓▒▒▒▒▒▒▒▒ ▒▒▒▓▓██████████▓▓▓▓▓▒▒▒▒▒▒ │ +│ ▓██████▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓██████████████████▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒ │ +│ ▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████████▓▓▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▓▓████████████▓▓▓▓▓█████████████▓▓▒▒ │ +│ ▒▒▓▓████████████████████████▓▓▓▒▒ │ +│ ▒▒▓▓▓███████████████████▓▓▓▒▒▒ │ +│ ▒▒▓▓▓▓▓███████████▓▓▓▓▒▒▒ │ +│ ▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒ │ +│ ▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ripple-tank 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⡏ ⢠⣿⠃ ⢠⣾⠁ ⠸⠿⠛ ⠰⣿⡷ ⠈⠉⠁ ⠿⠟ ⣴⣦⡀ ⢀⣀ ⢀ ⠘ │ +│ ⢀⣿⡏ ⢠⣿⠃ ⢀⣄ ⣀⡀ ⢀⣀ ⣿⣿ ⠉⠛⠁ ⠈⢿⣷ ⠸⣿⡄ │ +│ ⣼⡿ ⢀⣿⡏ ⢠⣿⠃ ⢀⡀ ⣿⡗ ⢸⣿ ⢀⣤⡀ ⢠⣤⡀ ⠹⣿⡀ │ +│ ⢠⣿⠇ ⣼⡿ ⢠⣿⡏ ⢀⣤ ⠐⢿⠿ ⠈⠁ ⢸⣿⠃ ⢠⣤⡀ ⢿⣷ ⠙⠃ │ +│ ⢸⣿ ⢠⣿⠇ ⣼⡿ ⢠⣿⡏ ⣰⣿⠆ ⠘⠛⠃ ⣶⣄ ⠛⠃ │ +│ ⣿⡏ ⢸⣿ ⢀⣿⡇ ⣾⡿ ⢀⡀ ⢀⣿⡄ ⣰⡇ ⣀ ⠉⠉ ⠹⣿ │ +│ ⣿⡇ ⣸⣿ ⢸⣿ ⣿⡇ ⢸⠟ ⢸⣿⠁ ⣿⡇ ⢰⣿ ⣠⡀ ⢀⣠⡀ │ +│ ⣿⡇ ⢹⡟ ⢸⣿ ⢿⡇ ⣾⡟ ⢰⣿⠃ ⣿⡏ ⢰⣿⠁ ⢀⡀ ⠘⠿⠟ ⢠⡀ │ +│ ⠘⠁ ⠁ ⢻⣿⠆ ⢠⣿⠃ ⣾⡟ ⢰⣿⠃ ⣿⡏ ⢀⡀ ⢸⣿ ⣿⡇ ⣿⡧ │ +│ ⣀ ⠁ ⣿⠃ ⢸⡿ ⢰⣿⠇ ⠿⠇ ⢸⣿ ⢠⣿⡇ ⣿⡇ │ +│ ⢿⣷ ⢀⣴⣦ ⠘⠃ ⠈⠟ ⢀⣿⡏ ⢸⣿ ⢀⣿⡇ │ +│ ⠐⣷⡄ ⠉ ⢸⣿⡆ ⠘⠛⠃ ⣠⡀ ⢀⣀ ⣾⡟ ⢀⣿⡏ ⣸⣿ ⢀ │ +│ ⢰⣦⡀ ⠹⣿⡄ ⠉ ⢰⣿⠇ ⣴⡄ ⢠⣄ ⠿⠿⠃ ⠁ ⣼⡟ ⢀⣿⡏ ⣸ │ +│ ⢰⣦ ⢿⣷ ⠈ ⣤⡀ ⣠⣤ ⠿⠇ ⠻⡿ ⣸⠟ ⣼⡟ ⢀⣿ │ +│ ⠈⣿⣧ ⠈⠻⠇ ⠻⣿⠆ ⠠⣶⣦ ⠻⠟ ⢀⡀ ⣀⣤⡀ ⢀⡀ ⣼⡿⠁ ⣾⡟ │ +│ ⠘⢿⡆ ⢀⡀ ⠉⠉ ⢿⡿ ⢰⣶⡄ ⠻⠟⠃ ⢠⣾⡿⠃ ⠰⠟ ⣼⡿⠁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > rotozoomer 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ │ +│ ▓▓███████▓▓▓▒▒▒░░░·······░░░▒▒▒▓▓████████▓▓▓▒▒░░░········░░▒▒▒▓▓▓███████▓▓▓▒▒▒░░░·······░░ │ +│ ▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓▓▒▒░░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░ │ +│ ▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓▓▒▒░░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░ │ +│ ▓▓████████▓▓▒▒▒░░░·······░░░▒▒▓▓▓████████▓▓▓▒▒░░░········░░░▒▒▓▓▓███████▓▓▓▒▒▒░░········░░ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ░░········░░▒▒▒▓▓▓███████▓▓▓▒▒░░░········░░░▒▒▓▓▓████████▓▓▓▒▒░░░·······░░░▒▒▒▓▓████████▓▓ │ +│ ░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░░▒▒▓▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓ │ +│ ░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░░▒▒▓▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓ │ +│ ░░·······░░░▒▒▒▓▓▓███████▓▓▓▒▒▒░░········░░░▒▒▓▓▓████████▓▓▒▒▒░░░·······░░░▒▒▒▓▓▓███████▓▓ │ +│ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > silk 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠉⠓⠦⠤⣄⣀⣀⣀⣀⣀⣠⠤⠴⠒⠒⠋⠉⣉⣉⡭⠭⠭⣽⣛⣛⣯⣭⣭⠭⠭⠤⠴⠒⠒⠚⠉⠉ ⠉⠓⠦⣄⡀ ⠈⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠓⠒⠲⠤⢤⣀⣀⣀⣀⣀⣀⡤⠤⠖⢋⣡⠤ │ +│ ⠉⠓⠲⢤⣀⡀ ⢀⣀⡤⠴⠚⢉⣡⠴⠒⣋⣩⠥⠤⠖⠒⠦⠬⢭⣉⣉⠓⠒⠒⠒⠒⠒⠒⠒⠋⠉⠉⠉⠉⠉⠉⠉⠓⠒⠦⣄⣀ ⠉⠓⠲⠤⣄⣀⣀⣀⣀⣀⣀⣠⠤⠤⠴⠒⠒⠒⠒⢒⣒⣒⣒⣒⣒⣒⣒⣒⣒⣒⣒⣋⡭⠤⠴ │ +│ ⠒⠦⢤⣀⣀⠉⠙⠒⠒⠒⠒⠒⠋⠉⣀⡤⠴⢚⣩⠴⠚⣉⣡⠤⠴⠒⠒⠒⠒⠒⠦⢤⣈⡉⠓⠒⠦⠤⣄⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡀ ⠈⠙⠲⢤⣀⡀ ⣀⣠⠤⠖⢚⣉⡭⠴⣒⣺⠭⠭⠭⠭⠭⠭⣗⣒⡒⠒⠒⠒ │ +│ ⠤⠤⠤⠤⢬⣉⣓⣒⡒⠒⠒⣒⣚⣩⠥⢖⣚⡭⠴⠒⠋⢁⣀⣠⠤⠤⠤⠤⠤⠤⠤⣄⣀⠉⠓⠦⢤⣀ ⠉⠉⠓⠲⠤⣄⣀⠉⠙⠒⠒⠒⠒⠒⠒⠚⠉⣁⣠⠴⢚⣩⠴⢚⣩⠥⠖⠒⠚⠉⠉⠓⠒⠲⠤⣍⡉⠓⠒ │ +│ ⠒⣒⣲⢯⣽⡿⠿⢿⣿⣿⣿⡷⠾⣟⣛⣛⡒⠒⠒⠋⠉⠉ ⣀⣀⣀⣀ ⠈⠙⠲⢤⣀⠈⠉⠓⠒⠦⠤⠤⠤⠤⠤⠤⠴⠒⠒⠒⠒⠒⠲⠤⠤⢬⣉⣓⡒⠲⠤⠤⠴⠒⣒⣋⡥⣖⣺⠭⠖⢋⣉⡤⠤⠖⠒⠒⠚⠙⠒⠒⠲⠤⣄⡉⠙⠲ │ +│ ⣩⢵⣺⠭⠖⠚⣉⣉⣀⣀⣀⣉⣉⠓⠲⠤⣍⣉⠉⠉⠉⠉⠉⠉⠉⠉⠁ ⠈⠉⠉⠙⠒⠦⣄⣈⠉⠓⠦⣄⣀⡀ ⢀⣀⣀⡤⠤⠴⠒⣒⣒⡯⣭⣿⠷⣿⣿⣛⣻⣿⣿⠷⢿⣛⣓⣒⣚⣉⣉⣀⣀⣀⣠⠤⠤⠤⠤⠤⠤⢤⣀⣀⠉⠙⠲ │ +│ ⣚⣩⠤⠖⠚⠉⠁ ⠈⠉⠓⠲⢤⣈⠉⠙⠒⠒⠦⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢤⣀⣈⠙⠲⠤⣄⣀⠉⠉⠉⠉⠉⠉⢀⣀⡤⠴⢒⣫⠵⣚⡭⠗⠚⣉⣁⡤⠤⠤⠤⣌⣉⡙⠒⠦⠤⣄⣀⡀ ⠈⠙⠒⠦ │ +│ ⠤⠤⠤⠴⠒⠒⠋⠉⠉⠉⠉⠉⠙⠒⠲⢤⣀⡈⠙⠒⠦⣄⣀⡀ ⢀⣀⣀⣀⣀⣈⣉⣓⠲⠤⢬⣉⣉⣓⠒⣋⣉⣩⠤⢖⣺⠭⢖⣋⡥⠖⠚⠉⠁ ⢀⣀⣀⣀⡀ ⠉⠙⠲⢤⣀⡀⠉⠉⠉⠙⠒⠒⠋⠉⠉⠉⠉⠉⠓⠒⠒⠦⢤ │ +│ ⣀ ⠈⠉⠙⠲⠤⣄⡀⠉⠙⠒⠦⠤⠤⠤⠤⠤⠖⠒⠋⠉⣁⣠⠴⠒⣋⡭⠵⠒⠒⢚⣉⣉⡓⠒⠒⠲⠤⠤⢤⣀⣀⣀⣀⣀⣀⣀⡤⠤⠤⠤⠤⠤⢤⣀⣀ ⠉⠓⠦⣄⡀ ⢀⣀⣀ │ +│ ⠈⠉⠓⠒⠦⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣄⣀⣀⠉⠙⠲⠤⢤⣀⣀⣀⣀⣠⠤⠴⠒⠋⣁⡤⠖⢋⣡⠤⠖⠋⠉⠉ ⠉⠉⠙⠒⠲⠤⣄⣀ ⠈⠉⠓⠲⢤⣀ ⠉⠙⠒⠲⠤⠤⠤⠴⠒⠒⠋⠉⠉⢀⣀ │ +│ ⠒⠦⣄⡀ ⠈⠉⠓⠒⠦⠤⢤⣀⣀⣀⡤⠤⠖⠚⣉⡥⠴⠚⠉⣀⡤⠴⠒⠚⠉⠉⠉⠉⠙⠒⠲⢤⣀⡀⠈⠉⠓⠒⠲⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣄⣀⡀⠈⠙⠒⠦⢤⣀⣀⣀⣀⣀⣀⡤⠴⠒⠋⢉⣠ │ +│ ⣀⡀ ⠉⠙⠒⠒⠲⠤⠤⠖⠒⠒⠒⠋⠉⠉⠉⠉⢉⣉⣉⣉⣓⣒⣒⣒⣒⣒⣒⣒⣋⣩⠥⠴⠒⠚⠉⠁ ⣀⣀⣠⠤⠤⠤⠤⠤⣄⣀ ⠉⠓⠦⣄⣀ ⠉⠙⠒⠲⠤⣄⣀⣀ ⢀⣀⣠⠤⠖⠚⣉⡤ │ +│ ⠉⠓⠦⢤⣀⡀ ⣀⣀⡤⠤⠖⠚⠉⣉⡤⠤⠖⣒⣚⡭⠭⠭⢷⣒⣒⣒⠒⠒⠒⠒⠒⠚⠉⠉⠁ ⠈⠉⠓⠦⢤⣀ ⠈⠉⠓⠒⠒⠦⠤⠤⠖⠒⠒⠒⠒⠋⠉⠉⠓⠒⠒⠒⠦⠤⠤⠬⣍⣉⣩⠤⠤⠴⢚⣉⣡⠤ │ +│ ⠙⠲⠤⣄⣀ ⠉⠉⠉⠉⠉⠉⠁ ⣀⣠⠴⠚⣉⡥⠖⢚⣉⡥⠴⠒⠒⠒⠒⠲⠤⢬⣉⡉⠓⠒⠒⠦⠤⠤⠤⠤⠖⠒⠒⠒⠒⠒⠒⠦⠤⣄⣀ ⠈⠙⠲⢤⣀⡀ ⢀⣀⣠⠤⠴⠒⠒⢋⣉⣩⠭⢭⣭⣭⣭⣭⣭⣭⡭⠭⠭⠤⠤⠖ │ +│ ⠲⠤⠤⣄⣈⡉⠓⠒⠒⠒⠒⠒⠚⢉⣁⡤⠖⣋⡥⠖⠚⣉⣠⠤⠤⠖⠒⠒⠒⠲⠤⢤⣀⡉⠓⠲⠤⣄⣀⡀ ⠈⠉⠓⠦⢤⣀ ⠉⠉⠉⠙⠋⠉⠉⠉ ⢀⣠⠤⠖⣋⡭⠴⣚⣩⠭⠖⠒⠒⠒⠒⠲⠭⣍⣙⡒⠒⠒ │ +│ ⠤⠖⠒⣲⣤⣭⣭⣽⣶⣶⣶⣯⣭⣭⣖⣚⡭⠥⠖⠚⠉⠁ ⠉⠓⠦⣄⡀ ⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠙⠒⠒⠲⠤⣄⣈⡉⠓⠒⠦⠤⠤⠤⠖⠒⢋⣉⡤⢖⣋⡥⠖⠋⠁ ⠉⠙⠒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > skyline 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · · ▀▀▀▀▀ · · │ +│ █▀▓▓█ · • │ +│ · █▒▓▓█ · · · •••· │ +│ + + █·▓▀█ · • · • • │ +│ · █▓▓·█ │ │ │ │ │ +│ ·+ · █▒▓▓█ │ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀▀ │ +│ · · • █▀▒▓█ ▀▀▀▀▀▀▀▀▀ ·█▒▓▒▓·▓·█ █·▓▓▓·█ ·▓▀▓· █·▓▀▓▓ │ +│ │ │ █·▓·█ █·▓·▓▓▓▓█ █·▓·▓▓▓▀█ █▓▓▓▒▓█ ▓▓▓▓▓ █·▓·▓▓ │ +│ ▀▀ █▀▓▒▓·█ █▓▓·▓·█ █▀▓▓█ █▀▓▓▓▓▓▒█ █▀▒▀▒▓▓▀█ █▓▓·▓▓█ █▀▓▓▓▀█ █·▓▀▓▀ │ +│ ▄▄ █▀▓▓▓▓█ █·▓·▓▀█ █·▒▓█ █·▓▀▓·▓▀█ █▓▓▀▓▀▓▀█ █▓▓▓▓·█ █▀▓·▓▓█ █·▒▓▓▒ │ +│ ·││ · │ █▓▓▓▓▀█ █·▓▀▓▓█ █▀▓▓█ █▓▓·▒▒▓·█ █▀▒▀▓▀▓▀█ █▀▓▓▓·█ █▀▓▓▒▀█ █▀▓▀▒· │ +│ ▀▀▀▀▀ ▀▀▀▀▀▀▀ █▀▓▒▓▀█ █▓▓▀▓▒█ █▓▓▓█ █▓▓▀▒▓▒·█ █▓▓▀▓▓▓▓█ █▀▓▀▓·█·█·▒·▒▓█ █▀▓▓▓· │ +│ █▀▒▀█ █▀▓·▓▀▓▓█ █▓▓▓▓▓█ █▀▓·▓▓█ █▓▓▓█ █·▓·▓▀▓▓█ █▓▓▀▓▒▓·█ █▓▓▀▓·█ █▀▓▀▓▓█ █·▓▀▓▀ │ +│ ·▄▀▄▀ ▀▄▀▄▀▄▄·▀ ▀▄▀▄▀▄▀ ▀·▀▄▀▄▀ ▄▄▀▄· ▀▄▀▄▀▄▀▄▀ · ▀▄▄▄▄▄▄▄▀ ·▀▄▀▄▄▄▄ ▀▄▀▄·▄▀ ▀▄▀▄▀▄ │ +│ • • • • •█ • ••█ • • │ +│ ────·· · ────˙· ·────· · ──── · ───˙ ·────· ·˙ ──── · ────· ──── ────··· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > spectrogram 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ░░ ░░ │ +│ ░░ ░ ░░ ░ ░░░ ░░ │ +│ ░░ ░░░ ░░░ ░░░░░ ░░░░ ░░░░ ░░▒▒░░░░░▒▒▒░ │ +│ ░░░░ ░░ ░ ░░░░ ░░░ ░░░░ ░░▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░▒▒▓▓ │ +│ ░░░░░ ░░░░░ ░ ░░░░░░░▒▒▓▒▒▒▒▒▓▓▓▓▓▒▒▒▓▓▓▒▒▒▒▒▒▓▓▒▒▒▒▓▓▓▓▓▓▒▒▓▓▓▓▓▒▒▒▒▓ │ +│ ░░░░░░░░ ░░░░░ ░░▒▒▒░░░▒▒▓▓▒▒▒▒▒▓▓▓▒▒▒▒▒▓▓▓▒▒▒▒▓▓▓▓▓▒▒▒▓▓▓▓▒▒░░▒▒▒░░ ░░░░░░ ░░░▒▒▒░░░▒ │ +│ ▓▒▒▓▓████▓▓▓████▓▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓▓██▓▓▓▒▒▓▓▓▒░░░░░░░░ ░░░░░░░░░░▒▒░░░░░░░░░ ░░░ │ +│ ███████████████████████████▓▓▓▓▓▓▒▒░░░▒▒▒░░░░░▒▒▒░░░░░▒▒░░ ░░░░ ░░░ ░░░▒░░░░░░▒░░░ │ +│ ██████████████████████████████▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒░░ ░░░░ ░░░░░░░░▒▒▒▒░░░░▒▒▒░░░░▒▒▒ │ +│ ▓▓▓▓▓▓▒▒░▒▒▒▒▒▒▒▒▒▓▓███▓██████████████▓▒▒▒▒▒▒▒░░░▒▒▒▒▒░░░▒▒▒▒░░░░░░░░░ ░░░▒░░░░▒▒▓▓▓▓▓███ │ +│ ▒▒▒▒▒▓▓▒▒░░▒▒▒▒▒░░░░▒▒▒▒▒▒▒▓▓█████████████████▓▓▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▓▓▓▒▒▒▒▒▓▓▓▓▒▒▓▓███████ │ +│ ░░░░░░▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▓▒▒▒▓▓███████████████████▓▓▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓██████████████▓ │ +│ ▓▒▒▒░░░▒▒▒░░░░░░▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒▓▓████▓█████████████████████████████████████████████▓ │ +│ ▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒░░░░░▒▒▒▒▒▒▒▓▓███▓▓████████████████████████████████████▓▓▒▒▒▒▒▒ │ +│ ░▒▒▒▒░░░░░▒▒▒▒░░▒▒▓▓▓▒▒▒▒▒▓▓▒▒░░░▒▒▒▒░░░░▒▒▒▒▒▒▓▓████████████████████▓▓▓████▓▓▓▓▓▓▓▓▓▒▒▒▒▓ │ +│ ▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒▒▒░░░▒▒▓▓▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒░░▒▒▒▓▓▒▒▒▓▓███▓▓▓█████▓▓▒▓▓▓▓▒▒░░▒▒▒▒▒░░▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > spinning-donut 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ~-:--;:-. =, .~=.,~;,-:-~ │ +│ ;;=;!=;..!;...*=. . *....!..:=!:=:; │ +│ !!*!-,*...#*....#..~..#.....#...!*-=*=! │ +│ #*;#~-#,,..$.,...$..#...$..,..$..,#-:*#*# │ +│ $@*@**@*!*!@**;;:@~~@~~~@;;*=*@**!@#*@@@@ │ +│ @@@@@@@@@@@**!!=@==@==@=!!**@$@@@@@@@@@ │ +│ @@@@@@@@@@@$#*@@$ $ @$#$$@@@@@@@@@@ │ +│ @@@@@@@@@ @@ $@@@@@@@@@@@ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > spirograph 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ⢠⢄ ⡞⢧ ⡼⢳ ⡀ │ +│ ⣀ ⠸⡀⠑⡇ ⢧⡸⠁⢸⢀⡴⠋⡇⢀⣀ │ +│ ⠱⡉⠒⠤⣇ ⡏⢢⡰⢇⢀⢼⠃⢀⡼⠛⢉⠞ │ +│ ⢀⡤⢄⣀⣙⣄ ⢸⠙⡧⣴⣑⣼⣧⢼⠋⢡⢃⣠⣇⣀⡠⢤⡀ │ +│ ⢑⣦⣀⣀⣘⢭⠭⣯⣿⠷⠛⠛⠿⣾⡭⡯⡽⣁⣀⣀⣴⠋ │ +│ ⠻⢭⣀⡈⠑⣤⡬⢳⣿⠃ ⠘⣿⠾⢥⡔⠊⠁⣀⠭⠟ │ +│ ⠸⠭⢤⡤⢮⠥⢤⠧⢼⣿⣶⣤⣤⡶⡿⡯⠽⡭⠶⡤⠤⠤⠬⠗ │ +│ ⢠⣋⡴⠥⢤⣛⠮⡟⢻⠝⢯⡝⣻⡕⢳⠤⣙⡄⠈⢢⡀ │ +│ ⠮⠖⢪⠏⢀⡰⡏⠙⣆⠜⢣⡇⠈⢹⢤⡀⠘⡕⠒⠵ │ +│ ⠯⠒⠁ ⡇⢠⢏ ⡜⠑⡄⢸ ⠈⠒⠽ │ +│ ⠷⠃⢸⣰⠃ ⠘⠾ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > starfield 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ +*✦ +++ *+ · + * + **━** │ +│ *++ * + ++ + ++ * ━** ** │ +│ + * ++ *++ + *+ + + ** ·· │ +│ +++* * **· ++ ++ * + ✦*++ ✦ * │ +│ *++ ━*━* *** ++*✦+ + *+ *** ++ ** │ +│ ***━* ++* ++ + **+ *+ · ·━ ++ + *++*++ │ +│ ━***+ + · + ·++ ━ + ***━********━**** │ +│ ++*✦✦✦*+ + ˙ · · ***━++✦✦*++· │ +│ ++*✦*+ ········· + ++ *·++·*+··*━****······ │ +│ + ·+++····+·+✦ + +··+ *+━·++·············+· +++*✦✦✦+ │ +│ ****━*** ············+· *+ ++* +·━+··········++····· │ +│ ** ···············+ ·* *+ ✦* ···+····+··*✦*+ * │ +│ ············*·✦+ * ++ +· ··✦····++ ** +++ ** │ +│ ━***·····✦**··*·+✦· + + + * + ✦*++ │ +│ ✦✦*++ **** ···+*++·**··+·· ✦ + * + + ━ ··· │ +│ ++* ········**····· + ++ * ·+ * ++ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > stormfront 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▓░░░░░░░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒ │ +│ ▓█▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒█▒▒▒▒█▒░░░░░░░░░░░░░░░▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒░▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓░░░░░░░░▒▒░▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▓░░░░░░░░█░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▓▒▒░▒▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░█░░░░░░░░░░█░█░░░░░▒▒▒▒▒▒░░░░░░░░▓░░░░░░░░░░░░░░░▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▓▒█▒▒▒▒▒█▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░▓░░░░░░░░░░░░░█░░░░░░░█░░░░░░░░░░▓░░░░░▒▒▒▒▒▒ │ +│ ▒▒▒▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▓░░░░█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░ │ +│ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░░░░░░░░░░░░░░▓░█░░░░░░░░░░░░░░▓░░░░░░░░░░░░▓░░░░░░░░░░░░░░░░░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░··· █ █ ·····░░░░█░░░░░░·······▓····· █ ······░▓░ │ +│ ░░░░░░░░░░░░░░░░░░░░▓░░░░░░░··· ····░░░░░░··░······ █ ▓ │ +│ ·····░░▓░░░░░░░░░░░░█░░░░··░ ░ ······ ▓▓ ▓ ░ █ │ +│ ·······▓··░░░░░░░░░···· ▓ ▓ ▓▓ ▓ ▓░ ░ │ +│ ·█···········▓ ▓ ▓░░ ██ █ ▓ █ │ +│ ··· ··· ··· ░··· ···░ ··· █ ░▓··· ··· ··· ··· │ +│ █ █ █ ▓█▓ ░ ▓ │ +│ ░ ░ ⌁ ⌁ ⌁ ⌁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > sumi-ocean 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ▓ │ +│ · · ▓████▓ │ +│ ⌁ ▓██████▓ │ +│ ⌁ ▓████▓ │ +│ │ +│ ───·░·░──░·░░──···░──···───·░·───··░──·─··──░···───···───····──░░────···───···──· ·░──·░░░ │ +│ ─────────────░─────··───···─────·───────────────░·────··░────··───────────────·─────··──── │ +│ ░──━━░░───────━━░──────░░░━━───░░░░───━━░░░░░───░░░░─░────░━░────────━━━────░░░░─━━─░─░░░─ │ +│ ━·━▒▒━━━━━━━━━··━━━━━━━━━━▒····▒━━━━━━▒▒━━···━━━━━━━━·░░··━▒━━━━─░──··▒▒━━━━━━━━━···━━ ━━━ │ +│ ━━····▒▒▒▒▒▒·━━━···▒▒▒▒▒▒▒▒━━━━·▒▒▒▒▒▒░▒··━━━▒▒▒▒▒▒▒━━··━━━░▒▒▒───━━━━·▒▒▒▒▒▒▒▒··━━━▒▒━▒▒▒ │ +│ ▒▒━━━━━━━━━·━···━━━━━━━━━━━▒·▒▒━━░▒━━━━━━━▒▒▒━━━▒▒··▒▒━━▒▒▒━━━▒░···──▒─━━━━━▒····▒▒▒━━━━━━ │ +│ ···▒▒▒▒▒▀▀━━▓━━━▒▒▒▒▒▒▒▒····━━━━━━━▒▒▒····▓▓━▒▒━━━━━··▒▒▒▓▓━━━━·━━━━━▒▒▒▒▒▒▒·━━━━━━▒▒▒▒▒▒· │ +│ ━━━━━━━━━━▀▀▒▓▓▓━━━━━━━━━━━━▓▓▓▓▓▓▀━▓·━━━━━━▓━━▓▓▓▓▓▀━·▓▓━━▓▓▓▓━▓▓━▓━━━━▒▓▀▀▀▓▓▓▓▓▓━━━━━━▀ │ +│ ▓▓▓▓▓▓▓━██━━▀▀▒▒▓▓▓▓▓▀▀▀▓▓▀▓━━━━━━━▀▀━▓▓▓▓▓▓▓▓▓▀▀▀▀▀━━━━━▓▓▓▒ ▓▓▓▓━━━━▓▓━▀━━━▀▀▒▒▒▒▓▓▓▓▀▀━ │ +│ ▀▒▓▓▒━━█▒▓██━━━▓▓▒▓▓━━━━▀▀━━███████━━▀▀▓▓▓━━━━━━━━━━█████━▒━▀▀▀▀▀▀━━██━━━━███━━━━━▓▓▒▓▀━━█ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > tesseract 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠑⢄ ⡠⠊ │ +│ ⠑⢄ ⡠⠊ │ +│ ⠑⢄⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡠⠊ │ +│ ⢸⠑⣤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣤⠊⡇ │ +│ ⢸ ⡇⠑⢄ ⡠⠊⢸ ⡇ │ +│ ⢸ ⡇ ⢱⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⡎ ⢸ ⡇ │ +│ ⢸ ⡇ ⢸ ⡇ ⢸ ⡇ │ +│ ⢸ ⡇ ⢸ ⡇ ⢸ ⡇ │ +│ ⢸ ⡇ ⢸ ⡇ ⢸ ⡇ │ +│ ⢸ ⡇ ⡸⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢇ ⢸ ⡇ │ +│ ⢸ ⡇⡠⠊ ⠑⢄⢸ ⡇ │ +│ ⢸⡠⠛⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠛⢄⡇ │ +│ ⡠⠊⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠑⢄ │ +│ ⡠⠊ ⠑⢄ │ +│ ⡠⠊ ⠑⢄ │ +│ ⡠⠊ ⠑⢄ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > torus-knot 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ⣀⡤⠖⠚⠉⠉⠉⠓⠲⠤⣀ │ +│ ⢀⣠⠤⠖⠒⠒⠒⠒⠒⠲⠤⢤⣀⡀ ⢀⠞⠁ ⠈⠙⢦⡀ │ +│ ⡴⠉ ⠉⣹⠻⢄⡀ ⠙⢦ │ +│ ⢧ ⣰⠃ ⠙⢦⡀ ⢳⡀ │ +│ ⠈⠳⣄ ⣰⠃ ⠙⢆ ⢳ │ +│ ⠈⠳⢄⡀ ⢀⣠⠞⠁ ⠘⣆⣀ ⠈⡇ │ +│ ⢀⡰⠚⠉⠙⠢⣄ ⠈⢳⡀ ⡇ │ +│ ⣠⠊ ⠙⢦⡀ ⢳⡀ ⢠⠃ │ +│ ⢀⡞⠁ ⠙⣄ ⢇ ⢠⠎ │ +│ ⢠⠎ ⠈⠳⣄ ⢸ ⡰⠋ │ +│ ⢀⠏ ⠈⠳⢤⣀ ⣀⡼⡎ │ +│ ⡜ ⠈⠙⠒⠒⠒⠒⠒⠚⠉ ⢰⠃ │ +│ ⢰⠃ ⢸ │ +│ ⢸ ⡎ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > tunnel 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ │ +│ ░░░░░░░░░░░░░░ ░░░░░░░░▒▒▒▒▒▓▓▓▓▓███████████▓▓▓▓▓▓▒▒▒▒▒░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓███████████████████████▓▓▓▓▓▓▒▒▒▒░░░░░ │ +│ ░░░░░░░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░ │ +│ ░░░░░░░░▒▒▒▒▒▓▓▓▓███████▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒░░░ ░▒▒▒▓▓█████▓▓▓▓▓▒▒▒░░░░░░ │ +│ ░░░░░▒▒▒▒▓▓▓████████▓▓▒░ ░▒▓███▓▒░ ░░░▒▓████▓▒░░░░▒▒▓▓█████████▓▓▓▒▒▒▒░░░░░░░ │ +│ ░░░▒▒▒▓▓▓████████▓▓▒░ ░▒▓▓▒░░▒██░ ▓█▓░ ░▓▒ ▓▓░ ░▒██▓▒░░░░▒▓▓█████████▓▓▓▒▒▒▒▒░░░░ │ +│ ░░░▒▒▒▓▓▓██████▓▓▒░░░▒▓██▒ ▒█░▓▒▓▒ █▓█░░▓▓▓▒░▒▒▓▒█▓ ░▒▓▓▒ ░▒▓▓███████▓▓▓▒▒▒▒▒░░░░ │ +│ ░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ▒▓██▓░░▒██▒░██▓░░░ ░▓▓░▒▓▓░ ░▓███▒░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░ │ +│ ░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░▒▒▓▓▓▒░ ░░░░░░░░░▒▓▓▓▓▓▒░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░ │ +│ ░░░░░▒▒▒▒▓▓▓▓███████▓▓▓▒▒░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒▓▓▓███████▓▓▓▒▒▒░░░ │ +│ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓██████████████▓▓▓▒▒▒▒░░░░░ ░░░░▒▒▒▓▓███████████▓▓▓▒▒▒░░░ │ +│ ░░░░░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▓█████████████▓▓▓▓▓▓▓▓▓▓▓▓█████████████▓▓▓▓▒▒▒░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░ │ +│ ▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ │ +│ ▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > twister 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ██████████████████████████ │ +│ ████████████████████████████ │ +│ ███████████████████████████ │ +│ ▓▓▓▓▓▓██████████████ │ +│ ▓▓▓▓▓▓█████ │ +│ ▒ │ +│ ▓▓▓▓▓▓▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓ │ +│ ▒▒▒▒▓▓▓▓▓█ │ +│ █▓▒ │ +│ ███████▓▓▓▓▓▓▓ │ +│ ████████████████▓▓▓▓▓ │ +│ ███████████████████████████ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ultracode 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > xor-texture 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / micro / ascii > braille-wave 1`] = ` +"KEXP 90.3 FM . playing + + +.--::--:. +.--..-:. :-: +-- ..+-*. :-- .-+-+-: + .:: ::. +" +`; + +exports[`receiver baseline / micro / ascii > gallop-fine 1`] = ` +"KEXP 90.3 FM . playing + + .-+- + :-::.:#: + -: .**-*++ + +#:. .. +" +`; + +exports[`receiver baseline / micro / ascii > manhattan 1`] = ` +"KEXP 90.3 FM . playing + +## . oo o |o@o. +#### #||#. /|.. ###@..## +#..* ##..../*\\.o|.....## + -o. .oooo. oo o .o.o..o +" +`; + +exports[`receiver baseline / micro / ascii > pulse-grid 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`receiver baseline / micro / ascii > ultracode 1`] = ` +"KEXP 90.3 FM . playing + + + + + +" +`; + +exports[`receiver baseline / micro / unicode > braille-wave 1`] = ` +"KEXP 90.3 FM · playing + + +⢀⡤⠲⠤⣀⣠⢤⣀⡀ ⣰⢀⡤⣄⡀⢀⣠⣀⡀ ⣀⡤⠤ +⠴⠋ ⠈⠈⠳⣄⡼⠁ ⠉⠓⠋ ⠈⠙⢦⣠⠞⡤⠤ + ⠈⠉⠉ ⠉⠉⠁ +" +`; + +exports[`receiver baseline / micro / unicode > gallop-fine 1`] = ` +"KEXP 90.3 FM · playing + + ⢀⢤⢖⢤ + ⣀⡔⠒⠒⠈⠡⣼⠉ + ⠲⠤ ⠈⢧⣖⢒⣲⠗⢣ + ⣘⣮⠃⠈ ⠈⠁ +" +`; + +exports[`receiver baseline / micro / unicode > manhattan 1`] = ` +"KEXP 90.3 FM · playing + +▀▀ ˙ •• • │•●•˙ +██▀▀ ▀││▀░ ╱│░░ ▀▀▀●░˙▀▀ +█░░▪ ▀▀░░░˙╱◆╲░•│░░░░░██ + ─•˙ ·••••˙ •• • ˙•˙•˙˙• +" +`; + +exports[`receiver baseline / micro / unicode > pulse-grid 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`receiver baseline / micro / unicode > ultracode 1`] = ` +"KEXP 90.3 FM · playing + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ------------------------- +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ------------------------------ +1 receivers . mpv + +> Living Room . speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do. +REVIEW +> Save alarm . next Mon, Se. + Cancel + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +> Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK +> Volume: 70% [#######...]. + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM - . + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-picker' 1`] = ` +"Primary station ------------------------------- +Favorites, imports, recents, current station, a. + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------------------------------ +Favorites, imports, recents, current station, a. + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-ringing' 1`] = ` +"Alarm ringing --------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio +Started 1m ago . scheduled Mon, Sep 7, 11:59 AM. + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / ascii > 'alarms' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow. + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii > 'alarms/degraded' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Repair needed +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow. + + + +Scheduling needs repair . press r +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii > 'alarms/empty' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +No upcoming alarms. + +> Create alarm + Verify alarm setup . scheduler, terminal, pow. + + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations --------- +Rehearsing "Morning radio" on this machine +READY WITH LIMITATIONS . Review warnings before +relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / ascii > 'countries' 1`] = ` +"RADIOCLI Countries ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E . 2 stations + + . .:.:+** . .-. > KEXP 90.3 FM . * + ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM - . + *@@@%- .@*%#%@@@@@%+ + +#+ +@%#%%+%%@#:. + ..-+. +#%@%: : ::. + %@@- +@#. ..::. + :@+. :%:: .%%#. + ++ : . + .. :::.::::::. +::###%%@#***#%@@@@@@@@@@@@@@:" +`; + +exports[`screen baseline / compact / ascii > 'help' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Navigation + ^/v or n/p . Move selection + or Tab . Switch tabs + [ / ] . Page selection by 10 + 1-9 . Jump to an Overview option + Enter . Open / tune the selection + b or Esc . Back to home + : . Command palette + ? . Toggle this help +" +`; + +exports[`screen baseline / compact / ascii > 'help/commands' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Commands + :search . Search stations (alias :s) + :country . Open a country (alias . + :codec . Filter by codec +> :language . Filter by language (al. + :bitrate . Minimum bitrate filter + :clear . Clear search filters + :volume <0-100> . Set volume (alias :vol) + :mute . Toggle mute +" +`; + +exports[`screen baseline / compact / ascii > 'home' 1`] = ` +"RADIOCLI ######### + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent . 1 favorites . 1 imported +" +`; + +exports[`screen baseline / compact / ascii > 'library' 1`] = ` +"RADIOCLI Library ------------------------------ +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'library/empty' 1`] = ` +"RADIOCLI Library ------------------------------ +0 stations . playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'map' 1`] = ` +"RADIOCLI World map ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'nearby' 1`] = ` +"RADIOCLI Nearby ------------------------------- +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle . vol 70 + + + + + + + + + +################################################ +" +`; + +exports[`screen baseline / compact / ascii > 'search' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------+ + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'search/loading' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|* tokyo jazz | ++----------------------------------------------+ + +Searching station directories. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings' 1`] = ` +"RADIOCLI Settings ----------------------------- +Choose a category . Updates are available here + +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/agent' 1`] = ` +"RADIOCLI Settings ----------------------------- +Agent control & MCP . Enter changes setting . b. + +> Allow local agent con. off + Install or repair MCP. run repair + Open TUI for agent pl. on + Show Now Playing for . on + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ----------------------------- +Appearance . Enter changes setting . b categori. + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad sc. auto + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/data' 1`] = ` +"RADIOCLI Settings ----------------------------- +Data & library . Enter changes setting . b cate. + +> Export preferences an. JSON backup + Import preferences an. restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ----------------------------- +Discovery & providers . Enter changes setting .. + +> Nearby location off + Radio Garden adapter off + Share favorite votes . on + Refresh provider heal. + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ----------------------------- +Media keys . Enter changes setting . b categori. + +> Learn previous media . + Learn play/pause medi. + Learn next media key + Reset learned media k. prev 0 . play 0 . ne. + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/playback' 1`] = ` +"RADIOCLI Settings ----------------------------- +Playback & audio . Enter changes setting . b ca. + +> Audio output Automatic + AirPlay receiver Living Room . 1 rece. + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o. on + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/updates' 1`] = ` +"RADIOCLI Settings ----------------------------- +Updates . Enter changes setting . b categories + +> Automatically check f. on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'stations' 1`] = ` +"RADIOCLI Stations ----------------------------- +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'stats' 1`] = ` +"RADIOCLI Listening stats ---------------------- +Stored locally on this machine + +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +Streak . 2 current . 2 best +..........................## . 2 active days + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ------------------------- +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ------------------------------ +1 receivers . mpv + +> Living Room . speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do. +REVIEW +> Save alarm . next Mon, Se. + Cancel + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +> Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK +> Volume: 70% [#######...]. + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM - . + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-picker' 1`] = ` +"Primary station ------------------------------- +Favorites, imports, recents, current station, a. + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------------------------------ +Favorites, imports, recents, current station, a. + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing --------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio +Started 1m ago . scheduled Mon, Sep 7, 11:59 AM. + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow. + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms/degraded' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Repair needed +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow. + + + +Scheduling needs repair . press r +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms/empty' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +No upcoming alarms. + +> Create alarm + Verify alarm setup . scheduler, terminal, pow. + + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations --------- +Rehearsing "Morning radio" on this machine +READY WITH LIMITATIONS . Review warnings before +relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / ascii-no-color > 'countries' 1`] = ` +"RADIOCLI Countries ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E . 2 stations + + . .:.:+** . .-. > KEXP 90.3 FM . * + ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM - . + *@@@%- .@*%#%@@@@@%+ + +#+ +@%#%%+%%@#:. + ..-+. +#%@%: : ::. + %@@- +@#. ..::. + :@+. :%:: .%%#. + ++ : . + .. :::.::::::. +::###%%@#***#%@@@@@@@@@@@@@@:" +`; + +exports[`screen baseline / compact / ascii-no-color > 'help' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Navigation + ^/v or n/p . Move selection + or Tab . Switch tabs + [ / ] . Page selection by 10 + 1-9 . Jump to an Overview option + Enter . Open / tune the selection + b or Esc . Back to home + : . Command palette + ? . Toggle this help +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'help/commands' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Commands + :search . Search stations (alias :s) + :country . Open a country (alias . + :codec . Filter by codec +> :language . Filter by language (al. + :bitrate . Minimum bitrate filter + :clear . Clear search filters + :volume <0-100> . Set volume (alias :vol) + :mute . Toggle mute +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'home' 1`] = ` +"RADIOCLI ######### + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent . 1 favorites . 1 imported +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'library' 1`] = ` +"RADIOCLI Library ------------------------------ +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'library/empty' 1`] = ` +"RADIOCLI Library ------------------------------ +0 stations . playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'map' 1`] = ` +"RADIOCLI World map ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'nearby' 1`] = ` +"RADIOCLI Nearby ------------------------------- +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle . vol 70 + + + + + + + + + +################################################ +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'search' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------+ + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'search/loading' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|* tokyo jazz | ++----------------------------------------------+ + +Searching station directories. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings' 1`] = ` +"RADIOCLI Settings ----------------------------- +Choose a category . Updates are available here + +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/agent' 1`] = ` +"RADIOCLI Settings ----------------------------- +Agent control & MCP . Enter changes setting . b. + +> Allow local agent con. off + Install or repair MCP. run repair + Open TUI for agent pl. on + Show Now Playing for . on + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ----------------------------- +Appearance . Enter changes setting . b categori. + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad sc. auto + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/data' 1`] = ` +"RADIOCLI Settings ----------------------------- +Data & library . Enter changes setting . b cate. + +> Export preferences an. JSON backup + Import preferences an. restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ----------------------------- +Discovery & providers . Enter changes setting .. + +> Nearby location off + Radio Garden adapter off + Share favorite votes . on + Refresh provider heal. + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ----------------------------- +Media keys . Enter changes setting . b categori. + +> Learn previous media . + Learn play/pause medi. + Learn next media key + Reset learned media k. prev 0 . play 0 . ne. + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/playback' 1`] = ` +"RADIOCLI Settings ----------------------------- +Playback & audio . Enter changes setting . b ca. + +> Audio output Automatic + AirPlay receiver Living Room . 1 rece. + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o. on + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/updates' 1`] = ` +"RADIOCLI Settings ----------------------------- +Updates . Enter changes setting . b categories + +> Automatically check f. on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'stations' 1`] = ` +"RADIOCLI Stations ----------------------------- +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'stats' 1`] = ` +"RADIOCLI Listening stats ---------------------- +Stored locally on this machine + +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +Streak . 2 current . 2 best +..........................## . 2 active days + + + +" +`; + +exports[`screen baseline / compact / unicode > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ───────────────────────── +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ────────────────────────────── +1 receivers · mpv + +> Living Room · speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do… +REVIEW +› Save alarm · next Mon, Se… + Cancel + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +› Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK +› Volume: 70% [███████···]… + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM — … + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-picker' 1`] = ` +"Primary station ─────────────────────────────── +Favorites, imports, recents, current station, a… + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────────────────────────────── +Favorites, imports, recents, current station, a… + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio +Started 1m ago · scheduled Mon, Sep 7, 11:59 AM… + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / unicode > 'alarms' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode > 'alarms/degraded' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Repair needed +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduling needs repair · press r +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode > 'alarms/empty' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +No upcoming alarms. + +› Create alarm + Verify alarm setup · scheduler, terminal, pow… + + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ───────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before +relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / unicode > 'countries' 1`] = ` +"RADIOCLI Countries ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E · 2 stations + + ⢀ ⠄⠤⠄⠤⣤⣴⣦ ⠠ ⢀⣠⡀ > KEXP 90.3 FM · ★ + ⢾⠿⠶⣷⣿⣷⡊⢾⡀⠙⠋⠑⠂⡀⣴⣽⣶⣶⣶⣿⣿⣿⣿⣿⡿⠿⡳⠗ KCRW 89.9 FM — … + ⢹⣿⣿⣿⡿⠋ ⠈●⠟⣟⣛⡿⣿⣿⣿⣿⣿⢿⢋ + ⠛⡟⡋ ⣰⣿⣷⣶⣾⣟⠛⢿⡿⣿⠿⠆⠁ + ⠈⠁⣠⣤⡀ ⠹⠿⢿⣿⣷⠅ ⠘ ⢈⢂⠁ + ⢿⣿⣿⡆ ⢸⣿⡯⡀ ⠈⠈⢁⡐⠂ + ⢠⣿⠏⠁ ⠘⡿⠃⠃ ⠐⡿⢿⡷⠈ + ⢸⢋ ⠃ ⠈ + ⢀⡀ ⣀⣀⣀⢀⣀⣀⣀⣀⣀⣀⡀ +⣀⣀⣶⣶⣶⣷⣾⣿⣭⣥⣴⣴⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣀" +`; + +exports[`screen baseline / compact / unicode > 'help' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Navigation + ↑/↓ or n/p · Move selection + ←/→ or Tab · Switch tabs + [ / ] · Page selection by 10 + 1-9 · Jump to an Overview option + Enter · Open / tune the selection + b or Esc · Back to home + : · Command palette + ? · Toggle this help +" +`; + +exports[`screen baseline / compact / unicode > 'help/commands' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Commands + :search · Search stations (alias :s) + :country · Open a country (alias … + :codec · Filter by codec +> :language · Filter by language (al… + :bitrate · Minimum bitrate filter + :clear · Clear search filters + :volume <0-100> · Set volume (alias :vol) + :mute · Toggle mute +" +`; + +exports[`screen baseline / compact / unicode > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent · 1 favorites · 1 imported +" +`; + +exports[`screen baseline / compact / unicode > 'library' 1`] = ` +"RADIOCLI Library ────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'library/empty' 1`] = ` +"RADIOCLI Library ────────────────────────────── +0 stations · playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'map' 1`] = ` +"RADIOCLI World map ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'nearby' 1`] = ` +"RADIOCLI Nearby ─────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle · vol 70 + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / compact / unicode > 'search' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────┘ + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'search/loading' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────────────────────────────┘ + +Searching station directories… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Choose a category · Updates are available here + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/agent' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Agent control & MCP · Enter changes setting · b… + +> Allow local agent con… off + Install or repair MCP… run repair + Open TUI for agent pl… on + Show Now Playing for … on + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Appearance · Enter changes setting · b categori… + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad sc… auto + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/data' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Data & library · Enter changes setting · b cate… + +> Export preferences an… JSON backup + Import preferences an… restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Discovery & providers · Enter changes setting ·… + +> Nearby location off + Radio Garden adapter off + Share favorite votes … on + Refresh provider heal… + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Media keys · Enter changes setting · b categori… + +> Learn previous media … + Learn play/pause medi… + Learn next media key + Reset learned media k… prev 0 · play 0 · ne… + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/playback' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Playback & audio · Enter changes setting · b ca… + +> Audio output Automatic + AirPlay receiver Living Room · 1 rece… + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o… on + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/updates' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Updates · Enter changes setting · b categories + +> Automatically check f… on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'stations' 1`] = ` +"RADIOCLI Stations ───────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'stats' 1`] = ` +"RADIOCLI Listening stats ────────────────────── +Stored locally on this machine + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +Streak · 2 current · 2 best +··························■■ · 2 active days + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ───────────────────────── +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ────────────────────────────── +1 receivers · mpv + +> Living Room · speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do… +REVIEW +› Save alarm · next Mon, Se… + Cancel + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +› Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK +› Volume: 70% [███████···]… + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM — … + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-picker' 1`] = ` +"Primary station ─────────────────────────────── +Favorites, imports, recents, current station, a… + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────────────────────────────── +Favorites, imports, recents, current station, a… + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio +Started 1m ago · scheduled Mon, Sep 7, 11:59 AM… + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms/degraded' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Repair needed +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduling needs repair · press r +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms/empty' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +No upcoming alarms. + +› Create alarm + Verify alarm setup · scheduler, terminal, pow… + + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ───────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before +relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / unicode-no-color > 'countries' 1`] = ` +"RADIOCLI Countries ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E · 2 stations + + ⢀ ⠄⠤⠄⠤⣤⣴⣦ ⠠ ⢀⣠⡀ > KEXP 90.3 FM · ★ + ⢾⠿⠶⣷⣿⣷⡊⢾⡀⠙⠋⠑⠂⡀⣴⣽⣶⣶⣶⣿⣿⣿⣿⣿⡿⠿⡳⠗ KCRW 89.9 FM — … + ⢹⣿⣿⣿⡿⠋ ⠈●⠟⣟⣛⡿⣿⣿⣿⣿⣿⢿⢋ + ⠛⡟⡋ ⣰⣿⣷⣶⣾⣟⠛⢿⡿⣿⠿⠆⠁ + ⠈⠁⣠⣤⡀ ⠹⠿⢿⣿⣷⠅ ⠘ ⢈⢂⠁ + ⢿⣿⣿⡆ ⢸⣿⡯⡀ ⠈⠈⢁⡐⠂ + ⢠⣿⠏⠁ ⠘⡿⠃⠃ ⠐⡿⢿⡷⠈ + ⢸⢋ ⠃ ⠈ + ⢀⡀ ⣀⣀⣀⢀⣀⣀⣀⣀⣀⣀⡀ +⣀⣀⣶⣶⣶⣷⣾⣿⣭⣥⣴⣴⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣀" +`; + +exports[`screen baseline / compact / unicode-no-color > 'help' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Navigation + ↑/↓ or n/p · Move selection + ←/→ or Tab · Switch tabs + [ / ] · Page selection by 10 + 1-9 · Jump to an Overview option + Enter · Open / tune the selection + b or Esc · Back to home + : · Command palette + ? · Toggle this help +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'help/commands' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Commands + :search · Search stations (alias :s) + :country · Open a country (alias … + :codec · Filter by codec +> :language · Filter by language (al… + :bitrate · Minimum bitrate filter + :clear · Clear search filters + :volume <0-100> · Set volume (alias :vol) + :mute · Toggle mute +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent · 1 favorites · 1 imported +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'library' 1`] = ` +"RADIOCLI Library ────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'library/empty' 1`] = ` +"RADIOCLI Library ────────────────────────────── +0 stations · playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'map' 1`] = ` +"RADIOCLI World map ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'nearby' 1`] = ` +"RADIOCLI Nearby ─────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle · vol 70 + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'search' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────┘ + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'search/loading' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────────────────────────────┘ + +Searching station directories… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Choose a category · Updates are available here + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/agent' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Agent control & MCP · Enter changes setting · b… + +> Allow local agent con… off + Install or repair MCP… run repair + Open TUI for agent pl… on + Show Now Playing for … on + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Appearance · Enter changes setting · b categori… + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad sc… auto + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/data' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Data & library · Enter changes setting · b cate… + +> Export preferences an… JSON backup + Import preferences an… restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Discovery & providers · Enter changes setting ·… + +> Nearby location off + Radio Garden adapter off + Share favorite votes … on + Refresh provider heal… + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Media keys · Enter changes setting · b categori… + +> Learn previous media … + Learn play/pause medi… + Learn next media key + Reset learned media k… prev 0 · play 0 · ne… + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/playback' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Playback & audio · Enter changes setting · b ca… + +> Audio output Automatic + AirPlay receiver Living Room · 1 rece… + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o… on + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/updates' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Updates · Enter changes setting · b categories + +> Automatically check f… on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'stations' 1`] = ` +"RADIOCLI Stations ───────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'stats' 1`] = ` +"RADIOCLI Listening stats ────────────────────── +Stored locally on this machine + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +Streak · 2 current · 2 best +··························■■ · 2 active days + + + +" +`; + +exports[`screen baseline / full / ascii > 'airplay-code' 1`] = ` +"AirPlay Code ---------------------------------------------------------------------- Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits . Backspace edits . Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'airplay-settings' 1`] = ` +"AirPlay ----------------------------------------------------------------------------------- Ready +Choose where AirPlay playback should go + +Audio output: Automatic . Streaming: ready . Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected . speaker.local:7000 . AirPlay 2 . code shown on receiver +Enter selects . c opens code entry . r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/save' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW +> Save alarm . next Mon, Sep 7, 1:30 PM (you. + Cancel + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/time' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring +> Time: 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/volume' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK +> Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +WEEKDAYS + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday + * Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-picker' 1`] = ` +"Primary station --------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-picker/fallback' 1`] = ` +"Fallback station -------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-ringing' 1`] = ` +"Alarm ringing ----------------------------------------------------------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio + Started 1m ago . scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing . Space stops the radio until snooze ends . b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarms' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii > 'alarms/degraded' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Degraded . Native scheduler ready. +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM . needs repair . press x again to de + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii > 'alarms/empty' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +No enabled future alarm. + +> Create alarm + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ----------------------------------------------------------- +Rehearsing "Morning radio" on this machine +READY WITH LIMITATIONS . Review warnings before relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / ascii > 'countries' 1`] = ` +"Countries --------------------------------------------------------------------------------------- +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'explore' 1`] = ` +"Explore ----------------------------------------------------------------------------------------- +Saved and discovered stations + ++-------------------------------------------------------------+ +--------------------------------+ +| .::::.::::. | |Stations 2| +| .:-::#*%-:-*@@@@@%. .-- .: :-#*++.. .: | | | +|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM * | +| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM - A deliberately.| +| :#@@@@@@%@@#*. +@%@@@%%@@%@@@@@@@@@@@@@@+ . | | | +| -@@@@@@@%: #*::-##*%%*@@@@@@@@@@%#+.+ | | | +| -%@@###. .%@@%-*+#@%@@@@@@@@@@@% .: | | | +| . +@ :..: :@@@@@@@@%%@#*:*@@+%@%*- | | | +| .:+-.:: -@@@@@@@@@##. %. .#* | | | +| .#@@%* .+--@@@@@@* . + ---. | | | +| @@@@@%#: *@@@@: .:+-.::*-: | | | +| +@@@@@@. -@@@@-- :++-. | | | +| +@@@%- .@@@- + .#@@@@%. | | | +| +@%*. +#+ %*+#@@- | | | +| @#: :- ::| | | +| :@ .. | | | +| .. | | | +| .+. :::::--+#++.:+++***+++*++::: | | | +| .:+*######@###*--..: -+*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+. | | | +|##++*%@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*| | | ++-------------------------------------------------------------+ +--------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'help' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +Navigation +^/v or n/p Move selection + or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi. Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f / Ctrl+F Favorite the station (Ctrl+F while typing in Search) +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 . ^/v scroll" +`; + +exports[`screen baseline / full / ascii > 'help/commands' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ################## +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent . 1 favorites . 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'library' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'library/empty' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'map' 1`] = ` +"World map ------------------------------------------------------------------------------------- +Station density by country + +. .................. . . .................... . +. ............................ .... ....................................... +. ........................................ .............................................. +. ...................................... ............................................... +. ................US.......... . .....................................JP.... +................................................................................................ +. . ................. . ................ ....................... +. . ..... ......... ................ ................ +. . . .......... ............... ............. +. . . ........... ........... . . .......... +. . . ........... . .... . . .............. +. . . .......... . . . .......... +. . . ......... . . . . ... +. . . . . . . . +2 plotted . 0 unplaced . labels show highest station counts + +Highest station counts > US . United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / ascii > 'nearby' 1`] = ` +"Nearby ------------------------------------------------------------------------------------------ +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'now-playing' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o @ @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| . o @ o o o @ o . . . o o @ o o o o @ o o o o o o o @ @ o o o o @ o . . . . o @ o o o o o | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'now-playing/diagnostics' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o o o . . . o o @ @ @ o o . . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . o o o o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . . o @ o o @ o . . . o o @ o o o o @ o . . . o o o o o @ o . . . o o o o @ | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o o @ o . . . o @ o o o @ o . . . . . . . . . . . o o @ o o o o o . . . o @ o o @ o | +| | +| Bjork - Joga * Favorite | +| | +| Stream: none | +| Station time: 12:00 | +| Started: 11:30:00 AM . available mpv, airplay | +| | +| . Bjork - Joga | +| . Aphex Twin - Avril 14th | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'now-playing/idle' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| No station tuned IDLE | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| ########################################################################################## | +| | +| Playback backend ready. Choose a station to start tuning. * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'search' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'search/loading' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +Searching station directories. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Choose a category + +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/agent' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba. on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/appearance' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/data' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/discovery' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B. on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/media-keys' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 . play 0 . next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/playback' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room . 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) . playing . volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/updates' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'stations' 1`] = ` +"United States ----------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'stats' 1`] = ` +"Listening stats --------------------------------------------------------------------------------- +Local listening history . never leaves this machine + ++------------------------------------------------------------------------------------------------+ +|Activity - 2026 | +| Dec Jan Feb Mar Apr May Jun Jul Aug Sep | +| | +|Mon | +| | +|Wed | +| | +|Fri | +| | +| | ++------------------------------------------------------------------------------------------------+ + ++------------------------------------------------------------------------------------------------+ +|Summary | +|Favorite station: KEXP 90.3 FM Total hours listened: 1.5h | +|Sessions: 2 Longest streak: 2 days | +|Current streak: 2 days Stations listened: 2 | +|Active days: 2/371 Stations counted after: 2 min | +| | +|Less . More | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'airplay-code' 1`] = ` +"AirPlay Code ---------------------------------------------------------------------- Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits . Backspace edits . Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'airplay-settings' 1`] = ` +"AirPlay ----------------------------------------------------------------------------------- Ready +Choose where AirPlay playback should go + +Audio output: Automatic . Streaming: ready . Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected . speaker.local:7000 . AirPlay 2 . code shown on receiver +Enter selects . c opens code entry . r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW +> Save alarm . next Mon, Sep 7, 1:30 PM (you. + Cancel + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring +> Time: 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK +> Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +WEEKDAYS + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday + * Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-picker' 1`] = ` +"Primary station --------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station -------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ----------------------------------------------------------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio + Started 1m ago . scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing . Space stops the radio until snooze ends . b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms/degraded' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Degraded . Native scheduler ready. +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM . needs repair . press x again to de + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms/empty' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +No enabled future alarm. + +> Create alarm + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ----------------------------------------------------------- +Rehearsing "Morning radio" on this machine +READY WITH LIMITATIONS . Review warnings before relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / ascii-no-color > 'countries' 1`] = ` +"Countries --------------------------------------------------------------------------------------- +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'explore' 1`] = ` +"Explore ----------------------------------------------------------------------------------------- +Saved and discovered stations + ++-------------------------------------------------------------+ +--------------------------------+ +| .::::.::::. | |Stations 2| +| .:-::#*%-:-*@@@@@%. .-- .: :-#*++.. .: | | | +|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM * | +| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM - A deliberately.| +| :#@@@@@@%@@#*. +@%@@@%%@@%@@@@@@@@@@@@@@+ . | | | +| -@@@@@@@%: #*::-##*%%*@@@@@@@@@@%#+.+ | | | +| -%@@###. .%@@%-*+#@%@@@@@@@@@@@% .: | | | +| . +@ :..: :@@@@@@@@%%@#*:*@@+%@%*- | | | +| .:+-.:: -@@@@@@@@@##. %. .#* | | | +| .#@@%* .+--@@@@@@* . + ---. | | | +| @@@@@%#: *@@@@: .:+-.::*-: | | | +| +@@@@@@. -@@@@-- :++-. | | | +| +@@@%- .@@@- + .#@@@@%. | | | +| +@%*. +#+ %*+#@@- | | | +| @#: :- ::| | | +| :@ .. | | | +| .. | | | +| .+. :::::--+#++.:+++***+++*++::: | | | +| .:+*######@###*--..: -+*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+. | | | +|##++*%@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*| | | ++-------------------------------------------------------------+ +--------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'help' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +Navigation +^/v or n/p Move selection + or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi. Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f / Ctrl+F Favorite the station (Ctrl+F while typing in Search) +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 . ^/v scroll" +`; + +exports[`screen baseline / full / ascii-no-color > 'help/commands' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ################## +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent . 1 favorites . 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'library' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'library/empty' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'map' 1`] = ` +"World map ------------------------------------------------------------------------------------- +Station density by country + +. .................. . . .................... . +. ............................ .... ....................................... +. ........................................ .............................................. +. ...................................... ............................................... +. ................US.......... . .....................................JP.... +................................................................................................ +. . ................. . ................ ....................... +. . ..... ......... ................ ................ +. . . .......... ............... ............. +. . . ........... ........... . . .......... +. . . ........... . .... . . .............. +. . . .......... . . . .......... +. . . ......... . . . . ... +. . . . . . . . +2 plotted . 0 unplaced . labels show highest station counts + +Highest station counts > US . United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'nearby' 1`] = ` +"Nearby ------------------------------------------------------------------------------------------ +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'now-playing' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o @ @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| . o @ o o o @ o . . . o o @ o o o o @ o o o o o o o @ @ o o o o @ o . . . . o @ o o o o o | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'now-playing/diagnostics' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o o o . . . o o @ @ @ o o . . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . o o o o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . . o @ o o @ o . . . o o @ o o o o @ o . . . o o o o o @ o . . . o o o o @ | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o o @ o . . . o @ o o o @ o . . . . . . . . . . . o o @ o o o o o . . . o @ o o @ o | +| | +| Bjork - Joga * Favorite | +| | +| Stream: none | +| Station time: 12:00 | +| Started: 11:30:00 AM . available mpv, airplay | +| | +| . Bjork - Joga | +| . Aphex Twin - Avril 14th | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'now-playing/idle' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| No station tuned IDLE | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| ########################################################################################## | +| | +| Playback backend ready. Choose a station to start tuning. * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'search' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'search/loading' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +Searching station directories. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Choose a category + +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/agent' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba. on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/appearance' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/data' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/discovery' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B. on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/media-keys' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 . play 0 . next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/playback' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room . 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) . playing . volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/updates' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'stations' 1`] = ` +"United States ----------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'stats' 1`] = ` +"Listening stats --------------------------------------------------------------------------------- +Local listening history . never leaves this machine + ++------------------------------------------------------------------------------------------------+ +|Activity - 2026 | +| Dec Jan Feb Mar Apr May Jun Jul Aug Sep | +| | +|Mon | +| | +|Wed | +| | +|Fri | +| | +| | ++------------------------------------------------------------------------------------------------+ + ++------------------------------------------------------------------------------------------------+ +|Summary | +|Favorite station: KEXP 90.3 FM Total hours listened: 1.5h | +|Sessions: 2 Longest streak: 2 days | +|Current streak: 2 days Stations listened: 2 | +|Active days: 2/371 Stations counted after: 2 min | +| | +|Less . More | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / unicode > 'airplay-code' 1`] = ` +"AirPlay Code ────────────────────────────────────────────────────────────────────── Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits · Backspace edits · Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'airplay-settings' 1`] = ` +"AirPlay ─────────────────────────────────────────────────────────────────────────────────── Ready +Choose where AirPlay playback should go + +Audio output: Automatic · Streaming: ready · Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver +Enter selects · c opens code entry · r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/save' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW +› Save alarm · next Mon, Sep 7, 1:30 PM (you… + Cancel + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/time' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring +› Time: 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/volume' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK +› Volume: 70% [███████···] ←/→ Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +WEEKDAYS + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday + ○ Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-picker' 1`] = ` +"Primary station ───────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-picker/fallback' 1`] = ` +"Fallback station ──────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-ringing' 1`] = ` +"Alarm ringing ─────────────────────────────────────────────────────────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio + Started 1m ago · scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing · Space stops the radio until snooze ends · b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarms' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode > 'alarms/degraded' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Degraded · Native scheduler ready. +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM · needs repair · press x again to de + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode > 'alarms/empty' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +No enabled future alarm. + +› Create alarm + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ─────────────────────────────────────────────────────────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / unicode > 'countries' 1`] = ` +"Countries ─────────────────────────────────────────────────────────────────────────────────────── +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'explore' 1`] = ` +"Explore ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +┌─────────────────────────────────────────────────────────────┐ ┌────────────────────────────────┐ +│ ⢀⣀⣀⣀⣀⢀⣀⣀⣀⣀⢀ │ │Stations 2│ +│ ⢀⡠⠴⡄⢠⣏⢵⡿⡉⠑⠚⠻⣿⣿⣿⣿⣿⡿⠁ ⠈⠋⠋ ⢀⠤ ⣀⣠⣭⣥⣤⣤⡀⢀ ⠠⠤ │ │ │ +│⠤⠄⠰⣾⣷⣶⣶⣶⣶⣶⣾⣭⣯⣤⣽⡮⡏⠙⣷⡤ ⢸⣿⡿⠟⠛⢨⣠ ⣠⣴⢾⣷⡲⣤⣤⣬⣤⣶⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣷⣶⣶⡶│ │> KEXP 90.3 FM ★ │ +│ ⠈⠻⠋⠉⠙⠛⢿⣿⣿⣿⣿⣿⣧⣀⡀⣸⣦⣤⡀ ⠉⠁ ⣠ ⠘⠿⠇⣭⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠛⠋⣽⠈⠉ │ │ KCRW 89.9 FM — A deliberately…│ +│ ⠘⢻⣿⣿⣿⣿⣿⣿⣷⣿⣿⡟⡫⠄ ⠹●⣾⣿⣿⣿⡿⡿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛ ⠁ │ │ │ +│ ⠸⣿⣿⣿⣿⣿⣿⣿⡿⠃ ⣶⠟⠉⠑⠍⡟⣧⣦⣽⣷⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡻⡋⠁⡎ │ │ │ +│ ⠙⣻⣿⣿⠿⠿⢟⠁ ⢀⣾⣿⣿⣷⣄⣦⣤⣼⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷ ⠐⠉ │ │ │ +│ ⠠ ⠹⣿ ⡠⠈⠄⣀ ⢠⣿⣿⣿⣿⣿⣿⣿⣿⣯⢿⣿⣶⡏⠉⠻⣿⣿⠛⢿⣿⡿⠟⠋ │ │ │ +│ ⠈⠉⠳⡆⢀⣀⣀ ⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⣛⠁ ⢿⠁ ⠈⡟⠷ │ │ │ +│ ⠈⣹⣿⣿⣷⣦ ⠈⠛⠋⠙⣿⣿⣿⣿⣿⣿⠟ ⠁ ⢴ ⣠⡆⠙⡀ │ │ │ +│ ⣿⣿⣿⣿⣿⣷⣶⡄ ⢹⣿⣿⣿⣿⠃ ⠈⠆⣉⠙⠁⠊⠒⣦⡤⠢ │ │ │ +│ ⠹⣿⣿⣿⣿⣿⣿⠁ ⢨⣿⣿⣿⣿⠇⣠ ⢠⣰⣆⢰⠈ │ │ │ +│ ⢸⣿⣿⣿⡿⠋ ⠈⣿⣿⣿⠇ ⠏ ⢀⣶⣿⣿⣿⣿⣷⡀ │ │ │ +│ ⢸⣿⡿⠟⠁ ⠹⠿⠏ ⡿⠟⠛⢻⣿⣿⠇ │ │ │ +│ ⣿⡟⠉ ⠉⠍ ⡠⠃│ │ │ +│ ⠰⣿ ⠈⠁ │ │ │ +│ ⠈⠁ │ │ │ +│ ⢀⣔⠁ ⣀⣀⣀⣀⣀⣄⣠⣤⣶⣤⣤⠄⣀⣤⣤⣤⣴⣦⣴⣤⣤⣤⣦⣤⣤⣀⣀⣀ │ │ │ +│ ⠠⠤⢴⣴⣶⣶⣶⣶⣶⣶⣿⣶⣶⣶⡶⠚⠋⡀⢀⣀ ⢤⣤⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠁ │ │ │ +│⣶⣶⣤⣤⣬⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⣦│ │ │ +└─────────────────────────────────────────────────────────────┘ └────────────────────────────────┘" +`; + +exports[`screen baseline / full / unicode > 'help' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +Navigation +↑/↓ or n/p Move selection +←/→ or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi… Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f / Ctrl+F Favorite the station (Ctrl+F while typing in Search) +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 · ↑/↓ scroll" +`; + +exports[`screen baseline / full / unicode > 'help/commands' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ██████████████████ +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent · 1 favorites · 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'library' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'library/empty' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'map' 1`] = ` +"World map ───────────────────────────────────────────────────────────────────────────────────── +Station density by country + +· ·················· · · ···················· · +· ···························· ···· ······································· +· ········································ ·············································· +· ······································ ··············································· +· ················US·········· · ·····································JP···· +································································································ +· · ················· · ················ ······················· +· · ····· ········· ················ ················ +· · · ·········· ··············· ············· +· · · ··········· ··········· · · ·········· +· · · ··········· · ···· · · ·············· +· · · ·········· · · · ·········· +· · · ········· · · · · ··· +· · · · · · · · +2 plotted · 0 unplaced · labels show highest station counts + +Highest station counts > US · United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / unicode > 'nearby' 1`] = ` +"Nearby ────────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'now-playing' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ · ∘ ● ◉ ◉ ◉ ● ∘ · · · ∘ • ● ◉ ◉ ◉ ◉ ● • • ∘ ∘ ∘ ∘ • ● ● ◉ ◉ ◉ ◉ ● • · · · · • ● ◉ ◉ ◉ • ∘ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode > 'now-playing/diagnostics' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • ∘ · · · ∘ • ● ● ● • ∘ · · · · • ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ● ∘ · · · · · · · · · · · ∘ • ● ◉ ◉ ◉ • ∘ · · · • ● ◉ ◉ ● • │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +│ Stream: none │ +│ Station time: 12:00 │ +│ Started: 11:30:00 AM · available mpv, airplay │ +│ │ +│ · Bjork - Joga │ +│ · Aphex Twin - Avril 14th │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode > 'now-playing/idle' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No station tuned IDLE │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ +│ │ +│ Playback backend ready. Choose a station to start tuning. ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode > 'search' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'search/loading' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +Searching station directories… + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Choose a category + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/agent' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba… on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/appearance' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/data' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/discovery' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B… on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/media-keys' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 · play 0 · next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/playback' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room · 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) · playing · volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/updates' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'stations' 1`] = ` +"United States ─────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'stats' 1`] = ` +"Listening stats ───────────────────────────────────────────────────────────────────────────────── +Local listening history · never leaves this machine + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Activity — 2026 │ +│ Dec Jan Feb Mar Apr May Jun Jul Aug Sep │ +│ │ +│Mon │ +│ │ +│Wed │ +│ │ +│Fri │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Summary │ +│Favorite station: KEXP 90.3 FM Total hours listened: 1.5h │ +│Sessions: 2 Longest streak: 2 days │ +│Current streak: 2 days Stations listened: 2 │ +│Active days: 2/371 Stations counted after: 2 min │ +│ │ +│Less · More │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`screen baseline / full / unicode-no-color > 'airplay-code' 1`] = ` +"AirPlay Code ────────────────────────────────────────────────────────────────────── Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits · Backspace edits · Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'airplay-settings' 1`] = ` +"AirPlay ─────────────────────────────────────────────────────────────────────────────────── Ready +Choose where AirPlay playback should go + +Audio output: Automatic · Streaming: ready · Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver +Enter selects · c opens code entry · r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW +› Save alarm · next Mon, Sep 7, 1:30 PM (you… + Cancel + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring +› Time: 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK +› Volume: 70% [███████···] ←/→ Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +WEEKDAYS + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday + ○ Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-picker' 1`] = ` +"Primary station ───────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ──────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ─────────────────────────────────────────────────────────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio + Started 1m ago · scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing · Space stops the radio until snooze ends · b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms/degraded' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Degraded · Native scheduler ready. +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM · needs repair · press x again to de + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms/empty' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +No enabled future alarm. + +› Create alarm + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ─────────────────────────────────────────────────────────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / unicode-no-color > 'countries' 1`] = ` +"Countries ─────────────────────────────────────────────────────────────────────────────────────── +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'explore' 1`] = ` +"Explore ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +┌─────────────────────────────────────────────────────────────┐ ┌────────────────────────────────┐ +│ ⢀⣀⣀⣀⣀⢀⣀⣀⣀⣀⢀ │ │Stations 2│ +│ ⢀⡠⠴⡄⢠⣏⢵⡿⡉⠑⠚⠻⣿⣿⣿⣿⣿⡿⠁ ⠈⠋⠋ ⢀⠤ ⣀⣠⣭⣥⣤⣤⡀⢀ ⠠⠤ │ │ │ +│⠤⠄⠰⣾⣷⣶⣶⣶⣶⣶⣾⣭⣯⣤⣽⡮⡏⠙⣷⡤ ⢸⣿⡿⠟⠛⢨⣠ ⣠⣴⢾⣷⡲⣤⣤⣬⣤⣶⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣷⣶⣶⡶│ │> KEXP 90.3 FM ★ │ +│ ⠈⠻⠋⠉⠙⠛⢿⣿⣿⣿⣿⣿⣧⣀⡀⣸⣦⣤⡀ ⠉⠁ ⣠ ⠘⠿⠇⣭⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠛⠋⣽⠈⠉ │ │ KCRW 89.9 FM — A deliberately…│ +│ ⠘⢻⣿⣿⣿⣿⣿⣿⣷⣿⣿⡟⡫⠄ ⠹●⣾⣿⣿⣿⡿⡿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛ ⠁ │ │ │ +│ ⠸⣿⣿⣿⣿⣿⣿⣿⡿⠃ ⣶⠟⠉⠑⠍⡟⣧⣦⣽⣷⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡻⡋⠁⡎ │ │ │ +│ ⠙⣻⣿⣿⠿⠿⢟⠁ ⢀⣾⣿⣿⣷⣄⣦⣤⣼⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷ ⠐⠉ │ │ │ +│ ⠠ ⠹⣿ ⡠⠈⠄⣀ ⢠⣿⣿⣿⣿⣿⣿⣿⣿⣯⢿⣿⣶⡏⠉⠻⣿⣿⠛⢿⣿⡿⠟⠋ │ │ │ +│ ⠈⠉⠳⡆⢀⣀⣀ ⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⣛⠁ ⢿⠁ ⠈⡟⠷ │ │ │ +│ ⠈⣹⣿⣿⣷⣦ ⠈⠛⠋⠙⣿⣿⣿⣿⣿⣿⠟ ⠁ ⢴ ⣠⡆⠙⡀ │ │ │ +│ ⣿⣿⣿⣿⣿⣷⣶⡄ ⢹⣿⣿⣿⣿⠃ ⠈⠆⣉⠙⠁⠊⠒⣦⡤⠢ │ │ │ +│ ⠹⣿⣿⣿⣿⣿⣿⠁ ⢨⣿⣿⣿⣿⠇⣠ ⢠⣰⣆⢰⠈ │ │ │ +│ ⢸⣿⣿⣿⡿⠋ ⠈⣿⣿⣿⠇ ⠏ ⢀⣶⣿⣿⣿⣿⣷⡀ │ │ │ +│ ⢸⣿⡿⠟⠁ ⠹⠿⠏ ⡿⠟⠛⢻⣿⣿⠇ │ │ │ +│ ⣿⡟⠉ ⠉⠍ ⡠⠃│ │ │ +│ ⠰⣿ ⠈⠁ │ │ │ +│ ⠈⠁ │ │ │ +│ ⢀⣔⠁ ⣀⣀⣀⣀⣀⣄⣠⣤⣶⣤⣤⠄⣀⣤⣤⣤⣴⣦⣴⣤⣤⣤⣦⣤⣤⣀⣀⣀ │ │ │ +│ ⠠⠤⢴⣴⣶⣶⣶⣶⣶⣶⣿⣶⣶⣶⡶⠚⠋⡀⢀⣀ ⢤⣤⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠁ │ │ │ +│⣶⣶⣤⣤⣬⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⣦│ │ │ +└─────────────────────────────────────────────────────────────┘ └────────────────────────────────┘" +`; + +exports[`screen baseline / full / unicode-no-color > 'help' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +Navigation +↑/↓ or n/p Move selection +←/→ or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi… Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f / Ctrl+F Favorite the station (Ctrl+F while typing in Search) +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 · ↑/↓ scroll" +`; + +exports[`screen baseline / full / unicode-no-color > 'help/commands' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ██████████████████ +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent · 1 favorites · 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'library' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'library/empty' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'map' 1`] = ` +"World map ───────────────────────────────────────────────────────────────────────────────────── +Station density by country + +· ·················· · · ···················· · +· ···························· ···· ······································· +· ········································ ·············································· +· ······································ ··············································· +· ················US·········· · ·····································JP···· +································································································ +· · ················· · ················ ······················· +· · ····· ········· ················ ················ +· · · ·········· ··············· ············· +· · · ··········· ··········· · · ·········· +· · · ··········· · ···· · · ·············· +· · · ·········· · · · ·········· +· · · ········· · · · · ··· +· · · · · · · · +2 plotted · 0 unplaced · labels show highest station counts + +Highest station counts > US · United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'nearby' 1`] = ` +"Nearby ────────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'now-playing' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ · ∘ ● ◉ ◉ ◉ ● ∘ · · · ∘ • ● ◉ ◉ ◉ ◉ ● • • ∘ ∘ ∘ ∘ • ● ● ◉ ◉ ◉ ◉ ● • · · · · • ● ◉ ◉ ◉ • ∘ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode-no-color > 'now-playing/diagnostics' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • ∘ · · · ∘ • ● ● ● • ∘ · · · · • ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ● ∘ · · · · · · · · · · · ∘ • ● ◉ ◉ ◉ • ∘ · · · • ● ◉ ◉ ● • │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +│ Stream: none │ +│ Station time: 12:00 │ +│ Started: 11:30:00 AM · available mpv, airplay │ +│ │ +│ · Bjork - Joga │ +│ · Aphex Twin - Avril 14th │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode-no-color > 'now-playing/idle' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No station tuned IDLE │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ +│ │ +│ Playback backend ready. Choose a station to start tuning. ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode-no-color > 'search' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'search/loading' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +Searching station directories… + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Choose a category + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/agent' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba… on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/appearance' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/data' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/discovery' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B… on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/media-keys' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 · play 0 · next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/playback' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room · 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) · playing · volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/updates' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'stations' 1`] = ` +"United States ─────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'stats' 1`] = ` +"Listening stats ───────────────────────────────────────────────────────────────────────────────── +Local listening history · never leaves this machine + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Activity — 2026 │ +│ Dec Jan Feb Mar Apr May Jun Jul Aug Sep │ +│ │ +│Mon │ +│ │ +│Wed │ +│ │ +│Fri │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Summary │ +│Favorite station: KEXP 90.3 FM Total hours listened: 1.5h │ +│Sessions: 2 Longest streak: 2 days │ +│Current streak: 2 days Stations listened: 2 │ +│Active days: 2/371 Stations counted after: 2 min │ +│ │ +│Less · More │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`screen baseline / micro / ascii > 'airplay-code' 1`] = ` +"RC / AirPlay code . KEX. + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / ascii > 'airplay-settings' 1`] = ` +"RC / AirPlay . 1 receiv. + +> Living Room . speaker. + + + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +BASICS +> Label: Morning radio + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +REVIEW +> Save alarm . next Mon. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + Schedule: Recurring +> Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +PLAYBACK +> Volume: 70% [#######. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + @ Tuesday +> @ Wednesday + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-picker' 1`] = ` +"Primary station ------- +Favorites, imports, rec. + +> KEXP 90.3 FM . Now pl. + KCRW 89.9 FM - A deli. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------ +Favorites, imports, rec. + +> None . No fallback + KEXP 90.3 FM . Now pl. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-ringing' 1`] = ` +"Alarm ringing --------- +Wake radio . playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM . Morning . + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / ascii > 'alarms' 1`] = ` +"Alarms ---------------- +Scheduler ready +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii > 'alarms/degraded' 1`] = ` +"Alarms ---------------- +Repair scheduler +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii > 'alarms/empty' 1`] = ` +"Alarms ---------------- +Scheduler ready +No upcoming alarms. + +> Create alarm +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii > 'alarms/verification' 1`] = ` +"Alarm setup verifie. -- +Rehearsing "Morning rad. +READY WITH LIMITATIONS . + Review warnings before + Disposable job regis. +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / ascii > 'countries' 1`] = ` +"RC / Countries . 2 coun. + +> United St. 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii > 'explore' 1`] = ` +"RC / Explore . 48.9N, 2. + .:+#+:: @++*#*:. + .+:. +%%-+#-. + :#: -- --. + .::--:-+++++++- +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli." +`; + +exports[`screen baseline / micro / ascii > 'help' 1`] = ` +"RC / Help . Scroll for . + + Navigation + ^/v or n/p . Move sel. + or Tab . Switch t. + [ / ] . Page selectio. +" +`; + +exports[`screen baseline / micro / ascii > 'help/commands' 1`] = ` +"RC / Help . Scroll for . + + :country . + :codec . . +> :language . + :bitrate . Min. +" +`; + +exports[`screen baseline / micro / ascii > 'home' 1`] = ` +"RADIOCLI ######### + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / ascii > 'library' 1`] = ` +"RC / Library . 2 statio. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii > 'library/empty' 1`] = ` +"RC / Library . 0 statio. + +Your library is empty. +Press f on a station to. + + +" +`; + +exports[`screen baseline / micro / ascii > 'map' 1`] = ` +"RC / World map . 2 coun. + +> United St. 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii > 'nearby' 1`] = ` +"RC / Nearby . 2 station. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii > 'now-playing' 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii > 'now-playing/idle' 1`] = ` +"No station tuned . idle + + + + +######################## +" +`; + +exports[`screen baseline / micro / ascii > 'search' 1`] = ` +"SEARCH ++----------------------+ +|> tokyo jazz | ++----------------------+ + +> KEXP 90.3 FM . indie * +" +`; + +exports[`screen baseline / micro / ascii > 'search/loading' 1`] = ` +"SEARCH ++----------------------+ +|* tokyo jazz | ++----------------------+ + +Searching station direc. +" +`; + +exports[`screen baseline / micro / ascii > 'settings' 1`] = ` +"RC / Settings . Choose . + +> Playback . 7 settin. + Appearance 6 settin. + Discovery. 4 settin. + Data & li. 2 settin. +" +`; + +exports[`screen baseline / micro / ascii > 'settings/agent' 1`] = ` +"RC / Settings . Agent c. + +> Allow loc. off + Install o. run repa. + Open TUI . on + Show Now . on +" +`; + +exports[`screen baseline / micro / ascii > 'settings/appearance' 1`] = ` +"RC / Settings . Appeara. + +> Display c. green + Receiver . pulse-gr. + Transpare. off + ASCII-saf. on +" +`; + +exports[`screen baseline / micro / ascii > 'settings/data' 1`] = ` +"RC / Settings . Data & . + +> Export pr. JSON bac. + Import pr. restore . + + +" +`; + +exports[`screen baseline / micro / ascii > 'settings/discovery' 1`] = ` +"RC / Settings . Discove. + +> Nearby lo. off + Radio Gar. off + Share fav. on + Refresh p. +" +`; + +exports[`screen baseline / micro / ascii > 'settings/media-keys' 1`] = ` +"RC / Settings . Media k. + +> Learn pre. + Learn pla. + Learn nex. + Reset lea. prev 0 .. +" +`; + +exports[`screen baseline / micro / ascii > 'settings/playback' 1`] = ` +"RC / Settings . Playbac. + +> Audio out. Automatic + AirPlay r. Living R. + Volume up + Volume do. +" +`; + +exports[`screen baseline / micro / ascii > 'settings/updates' 1`] = ` +"RC / Settings . Updates. + +> Automatic. on + Install u. v0.3.0 a. + + +" +`; + +exports[`screen baseline / micro / ascii > 'stations' 1`] = ` +"RC / Stations . 2 stati. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii > 'stats' 1`] = ` +"RC / Listening stats . . + +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'airplay-code' 1`] = ` +"RC / AirPlay code . KEX. + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'airplay-settings' 1`] = ` +"RC / AirPlay . 1 receiv. + +> Living Room . speaker. + + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +BASICS +> Label: Morning radio + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +REVIEW +> Save alarm . next Mon. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + Schedule: Recurring +> Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +PLAYBACK +> Volume: 70% [#######. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + @ Tuesday +> @ Wednesday + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-picker' 1`] = ` +"Primary station ------- +Favorites, imports, rec. + +> KEXP 90.3 FM . Now pl. + KCRW 89.9 FM - A deli. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------ +Favorites, imports, rec. + +> None . No fallback + KEXP 90.3 FM . Now pl. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing --------- +Wake radio . playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM . Morning . + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms' 1`] = ` +"Alarms ---------------- +Scheduler ready +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms/degraded' 1`] = ` +"Alarms ---------------- +Repair scheduler +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms/empty' 1`] = ` +"Alarms ---------------- +Scheduler ready +No upcoming alarms. + +> Create alarm +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verifie. -- +Rehearsing "Morning rad. +READY WITH LIMITATIONS . + Review warnings before + Disposable job regis. +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / ascii-no-color > 'countries' 1`] = ` +"RC / Countries . 2 coun. + +> United St. 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'explore' 1`] = ` +"RC / Explore . 48.9N, 2. + .:+#+:: @++*#*:. + .+:. +%%-+#-. + :#: -- --. + .::--:-+++++++- +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli." +`; + +exports[`screen baseline / micro / ascii-no-color > 'help' 1`] = ` +"RC / Help . Scroll for . + + Navigation + ^/v or n/p . Move sel. + or Tab . Switch t. + [ / ] . Page selectio. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'help/commands' 1`] = ` +"RC / Help . Scroll for . + + :country . + :codec . . +> :language . + :bitrate . Min. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'home' 1`] = ` +"RADIOCLI ######### + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'library' 1`] = ` +"RC / Library . 2 statio. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'library/empty' 1`] = ` +"RC / Library . 0 statio. + +Your library is empty. +Press f on a station to. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'map' 1`] = ` +"RC / World map . 2 coun. + +> United St. 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'nearby' 1`] = ` +"RC / Nearby . 2 station. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'now-playing' 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'now-playing/idle' 1`] = ` +"No station tuned . idle + + + + +######################## +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'search' 1`] = ` +"SEARCH ++----------------------+ +|> tokyo jazz | ++----------------------+ + +> KEXP 90.3 FM . indie * +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'search/loading' 1`] = ` +"SEARCH ++----------------------+ +|* tokyo jazz | ++----------------------+ + +Searching station direc. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings' 1`] = ` +"RC / Settings . Choose . + +> Playback . 7 settin. + Appearance 6 settin. + Discovery. 4 settin. + Data & li. 2 settin. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/agent' 1`] = ` +"RC / Settings . Agent c. + +> Allow loc. off + Install o. run repa. + Open TUI . on + Show Now . on +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/appearance' 1`] = ` +"RC / Settings . Appeara. + +> Display c. green + Receiver . pulse-gr. + Transpare. off + ASCII-saf. on +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/data' 1`] = ` +"RC / Settings . Data & . + +> Export pr. JSON bac. + Import pr. restore . + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/discovery' 1`] = ` +"RC / Settings . Discove. + +> Nearby lo. off + Radio Gar. off + Share fav. on + Refresh p. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/media-keys' 1`] = ` +"RC / Settings . Media k. + +> Learn pre. + Learn pla. + Learn nex. + Reset lea. prev 0 .. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/playback' 1`] = ` +"RC / Settings . Playbac. + +> Audio out. Automatic + AirPlay r. Living R. + Volume up + Volume do. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/updates' 1`] = ` +"RC / Settings . Updates. + +> Automatic. on + Install u. v0.3.0 a. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'stations' 1`] = ` +"RC / Stations . 2 stati. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'stats' 1`] = ` +"RC / Listening stats . . + +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +" +`; + +exports[`screen baseline / micro / unicode > 'airplay-code' 1`] = ` +"RC / AirPlay code · KEX… + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / unicode > 'airplay-settings' 1`] = ` +"RC / AirPlay · 1 receiv… + +> Living Room · speaker… + + + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +BASICS +› Label: Morning radio + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +REVIEW +› Save alarm · next Mon… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + Schedule: Recurring +› Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +PLAYBACK +› Volume: 70% [███████… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + ● Tuesday +› ● Wednesday + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-picker' 1`] = ` +"Primary station ─────── +Favorites, imports, rec… + +› KEXP 90.3 FM · Now pl… + KCRW 89.9 FM — A deli… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────── +Favorites, imports, rec… + +› None · No fallback + KEXP 90.3 FM · Now pl… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────── +Wake radio · playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM · Morning … + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / unicode > 'alarms' 1`] = ` +"Alarms ──────────────── +Scheduler ready +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode > 'alarms/degraded' 1`] = ` +"Alarms ──────────────── +Repair scheduler +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode > 'alarms/empty' 1`] = ` +"Alarms ──────────────── +Scheduler ready +No upcoming alarms. + +› Create alarm +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode > 'alarms/verification' 1`] = ` +"Alarm setup verifie… ── +Rehearsing “Morning rad… +READY WITH LIMITATIONS · + Review warnings before + Disposable job regis… +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / unicode > 'countries' 1`] = ` +"RC / Countries · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode > 'explore' 1`] = ` +"RC / Explore · 48.9N, 2… + ⠠⠤⢴⣶⣢⡐⠒ ●⣤⣤⣴⣶⣦⠤⠠ + ⠈⠫⢁⡀ ⢸⣾⣟⠜⠛⠿⡃⠁ + ⢈⠿⠃ ⠸⠇ ⠴⠥⢀ + ⢀⣀⣀⣈⣄⣀⣠⣤⣤⣤⣤⣤⣤⣤⣄ +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli…" +`; + +exports[`screen baseline / micro / unicode > 'help' 1`] = ` +"RC / Help · Scroll for … + + Navigation + ↑/↓ or n/p · Move sel… + ←/→ or Tab · Switch t… + [ / ] · Page selectio… +" +`; + +exports[`screen baseline / micro / unicode > 'help/commands' 1`] = ` +"RC / Help · Scroll for … + + :country … + :codec · … +> :language … + :bitrate · Min… +" +`; + +exports[`screen baseline / micro / unicode > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / unicode > 'library' 1`] = ` +"RC / Library · 2 statio… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode > 'library/empty' 1`] = ` +"RC / Library · 0 statio… + +Your library is empty. +Press f on a station to… + + +" +`; + +exports[`screen baseline / micro / unicode > 'map' 1`] = ` +"RC / World map · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode > 'nearby' 1`] = ` +"RC / Nearby · 2 station… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode > 'now-playing' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode > 'now-playing/idle' 1`] = ` +"No station tuned · idle + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / micro / unicode > 'search' 1`] = ` +"SEARCH +┌──────────────────────┐ +│› tokyo jazz │ +└──────────────────────┘ + +> KEXP 90.3 FM · indie ★ +" +`; + +exports[`screen baseline / micro / unicode > 'search/loading' 1`] = ` +"SEARCH +┌──────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────┘ + +Searching station direc… +" +`; + +exports[`screen baseline / micro / unicode > 'settings' 1`] = ` +"RC / Settings · Choose … + +> Playback … 7 settin… + Appearance 6 settin… + Discovery… 4 settin… + Data & li… 2 settin… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/agent' 1`] = ` +"RC / Settings · Agent c… + +> Allow loc… off + Install o… run repa… + Open TUI … on + Show Now … on +" +`; + +exports[`screen baseline / micro / unicode > 'settings/appearance' 1`] = ` +"RC / Settings · Appeara… + +> Display c… green + Receiver … pulse-gr… + Transpare… off + ASCII-saf… off +" +`; + +exports[`screen baseline / micro / unicode > 'settings/data' 1`] = ` +"RC / Settings · Data & … + +> Export pr… JSON bac… + Import pr… restore … + + +" +`; + +exports[`screen baseline / micro / unicode > 'settings/discovery' 1`] = ` +"RC / Settings · Discove… + +> Nearby lo… off + Radio Gar… off + Share fav… on + Refresh p… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/media-keys' 1`] = ` +"RC / Settings · Media k… + +> Learn pre… + Learn pla… + Learn nex… + Reset lea… prev 0 ·… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/playback' 1`] = ` +"RC / Settings · Playbac… + +> Audio out… Automatic + AirPlay r… Living R… + Volume up + Volume do… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/updates' 1`] = ` +"RC / Settings · Updates… + +> Automatic… on + Install u… v0.3.0 a… + + +" +`; + +exports[`screen baseline / micro / unicode > 'stations' 1`] = ` +"RC / Stations · 2 stati… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode > 'stats' 1`] = ` +"RC / Listening stats · … + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'airplay-code' 1`] = ` +"RC / AirPlay code · KEX… + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'airplay-settings' 1`] = ` +"RC / AirPlay · 1 receiv… + +> Living Room · speaker… + + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +BASICS +› Label: Morning radio + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +REVIEW +› Save alarm · next Mon… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + Schedule: Recurring +› Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +PLAYBACK +› Volume: 70% [███████… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + ● Tuesday +› ● Wednesday + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-picker' 1`] = ` +"Primary station ─────── +Favorites, imports, rec… + +› KEXP 90.3 FM · Now pl… + KCRW 89.9 FM — A deli… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────── +Favorites, imports, rec… + +› None · No fallback + KEXP 90.3 FM · Now pl… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────── +Wake radio · playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM · Morning … + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms' 1`] = ` +"Alarms ──────────────── +Scheduler ready +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms/degraded' 1`] = ` +"Alarms ──────────────── +Repair scheduler +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms/empty' 1`] = ` +"Alarms ──────────────── +Scheduler ready +No upcoming alarms. + +› Create alarm +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verifie… ── +Rehearsing “Morning rad… +READY WITH LIMITATIONS · + Review warnings before + Disposable job regis… +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / unicode-no-color > 'countries' 1`] = ` +"RC / Countries · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'explore' 1`] = ` +"RC / Explore · 48.9N, 2… + ⠠⠤⢴⣶⣢⡐⠒ ●⣤⣤⣴⣶⣦⠤⠠ + ⠈⠫⢁⡀ ⢸⣾⣟⠜⠛⠿⡃⠁ + ⢈⠿⠃ ⠸⠇ ⠴⠥⢀ + ⢀⣀⣀⣈⣄⣀⣠⣤⣤⣤⣤⣤⣤⣤⣄ +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli…" +`; + +exports[`screen baseline / micro / unicode-no-color > 'help' 1`] = ` +"RC / Help · Scroll for … + + Navigation + ↑/↓ or n/p · Move sel… + ←/→ or Tab · Switch t… + [ / ] · Page selectio… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'help/commands' 1`] = ` +"RC / Help · Scroll for … + + :country … + :codec · … +> :language … + :bitrate · Min… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'library' 1`] = ` +"RC / Library · 2 statio… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'library/empty' 1`] = ` +"RC / Library · 0 statio… + +Your library is empty. +Press f on a station to… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'map' 1`] = ` +"RC / World map · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'nearby' 1`] = ` +"RC / Nearby · 2 station… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'now-playing' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'now-playing/idle' 1`] = ` +"No station tuned · idle + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'search' 1`] = ` +"SEARCH +┌──────────────────────┐ +│› tokyo jazz │ +└──────────────────────┘ + +> KEXP 90.3 FM · indie ★ +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'search/loading' 1`] = ` +"SEARCH +┌──────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────┘ + +Searching station direc… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings' 1`] = ` +"RC / Settings · Choose … + +> Playback … 7 settin… + Appearance 6 settin… + Discovery… 4 settin… + Data & li… 2 settin… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/agent' 1`] = ` +"RC / Settings · Agent c… + +> Allow loc… off + Install o… run repa… + Open TUI … on + Show Now … on +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/appearance' 1`] = ` +"RC / Settings · Appeara… + +> Display c… green + Receiver … pulse-gr… + Transpare… off + ASCII-saf… off +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/data' 1`] = ` +"RC / Settings · Data & … + +> Export pr… JSON bac… + Import pr… restore … + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/discovery' 1`] = ` +"RC / Settings · Discove… + +> Nearby lo… off + Radio Gar… off + Share fav… on + Refresh p… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/media-keys' 1`] = ` +"RC / Settings · Media k… + +> Learn pre… + Learn pla… + Learn nex… + Reset lea… prev 0 ·… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/playback' 1`] = ` +"RC / Settings · Playbac… + +> Audio out… Automatic + AirPlay r… Living R… + Volume up + Volume do… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/updates' 1`] = ` +"RC / Settings · Updates… + +> Automatic… on + Install u… v0.3.0 a… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'stations' 1`] = ` +"RC / Stations · 2 stati… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'stats' 1`] = ` +"RC / Listening stats · … + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +" +`; diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index f7f2109..1d18785 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -1,15 +1,71 @@ -import {mkdtempSync, rmSync} from 'node:fs'; +import {act} from 'react'; +import {chmodSync, mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {join} from 'node:path'; import {tmpdir} from 'node:os'; -import {afterEach, describe, expect, it, vi} from 'vitest'; -import {render} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render as inkRender} from 'ink-testing-library'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {PlayerController} from '../player/player-controller.js'; +import * as backendInstall from '../player/backend-install.js'; +import * as airplayDiscovery from '../player/airplay-discovery.js'; +import * as updates from '../update-check.js'; +import * as session from '../agent/session.js'; +import * as presence from '../alarms/tui-presence.js'; +import * as systemActions from './system-actions.js'; +import * as pageFooter from './page-footer.js'; import {JsonLibraryStore} from '../storage/store.js'; -import type {Alarm, Station} from '../types.js'; +import type {Alarm, IcyNowPlaying, PlaybackState, Station} from '../types.js'; import type {AlarmTuiService, TuiActiveAlarm} from './alarm-tui-service.js'; import {App} from './App.js'; const directories: string[] = []; +const pendingInputs: Array<() => void> = []; +// Exercise App and its alarm controller without host discovery or network races. +// Individual tests can still override the injected alarm/update/MCP services. +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // ink-testing-library sets columns but omits rows. Give Ink's fallback the + // fixture size so every layout avoids host terminal commands such as tput. + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '24'); + vi.stubEnv('LC_ALL', 'en_US.UTF-8'); + vi.stubEnv('TERM', 'xterm-256color'); + vi.stubEnv('RADIOCLI_ASCII', undefined); + vi.stubEnv('RADIOCLI_UNICODE', undefined); + const testHome = mkdtempSync(join(tmpdir(), 'radiocli-alarm-tui-home-')); + directories.push(testHome); + vi.stubEnv('RADIOCLI_HOME', testHome); + vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv']); + vi.spyOn(airplayDiscovery, 'discoverAirPlayDevices').mockResolvedValue([]); + vi.spyOn(ProviderManager.prototype, 'health').mockResolvedValue({}); + vi.spyOn(updates, 'checkForUpdate').mockImplementation(async ({currentVersion = '0.2.4'} = {}) => ({ + checkedAt: new Date().toISOString(), currentVersion, updateAvailable: false + })); + vi.spyOn(session, 'startRadioSession').mockResolvedValue({close: async () => undefined}); + vi.spyOn(presence, 'registerTuiPresence').mockReturnValue(() => undefined); + // These keyboard journeys repeat the same expensive footer layout many times. + // Keep the real layout for every distinct input; its wrapping and appearance + // also have dedicated page-footer and visual-baseline coverage. + const layoutFooter = pageFooter.balancedFooterLegendRows; + const footerLayouts = new Map(); + vi.spyOn(pageFooter, 'balancedFooterLegendRows').mockImplementation((...args) => { + const key = JSON.stringify(args); + let rows = footerLayouts.get(key); + if (!rows) { + rows = layoutFooter(...args); + footerLayouts.set(key, rows); + } + return [...rows]; + }); +}); afterEach(() => { + pendingInputs.length = 0; + act(() => cleanup()); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); vi.unstubAllEnvs(); for (const path of directories.splice(0)) rmSync(path, {recursive: true, force: true}); }); @@ -37,9 +93,275 @@ function callsFor() { return { verifySetup:vi.fn(async (alarm:Alarm|undefined,_settings:unknown,onUpdate:(report:unknown)=>void)=>{const report={state:'passed' as const,alarmLabel:alarm?.label,startedAt:new Date().toISOString(),finishedAt:new Date().toISOString(),steps:[{id:'scheduler',label:'Native scheduler',state:'passed' as const,detail:'Disposable job registered and removed.',critical:true}]};onUpdate(report);return report;}) }; } function addAlarm(store: JsonLibraryStore): Alarm { return store.addAlarm({label: 'Morning', enabled: true, station, schedule: {type: 'recurring', time: '06:30', weekdays: [1,2,3,4,5], timezone: 'America/Los_Angeles'}, playback: {volume: 70, fadeSeconds: 30, stopAfterMinutes: 60}, reliability: {missedRunGraceMinutes: 15, wakeIfSupported: true, keepAwakeUntilAlarm: false}}); } -async function settle(): Promise { await new Promise(resolve => setTimeout(resolve, 25)); } +// Preserve rapid duplicate keys and multi-character input as one React batch, +// matching the existing tests' write/write/settle boundaries. +function render(tree: Parameters[0]): ReturnType { + let app!: ReturnType; + act(() => { app = inkRender(tree); }); + const write = app.stdin.write; + app.stdin.write = input => { pendingInputs.push(() => write(input)); }; + const unmount = app.unmount; + app.unmount = () => { act(() => unmount()); }; + return app; +} +async function settle(elapsed = 0): Promise { + await act(async () => { + for (const write of pendingInputs.splice(0)) write(); + if (elapsed > 0) await vi.advanceTimersByTimeAsync(elapsed); + }); +} async function moveDown(app: ReturnType, count: number): Promise { for (let index = 0; index < count; index += 1) app.stdin.write('\u001B[B'); await settle(); } +function simulatedPlayer() { + let state: PlaybackState = {backend: 'mpv', state: 'idle', ready: false, volume: 70, muted: false}; + const listeners = new Set<(state: PlaybackState) => void>(); + const metadataListeners = new Set<(metadata: IcyNowPlaying) => void>(); + const change = (next: Partial) => { + state = {...state, ...next}; + for (const listener of listeners) listener({...state}); + }; + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...state})); + vi.spyOn(PlayerController.prototype, 'onChange').mockImplementation(listener => { + listeners.add(listener); + listener({...state}); + return () => { listeners.delete(listener); }; + }); + vi.spyOn(PlayerController.prototype, 'onMetadata').mockImplementation(listener => { + metadataListeners.add(listener); + return () => { metadataListeners.delete(listener); }; + }); + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (selected, url) => { + change({state: 'playing', ready: true, stationName: selected.name, streamUrl: url}); + }); + const stop = vi.spyOn(PlayerController.prototype, 'stop').mockImplementation(async () => { + change({state: 'idle', ready: false}); + }); + vi.spyOn(PlayerController.prototype, 'adjustVolume').mockImplementation(async delta => { + change({volume: state.volume + delta}); + return {ok: true}; + }); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async selected => ({url: selected.streamUrl ?? 'https://example.test/live'})); + return {play, stop, state: () => state, metadata: (metadata: IcyNowPlaying) => { + for (const listener of metadataListeners) listener(metadata); + }}; +} + +async function tuneLibrary(app: ReturnType): Promise { + app.stdin.write('2'); + await settle(); + app.stdin.write('\r'); + await settle(); +} + +describe('Persistence failure boundaries', () => { + const failedSave = () => { throw new Error('Library is read-only. Set RADIOCLI_HOME to a writable directory.'); }; + // Windows ACLs and a privileged POSIX user do not enforce this chmod fixture. + const nativePermissions = process.platform !== 'win32' && process.getuid?.() !== 0; + + it.skipIf(!nativePermissions)('tunes and navigates with a readable read-only library without inventing saved history', async () => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const original = readFileSync(store.filePath, 'utf8'); + chmodSync(store.filePath, 0o400); + try { + const app = render(); + await settle(); + await tuneLibrary(app); + expect(player.play).toHaveBeenCalledOnce(); + expect(player.state()).toMatchObject({state: 'playing', ready: true}); + expect(app.lastFrame()).toContain('Library not saved'); + expect(app.lastFrame()).not.toContain('Skipping'); + expect(readFileSync(store.filePath, 'utf8')).toBe(original); + app.stdin.write('b'); + await settle(); + expect(app.lastFrame()).toContain('Overview'); + app.stdin.write('q'); + await settle(); + expect(player.state().state).toBe('idle'); + app.unmount(); + } finally { + chmodSync(store.filePath, 0o600); + } + }); + + it.each([':stop\r', 'q'])('still stops playback through %j when the listening-history save fails', async input => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + vi.spyOn(store, 'finishActiveListeningSession').mockImplementation(failedSave); + + app.stdin.write(input); + await settle(); + expect(player.state().state).toBe('idle'); + expect(player.stop).toHaveBeenCalledTimes(2); + app.unmount(); + }); + + it('honors the sleep timer when the listening-history save fails', async () => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + vi.spyOn(store, 'finishActiveListeningSession').mockImplementation(failedSave); + + app.stdin.write(':sleep 1\r'); + await settle(); + await settle(60_000); + expect(player.state().state).toBe('idle'); + expect(app.lastFrame()).toContain('Library not saved'); + app.unmount(); + }); + + it.each(['stop', 'alarm-preempt'] as const)('honors agent %s when the listening-history save fails', async type => { + const {store, service} = fixture(); + store.updateSettings({agentControl: {...store.snapshot().settings.agentControl!, enabled: true}}); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + vi.spyOn(store, 'finishActiveListeningSession').mockImplementation(failedSave); + const handle = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + + await act(async () => { + const result = await handle({type}); + expect(result).toMatchObject({ok: true, status: {station: null, playback: {state: 'idle'}}}); + }); + expect(player.state().state).toBe('idle'); + expect(app.lastFrame()).toContain('Library not saved'); + app.unmount(); + }); + + it('keeps successful search results usable when search history cannot be saved', async () => { + const {store, service} = fixture(); + vi.spyOn(ProviderManager.prototype, 'search').mockResolvedValue([station]); + vi.spyOn(store, 'addSearch').mockImplementation(failedSave); + const app = render(); + await settle(); + app.stdin.write(':search jazz\r'); + await settle(); + expect(app.lastFrame()).toContain('KEXP'); + expect(app.lastFrame()).toContain('Library not saved'); + expect(store.snapshot().searchHistory).toEqual([]); + app.unmount(); + }); + + it.each(['metadata', 'checkpoint'] as const)('reports failed %s persistence while keeping playback active', async source => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + const before = store.snapshot(); + if (source === 'metadata') { + vi.spyOn(store, 'recordTrack').mockImplementation(failedSave); + act(() => player.metadata({title: 'A song', raw: '', updatedAt: new Date().toISOString()})); + await settle(); + } else { + vi.spyOn(store, 'checkpointActiveListeningSession').mockImplementation(failedSave); + await settle(30_000); + } + expect(app.lastFrame()).toContain('Library not saved'); + expect(player.state().state).toBe('playing'); + expect(store.snapshot()).toEqual(before); + app.unmount(); + }); + + it('reports update-check persistence failure without rejecting the background check', async () => { + vi.stubEnv('CI', 'false'); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '0'); + const {store, service} = fixture(); + vi.spyOn(store, 'updateCheckState').mockImplementation(failedSave); + const app = render(); + await settle(); + expect(updates.checkForUpdate).toHaveBeenCalledOnce(); + expect(app.lastFrame()).toContain('Library not saved'); + expect(store.snapshot().updateCheck).toBeUndefined(); + app.unmount(); + }); + + it('reports a rejected background update check', async () => { + vi.stubEnv('CI', 'false'); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '0'); + const {store, service} = fixture(); + vi.mocked(updates.checkForUpdate).mockRejectedValueOnce(new Error('Update service unavailable.')); + const app = render(); + await settle(); + expect(app.lastFrame()).toContain('Update service unavailable.'); + app.unmount(); + }); + + it.each(['t', ':location off\r', '+'])('reports a failed explicit setting through %j without claiming success', async input => { + const {store, service} = fixture(); + simulatedPlayer(); + const app = render(); + await settle(); + const settings = store.snapshot().settings; + vi.spyOn(store, 'updateSettings').mockImplementation(failedSave); + app.stdin.write(input); + await settle(); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(store.snapshot().settings).toEqual(settings); + app.stdin.write('9'); + await settle(); + expect(app.lastFrame()).toContain('Choose a category'); + app.unmount(); + }); + + it('reports a failed favorite write and preserves the favorite', async () => { + const {store, service} = fixture(); + const app = render(); + await settle(); + app.stdin.write('2'); + await settle(); + vi.spyOn(store, 'toggleFavorite').mockImplementation(failedSave); + app.stdin.write('f'); + await settle(); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(app.lastFrame()).not.toContain('Removed from favorites'); + expect(store.isFavorite(station)).toBe(true); + app.unmount(); + }); + + it('reports a failed media-key save from raw input', async () => { + const {store, service} = fixture(); + const app = render(); + await settle(); + app.stdin.write(':learn next\r'); + await settle(); + vi.spyOn(store, 'updateSettings').mockImplementation(failedSave); + app.stdin.write('x'); + await settle(); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(store.snapshot().settings.mediaKeys.next).toEqual([]); + app.unmount(); + }); + + it.each([ + {key: 'O', field: 'homepage' as const, value: 'javascript:unsafe-homepage'}, + {key: 'y', field: 'streamUrl' as const, value: 'javascript:unsafe-stream'} + ])('does not echo or dispatch an invalid $field when a desktop helper is unavailable', async ({key, field, value}) => { + const {store, service} = fixture(); + store.toggleFavorite(station); + store.toggleFavorite({...station, [field]: value}); + const open = vi.spyOn(systemActions, 'openExternal').mockResolvedValue(false); + const copy = vi.spyOn(systemActions, 'copyToClipboard').mockResolvedValue(false); + const app = render(); + await settle(); + app.stdin.write('2'); + await settle(); + app.stdin.write(key); + await settle(); + expect(open).not.toHaveBeenCalled(); + expect(copy).not.toHaveBeenCalled(); + expect(app.lastFrame()).not.toContain(value); + expect(app.lastFrame()).toMatch(/valid|safe/i); + app.unmount(); + }); +}); + describe('Settings TUI integration', () => { it('checks once at launch and shows the available version beside the installed version', async () => { vi.stubEnv('CI', 'false'); @@ -47,7 +369,7 @@ describe('Settings TUI integration', () => { const {store, service} = fixture(); const updateChecker = vi.fn(async () => ({ checkedAt: '2026-09-07T12:00:00.000Z', - currentVersion: '0.2.3', + currentVersion: '0.2.4', latestVersion: '0.3.0', updateAvailable: true })); @@ -56,7 +378,7 @@ describe('Settings TUI integration', () => { await settle(); expect(updateChecker).toHaveBeenCalledOnce(); - expect(app.lastFrame()).toContain('v0.3.0 available v0.2.3'); + expect(app.lastFrame()).toContain('v0.3.0 available v0.2.4'); app.unmount(); }); @@ -223,7 +545,19 @@ describe('alarm TUI integration', () => { }); it('does not let background active polling move a non-alarm screen cursor', async () => { - const {store,service}=fixture();const app=render();await settle();app.stdin.write('9');await settle();await moveDown(app,4);const selectedLine=app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '));expect(selectedLine).toBeTruthy();vi.useFakeTimers();try{await vi.advanceTimersByTimeAsync(1_600);expect(app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '))).toBe(selectedLine);}finally{app.unmount();vi.useRealTimers();} + const {store, service, calls} = fixture(); + const app = render(); + await settle(); + app.stdin.write('9'); + await settle(); + await moveDown(app, 4); + const selectedLine = app.lastFrame()?.split('\n').find(line => line.trimStart().startsWith('> ')); + expect(selectedLine).toBeTruthy(); + expect(calls.activeAlarms).toHaveBeenCalledOnce(); + await settle(1_600); + expect(calls.activeAlarms).toHaveBeenCalledTimes(2); + expect(app.lastFrame()?.split('\n').find(line => line.trimStart().startsWith('> '))).toBe(selectedLine); + app.unmount(); }); it('toggles, confirms deletion with a second x, and cleans the native job through the injected service', async () => { @@ -242,6 +576,81 @@ describe('alarm TUI integration', () => { app.stdin.write('r'); await settle(); expect(calls.syncAll.mock.calls.at(-1)?.[0]).toEqual([expect.objectContaining({id: alarm.id, enabled: false})]); app.unmount(); }); + it('does not touch native jobs when deletion cannot first save a disabled alarm', async () => { + const {store, service, calls} = fixture(); + const alarm = addAlarm(store); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + const original = readFileSync(store.filePath, 'utf8'); + const disable = vi.spyOn(store, 'toggleAlarm').mockImplementation(() => { throw new Error('Library is read-only'); }); + calls.remove.mockRejectedValue(new Error('Native cleanup denied')); + + app.stdin.write('b'); await settle(); + app.stdin.write('8'); await settle(); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + + expect(calls.remove).not.toHaveBeenCalled(); + expect(store.getAlarm(alarm.id)?.enabled).toBe(true); + expect(readFileSync(store.filePath, 'utf8')).toBe(original); + expect(player.state().state).toBe('playing'); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(app.lastFrame()).not.toContain('kept disabled'); + + // A failed attempt releases the row and permits an explicit retry. + disable.mockRestore(); calls.remove.mockResolvedValue(undefined); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + expect(store.getAlarm(alarm.id)).toBeUndefined(); + expect(calls.remove).toHaveBeenCalledOnce(); + expect(player.state().state).toBe('playing'); + app.unmount(); + }); + + it('survives native cleanup failure after storage becomes unavailable without a recovery write', async () => { + const {store, service, calls} = fixture(); + const alarm = addAlarm(store); + const player = simulatedPlayer(); + const app = render(); + await settle(); await tuneLibrary(app); + const disable = vi.spyOn(store, 'toggleAlarm'); + calls.remove.mockImplementationOnce(async () => { + disable.mockImplementation(() => { throw new Error('Storage disappeared during cleanup'); }); + throw new Error('Native cleanup denied'); + }); + app.stdin.write('b'); await settle(); + app.stdin.write('8'); await settle(); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + + expect(disable).toHaveBeenCalledOnce(); + expect(store.getAlarm(alarm.id)?.enabled).toBe(false); + expect(new JsonLibraryStore(store.filePath).getAlarm(alarm.id)?.enabled).toBe(false); + expect(app.lastFrame()).toContain('Native cleanup denied'); + expect(app.lastFrame()).toContain('kept disabled'); + expect(player.state().state).toBe('playing'); + app.stdin.write('b'); await settle(); + expect(app.lastFrame()).toContain('Overview'); + app.unmount(); + }); + + it('retains a disabled definition and accurate diagnostics when its final deletion write fails', async () => { + const {store, service, calls} = fixture(); + const alarm = addAlarm(store); + const app = render(); + await settle(); + const disable = vi.spyOn(store, 'toggleAlarm'); + vi.spyOn(store, 'removeAlarm').mockImplementation(() => { throw new Error('Disk full'); }); + app.stdin.write('8'); await settle(); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + + expect(calls.remove).toHaveBeenCalledWith(expect.objectContaining({id: alarm.id, enabled: false})); + expect(disable).toHaveBeenCalledOnce(); + expect(new JsonLibraryStore(store.filePath).getAlarm(alarm.id)?.enabled).toBe(false); + expect(app.lastFrame()).toContain('Native cleanup completed'); + expect(app.lastFrame()).toContain('Disk full'); + app.unmount(); + }); + it('preserves rapid disable-enable order through a deferred native sync', async () => { const {store, service, calls} = fixture(); addAlarm(store); let finish!: (value: Date) => void; calls.sync.mockImplementationOnce(async () => new Promise(resolve => { finish = resolve; })); @@ -283,7 +692,8 @@ describe('alarm TUI integration', () => { expect(app.lastFrame()).toContain('Primary station'); app.stdin.write('\r'); await settle(); expect(app.lastFrame()).toContain('Station: KEXP'); app.stdin.write('\u0013'); await settle(); expect(store.listAlarms()).toHaveLength(1); const original = store.listAlarms()[0]!; app.stdin.write('\r'); await settle(); expect(app.lastFrame()).toContain('Edit alarm'); - app.stdin.write('\u001B'); await settle(); expect(store.getAlarm(original.id)?.label).toBe(original.label); + // Ink waits 20ms to distinguish a lone Escape from a split escape sequence. + app.stdin.write('\u001B'); await settle(20); expect(store.getAlarm(original.id)?.label).toBe(original.label); app.stdin.write('r'); await settle(); expect(calls.syncAll.mock.calls.length).toBeGreaterThanOrEqual(2); expect(app.lastFrame()).toContain('synchronized'); app.unmount(); }); @@ -299,7 +709,7 @@ describe('alarm TUI integration', () => { await moveDown(app, 3); app.stdin.write('\r'); await settle(); await moveDown(app, 1); app.stdin.write('\r'); await settle(); app.stdin.write('\u0013'); await settle(); const updated = store.getAlarm(alarm.id)!; expect(updated.label).toContain('edited'); expect(updated.enabled).toBe(false); expect(updated.schedule).toMatchObject({weekdays: [1,3,5]}); expect(updated.playback.volume).toBe(85); expect(updated.playback.fallbackStation).toBeUndefined(); expect(updated.reliability).toMatchObject({wakeIfSupported: false, keepAwakeUntilAlarm: true}); app.unmount(); - }); + }, 10_000); it('edits hours and minutes as separate arrow-controlled segments', async () => { const {store, service} = fixture(); addAlarm(store); const app = render(); await settle(); app.stdin.write('8'); await settle(); app.stdin.write('\r'); await settle(); diff --git a/src/ui/app-state.test.ts b/src/ui/app-state.test.ts index a5b750e..da9dc59 100644 --- a/src/ui/app-state.test.ts +++ b/src/ui/app-state.test.ts @@ -9,6 +9,7 @@ import { defaultExploreCursor, favoriteTarget, formatExploreCursor, + isSearchFavoriteShortcut, mediaTransportActionForInput, moveExploreCursor, nextPlaybackBackend, @@ -20,6 +21,7 @@ import { shouldAnimateReceiver, shouldResetReceiverPulse, shouldSkipAfterTuneError, + shouldTuneSearchResult, shouldToggleNearbyLocationShortcut, stationContextKeyForScreen, topTabs @@ -82,8 +84,9 @@ describe('app state helpers', () => { it('targets favorites based on the active screen', () => { const playing = {...station, id: 'playing'}; - expect(favoriteTarget('library', station, playing)?.id).toBe('station-1'); - expect(favoriteTarget('search', station, playing)?.id).toBe('station-1'); + for (const screen of ['explore', 'stations', 'search', 'nearby', 'library'] as const) { + expect(favoriteTarget(screen, station, playing)?.id).toBe('station-1'); + } expect(favoriteTarget('now-playing', station, playing)?.id).toBe('playing'); expect(favoriteTarget('settings', null, playing)?.id).toBe('playing'); }); @@ -118,6 +121,22 @@ describe('app state helpers', () => { expect(searchEditingArrowAction({}, true)).toBeNull(); }); + it('tunes retained search results after the query is cleared without tuning stale results for a changed query', () => { + expect(shouldTuneSearchResult('', 'jazz')).toBe(true); + expect(shouldTuneSearchResult(' ', 'jazz')).toBe(true); + expect(shouldTuneSearchResult(' jazz ', 'jazz')).toBe(true); + expect(shouldTuneSearchResult('news', 'jazz')).toBe(false); + }); + + it('recognizes cross-platform modified-F favorite shortcuts without consuming plain search text', () => { + expect(isSearchFavoriteShortcut('f', {ctrl: true})).toBe(true); + expect(isSearchFavoriteShortcut('f', {meta: true})).toBe(true); + expect(isSearchFavoriteShortcut('f', {super: true})).toBe(true); + expect(isSearchFavoriteShortcut('F', {ctrl: true})).toBe(true); + expect(isSearchFavoriteShortcut('f', {})).toBe(false); + expect(isSearchFavoriteShortcut('a', {ctrl: true})).toBe(false); + }); + it('maps terminal function-key transport sequences', () => { expect(mediaTransportActionForInput('\u001B[18~')).toBe('previous'); expect(mediaTransportActionForInput('\u001B[19~')).toBe('playPause'); diff --git a/src/ui/app-state.ts b/src/ui/app-state.ts index 67a72a3..9eb6b56 100644 --- a/src/ui/app-state.ts +++ b/src/ui/app-state.ts @@ -125,6 +125,18 @@ export function searchEditingArrowAction( return null; } +export function shouldTuneSearchResult(query: string, lastSubmittedQuery: string): boolean { + const trimmedQuery = query.trim(); + return !trimmedQuery || trimmedQuery === lastSubmittedQuery.trim(); +} + +export function isSearchFavoriteShortcut( + input: string, + key: {ctrl?: boolean; meta?: boolean; super?: boolean} +): boolean { + return input.toLowerCase() === 'f' && Boolean(key.ctrl || key.meta || key.super); +} + export function clampVolume(value: number): number { return Math.min(100, Math.max(0, Math.round(value))); } diff --git a/src/ui/ascii.test.ts b/src/ui/ascii.test.ts index 49b49bc..80d6842 100644 --- a/src/ui/ascii.test.ts +++ b/src/ui/ascii.test.ts @@ -29,6 +29,7 @@ describe('toAsciiSafe', () => { expect(toAsciiSafe('·•●')).toBe('.o@'); expect(toAsciiSafe('a · b')).toBe('a . b'); expect(toAsciiSafe('Loading…')).toBe('Loading.'); + expect(toAsciiSafe('∘ ◆ “radio” ‘track’')).toBe('o * "radio" \'track\''); }); it('preserves direction in visualizer geometry', () => { @@ -37,5 +38,6 @@ describe('toAsciiSafe', () => { it('preserves international station names', () => { expect(toAsciiSafe('Rádio 東京')).toBe('Rádio 東京'); + expect(toAsciiSafe('Cafe\u0301 / Český / हिन्दी / العربية')).toBe('Cafe\u0301 / Český / हिन्दी / العربية'); }); }); diff --git a/src/ui/ascii.ts b/src/ui/ascii.ts index d5ea19f..eed9cd0 100644 --- a/src/ui/ascii.ts +++ b/src/ui/ascii.ts @@ -82,6 +82,11 @@ function asciiGlyph(code: number): string { return 'o'; // ◉ ◒ } + if (code === 0x2218) return 'o'; // ring operator used by receiver patterns + if (code === 0x25c6 || code === 0x25c7) return '*'; // decorative diamonds + if (code === 0x201c || code === 0x201d) return '"'; + if (code === 0x2018 || code === 0x2019) return "'"; + if (code === 0x25b2) { return '^'; // ▲ } @@ -131,8 +136,9 @@ function asciiGlyph(code: number): string { if (code === 0x2191) return '^'; if (code === 0x2193) return 'v'; - // Drop zero-width and combining marks entirely so they do not skew widths. - if (code === 0x200b || code === 0xfeff || (code >= 0x0300 && code <= 0x036f)) { + // Drop invisible separators, but retain combining accents in station data. + // Width measurement already accounts for combining marks as zero columns. + if (code === 0x200b || code === 0xfeff) { return ''; } diff --git a/src/ui/components/AdaptiveMarquee.tsx b/src/ui/components/AdaptiveMarquee.tsx index 369bb0e..02c1af0 100644 --- a/src/ui/components/AdaptiveMarquee.tsx +++ b/src/ui/components/AdaptiveMarquee.tsx @@ -1,6 +1,8 @@ import React, {useEffect, useState} from 'react'; import {Text} from 'ink'; import {displayWidth, sliceDisplay, truncate} from '../format.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type AdaptiveMarqueeProps = { text: string; @@ -13,14 +15,15 @@ const edgePauseMs = 1000; const stepMs = 180; export function AdaptiveMarquee({text, width, active, reduceMotion}: AdaptiveMarqueeProps): React.ReactElement { + const {ascii, reduceMotion: terminalReduceMotion} = useDisplay(); const safeWidth = Math.max(0, width); const maxOffset = Math.max(0, displayWidth(text) - safeWidth); - const animated = active && !reduceMotion && maxOffset > 0; + const animated = active && !reduceMotion && !terminalReduceMotion && maxOffset > 0; const [offset, setOffset] = useState(0); useEffect(() => { setOffset(0); - }, [active, reduceMotion, safeWidth, text]); + }, [active, reduceMotion, terminalReduceMotion, safeWidth, text]); useEffect(() => { if (!animated) return; @@ -34,5 +37,5 @@ export function AdaptiveMarquee({text, width, active, reduceMotion}: AdaptiveMar const visible = animated ? sliceDisplay(text, Math.min(offset, maxOffset), safeWidth) : truncate(text, safeWidth); - return {visible}; + return {ascii ? toAsciiSafe(visible) : visible}; } diff --git a/src/ui/components/Logo.tsx b/src/ui/components/Logo.tsx index 9269693..9e68a69 100644 --- a/src/ui/components/Logo.tsx +++ b/src/ui/components/Logo.tsx @@ -1,15 +1,17 @@ import React from 'react'; import {Box, Text} from 'ink'; import {textMuted} from '../theme.js'; +import {useDisplay} from '../display-context.js'; const logoSpectrumColors =['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc']; function LogoSpectrum(): React.ReactElement { + const {ascii} = useDisplay(); return ( <> {logoSpectrumColors.map(color => ( - - ██ + + {ascii ? '##' : '██'} ))} @@ -17,6 +19,7 @@ function LogoSpectrum(): React.ReactElement { } export function Logo({compact = false, width = 80}: {compact?: boolean; width?: number} = {}): React.ReactElement { + const {ascii} = useDisplay(); if (compact) { const spectrumColumns = Math.max(1, width - 10); const colors = logoSpectrumColors.slice(0, Math.min(logoSpectrumColors.length, spectrumColumns)); @@ -24,7 +27,7 @@ export function Logo({compact = false, width = 80}: {compact?: boolean; width?: RADIOCLI - {colors.map(color => )} + {colors.map(color => {ascii ? '#' : '█'})} ); } diff --git a/src/ui/components/Menu.tsx b/src/ui/components/Menu.tsx index 4467b2f..57f075c 100644 --- a/src/ui/components/Menu.tsx +++ b/src/ui/components/Menu.tsx @@ -12,9 +12,9 @@ export function Menu({items, selected, keyFor, render}: MenuProps): React. const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1; return ( - + {items.map((item, position) => ( - + {render(item, position, position === activeIndex)} ))} @@ -23,7 +23,7 @@ export function Menu({items, selected, keyFor, render}: MenuProps): React. } export function Pointer({active}: {active: boolean}): React.ReactElement { - return {active ? '> ' : ' '}; + return {active ? '> ' : ' '}; } function defaultMenuKey(item: unknown): React.Key { diff --git a/src/ui/components/ScreenHeader.tsx b/src/ui/components/ScreenHeader.tsx index d28ebc2..057ff13 100644 --- a/src/ui/components/ScreenHeader.tsx +++ b/src/ui/components/ScreenHeader.tsx @@ -32,7 +32,7 @@ export function ScreenHeader({title, width, theme, subtitle, right}: ScreenHeade {a(safeTitle)} - {rule} + {rule} {rightText ? {a(rightText)} : null} {subtitle ? {a(truncate(subtitle, width))} : null} diff --git a/src/ui/components/StationList.tsx b/src/ui/components/StationList.tsx index 2985fd4..1f88681 100644 --- a/src/ui/components/StationList.tsx +++ b/src/ui/components/StationList.tsx @@ -7,6 +7,7 @@ import {Menu, Pointer} from './Menu.js'; import {visibleWindow} from '../list-window.js'; import {useDisplay} from '../display-context.js'; import {AdaptiveMarquee} from './AdaptiveMarquee.js'; +import {toAsciiSafe} from '../ascii.js'; type StationListProps = { stations: Station[]; @@ -31,12 +32,13 @@ export function StationList({ emptyHint = 'Try another view or clear active filters.', showCount = true }: StationListProps): React.ReactElement { - const {reduceMotion} = useDisplay(); + const {reduceMotion, ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; if (stations.length === 0) { return ( - {emptyTitle} - {emptyHint} + {a(emptyTitle)} + {a(emptyHint)} ); } @@ -72,7 +74,7 @@ export function StationList({ reduceMotion={reduceMotion} /> - {favorite ? : null} + {favorite ? {a(' ★')} : null} ); } @@ -90,10 +92,10 @@ export function StationList({ - {favorite ? : null} + {favorite ? {a(' ★')} : null} - {truncate(active ? selectedMetadata : standardMetadata, metaWidth)} + {a(truncate(active ? selectedMetadata : standardMetadata, metaWidth))} ); diff --git a/src/ui/components/TopTabs.tsx b/src/ui/components/TopTabs.tsx index 4420e41..1934db3 100644 --- a/src/ui/components/TopTabs.tsx +++ b/src/ui/components/TopTabs.tsx @@ -38,7 +38,7 @@ export function TopTabs({tabs, active, theme, width, backendLabel}: TopTabsProps const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightWidth : 0)); return ( - + `${tab.label}${tab.screen === active ? ' (selected)' : ''}`).join(', ')}. Audio output: ${backendLabel}.`}> {box.tl} @@ -51,7 +51,7 @@ export function TopTabs({tabs, active, theme, width, backendLabel}: TopTabsProps {visibleTabs.map((item, index) => ( {item.type === 'overflow' ? ( - + {ascii ? '.' : '…'} ) : ( {item.tab.label} diff --git a/src/ui/display-context.test.ts b/src/ui/display-context.test.ts index 2ff920d..652d7e6 100644 --- a/src/ui/display-context.test.ts +++ b/src/ui/display-context.test.ts @@ -1,12 +1,12 @@ import {describe, expect, it} from 'vitest'; -import {noColorRequested, panelBorderStyle, resolveDisplayMode} from './display-context.js'; +import {panelBorderStyle, resolveDisplayMode} from './display-context.js'; import {appBackground, panelBackground} from './theme.js'; describe('display mode', () => { it('honors the NO_COLOR convention only for non-empty values', () => { - expect(noColorRequested({NO_COLOR: '1'})).toBe(true); - expect(noColorRequested({NO_COLOR: ''})).toBe(false); - expect(noColorRequested({})).toBe(false); + expect(resolveDisplayMode({}, {NO_COLOR: '1'})).toMatchObject({colorLevel: 0, app: undefined, panel: undefined}); + expect(resolveDisplayMode({}, {NO_COLOR: ''})).toMatchObject({colorLevel: 3, app: appBackground, panel: panelBackground}); + expect(resolveDisplayMode({}, {})).toMatchObject({colorLevel: 3, app: appBackground, panel: panelBackground}); }); it('keeps opaque panel fills by default', () => { @@ -14,7 +14,9 @@ describe('display mode', () => { app: appBackground, panel: panelBackground, ascii: false, - reduceMotion: false + reduceMotion: false, + screenReader: false, + colorLevel: 3 }); }); @@ -27,6 +29,22 @@ describe('display mode', () => { expect(resolveDisplayMode({asciiMode: true, reduceMotion: true}, {})).toMatchObject({ascii: true, reduceMotion: true}); }); + it('applies terminal overrides without changing persisted preferences', () => { + const settings = Object.freeze({asciiMode: false, reduceMotion: false, transparentBackground: false}); + expect(resolveDisplayMode(settings, {TERM: 'dumb'})).toMatchObject({ascii: true, reduceMotion: true, colorLevel: 0, app: undefined, panel: undefined}); + expect(settings).toEqual({asciiMode: false, reduceMotion: false, transparentBackground: false}); + expect(resolveDisplayMode({asciiMode: true}, {RADIOCLI_UNICODE: '1'}).ascii).toBe(false); + }); + + it('drops forced dark backgrounds on sixteen-color terminals', () => { + expect(resolveDisplayMode({}, {TERM: 'xterm'})).toMatchObject({colorLevel: 1, app: undefined, panel: undefined}); + expect(resolveDisplayMode({}, {TERM: 'xterm-256color'})).toMatchObject({colorLevel: 2, app: appBackground, panel: panelBackground}); + }); + + it.each([{INK_SCREEN_READER: 'true'}, {RADIOCLI_SCREEN_READER: '1'}])('marks %j as static screen-reader output', env => { + expect(resolveDisplayMode({}, env)).toMatchObject({screenReader: true, reduceMotion: true, app: undefined, panel: undefined}); + }); + it('switches panel borders to ASCII classic in ascii mode', () => { expect(panelBorderStyle(false)).toBe('round'); expect(panelBorderStyle(false, 'single')).toBe('single'); diff --git a/src/ui/display-context.ts b/src/ui/display-context.ts index a05a430..5a4adcb 100644 --- a/src/ui/display-context.ts +++ b/src/ui/display-context.ts @@ -1,4 +1,5 @@ import {createContext, useContext} from 'react'; +import {resolveTerminalCapabilities, type TerminalColorLevel, type TerminalEvidence} from '../platform/terminal.js'; import {appBackground, panelBackground} from './theme.js'; type DisplayBackgrounds = { @@ -9,6 +10,8 @@ type DisplayBackgrounds = { export type DisplayMode = DisplayBackgrounds & { ascii: boolean; reduceMotion: boolean; + screenReader: boolean; + colorLevel: TerminalColorLevel; }; type DisplaySettings = { @@ -20,20 +23,15 @@ type DisplaySettings = { const opaqueBackgrounds: DisplayBackgrounds = {app: appBackground, panel: panelBackground}; const transparentBackgrounds: DisplayBackgrounds = {app: undefined, panel: undefined}; -// Respect the NO_COLOR convention (https://no-color.org): any non-empty value -// opts the user out of our forced colors, including the dark panel fills. -export function noColorRequested(env: NodeJS.ProcessEnv = process.env): boolean { - return typeof env.NO_COLOR === 'string' && env.NO_COLOR.length > 0; +// Sixteen-color palettes cannot safely reproduce our dark fills. Let those +// terminals keep their own background while Ink quantizes the foregrounds. +export function resolveDisplayMode(settings: DisplaySettings, env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): DisplayMode { + const terminal = resolveTerminalCapabilities(env, {...settings, ...evidence}); + const backgrounds = settings.transparentBackground || terminal.colorLevel < 2 ? transparentBackgrounds : opaqueBackgrounds; + return {...backgrounds, ascii: !terminal.unicode, reduceMotion: terminal.reduceMotion, screenReader: terminal.screenReader, colorLevel: terminal.colorLevel}; } -// Resolve the effective display mode from the user's settings and the NO_COLOR -// environment so light terminals keep their own background. -export function resolveDisplayMode(settings: DisplaySettings, env: NodeJS.ProcessEnv = process.env): DisplayMode { - const backgrounds = settings.transparentBackground || noColorRequested(env) ? transparentBackgrounds : opaqueBackgrounds; - return {...backgrounds, ascii: Boolean(settings.asciiMode), reduceMotion: Boolean(settings.reduceMotion)}; -} - -const defaultMode: DisplayMode = {...opaqueBackgrounds, ascii: false, reduceMotion: false}; +const defaultMode: DisplayMode = resolveDisplayMode({}, {}); export const DisplayContext = createContext(defaultMode); diff --git a/src/ui/help-content.ts b/src/ui/help-content.ts index 06b07f4..7fdf498 100644 --- a/src/ui/help-content.ts +++ b/src/ui/help-content.ts @@ -30,7 +30,7 @@ export const keyHelpSections: HelpSection[] = [ {keys: ', / . or F7/F9', description: 'Previous / next station'}, {keys: '+ / -', description: 'Volume up / down'}, {keys: 'm', description: 'Mute / unmute'}, - {keys: 'f', description: 'Favorite the station'}, + {keys: 'f / Ctrl+F', description: 'Favorite the station (Ctrl+F while typing in Search)'}, {keys: 'O', description: 'Open station homepage'}, {keys: 'y', description: 'Copy stream URL'}, {keys: 's', description: 'Sleep timer (Now Playing)'}, diff --git a/src/ui/page-footer.test.ts b/src/ui/page-footer.test.ts index c49b9e4..5140f70 100644 --- a/src/ui/page-footer.test.ts +++ b/src/ui/page-footer.test.ts @@ -143,7 +143,7 @@ describe('page footer shortcuts', () => { playbackBackend: 'mpv', screen: 'search' }) - ).toBe('Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish'); + ).toBe('Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Ctrl+F favorite · Esc finish'); }); it('keeps AirPlay code entry hidden until AirPlay streaming is ready', () => { diff --git a/src/ui/page-footer.ts b/src/ui/page-footer.ts index d41437c..8de96fe 100644 --- a/src/ui/page-footer.ts +++ b/src/ui/page-footer.ts @@ -60,7 +60,7 @@ export function pageFooterText({ } if (screen === 'search' && editingSearch) { - return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish'; + return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Ctrl+F favorite · Esc finish'; } if (screen === 'search') { diff --git a/src/ui/portable-app.test.tsx b/src/ui/portable-app.test.tsx new file mode 100644 index 0000000..176cc0d --- /dev/null +++ b/src/ui/portable-app.test.tsx @@ -0,0 +1,223 @@ +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {PassThrough, Writable} from 'node:stream'; +import {stripVTControlCharacters} from 'node:util'; +import {act} from 'react'; +import {render} from 'ink'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {PlayerController} from '../player/player-controller.js'; +import * as backendInstall from '../player/backend-install.js'; +import * as airplayDiscovery from '../player/airplay-discovery.js'; +import * as session from '../agent/session.js'; +import * as presence from '../alarms/tui-presence.js'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import type {PlaybackState, Station} from '../types.js'; +import {App} from './App.js'; +import type {AlarmTuiService} from './alarm-tui-service.js'; +import * as visualizers from './visualizers/receiver-visualizers.js'; + +const directories: string[] = []; +const instances: ReturnType[] = []; +const runtime = {capabilities: {supported: true, exactWake: false, catchUpAfterWake: true, message: 'ready'}, degradedAlarmIds: new Set(), message: 'Native scheduler ready.'}; +const service: AlarmTuiService = { + sync: async () => null, syncAll: async () => [], remove: async () => undefined, + runtimeStatus: async () => runtime, activeAlarms: async () => [], prepareTerminalAccess: async () => undefined, + verifySetup: async () => ({state: 'passed', startedAt: '2026-09-07T12:00:00.000Z', steps: []}) +}; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + for (const name of ['TERM', 'NO_COLOR', 'RADIOCLI_ASCII', 'RADIOCLI_UNICODE', 'RADIOCLI_SCREEN_READER', 'RADIOCLI_DISABLE_ANIMATION', 'RADIO_ATLAS_DISABLE_ANIMATION', 'RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH']) vi.stubEnv(name, undefined); + vi.stubEnv('INK_SCREEN_READER', 'false'); + vi.stubEnv('LC_ALL', 'en_US.UTF-8'); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '1'); + vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv']); + vi.spyOn(airplayDiscovery, 'discoverAirPlayDevices').mockResolvedValue([]); + vi.spyOn(ProviderManager.prototype, 'health').mockResolvedValue({}); + vi.spyOn(session, 'startRadioSession').mockResolvedValue({close: async () => undefined}); + vi.spyOn(presence, 'registerTuiPresence').mockReturnValue(() => undefined); + vi.spyOn(PlayerController.prototype, 'stop').mockResolvedValue(undefined); +}); +afterEach(() => { + act(() => { for (const instance of instances.splice(0)) { instance.unmount(); instance.cleanup(); } }); + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + for (const directory of directories.splice(0)) rmSync(directory, {recursive: true, force: true}); +}); + +describe('App terminal integration', () => { + it('tunes the selected retained result after its search query is cleared', async () => { + const result: Station = {id: 'search-result', provider: 'radio-browser', name: 'Search Result FM', tags: []}; + const search = vi.spyOn(ProviderManager.prototype, 'search').mockResolvedValue([result]); + vi.spyOn(ProviderManager.prototype, 'resolve').mockResolvedValue({url: 'https://example.test/search-result'}); + const play = vi.spyOn(PlayerController.prototype, 'play').mockResolvedValue(undefined); + const {input, lastFrame} = await openApp(false); + + await input('4'); + await input('jazz'); + await input('\r'); + expect(lastFrame()).toContain('Search Result FM'); + + for (let index = 0; index < 4; index += 1) await input('\u007f'); + expect(lastFrame()).toContain('Search Result FM'); + await input('\r'); + + expect(search).toHaveBeenCalledTimes(1); + expect(play).toHaveBeenCalledWith(result, 'https://example.test/search-result'); + }); + + it.each([ + ['Ctrl+F', '\u0006'], + ['legacy Meta+F', '\u001bf'], + ['Kitty Super+F', '\u001b[102;9u'] + ])('favorites a selected Search result with %s while the query remains editable', async (_label, shortcut) => { + const result: Station = {id: 'favorite-result', provider: 'radio-browser', name: 'Favorite Result FM', tags: []}; + vi.spyOn(ProviderManager.prototype, 'search').mockResolvedValue([result]); + vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(false); + const {store, input, lastFrame} = await openApp(false); + + await input('4'); + await input('jazz'); + await input('\r'); + await input('f'); + expect(store.isFavorite(result)).toBe(false); + expect(lastFrame()).toContain('jazzf'); + await input(shortcut); + + expect(store.isFavorite(result)).toBe(true); + expect(lastFrame()).toContain('Added to favorites: Favorite Result FM'); + expect(lastFrame()).toContain('jazzf'); + + await act(async () => { await vi.advanceTimersByTimeAsync(4499); }); + expect(lastFrame()).toContain('Added to favorites: Favorite Result FM'); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + expect(lastFrame()).not.toContain('Added to favorites: Favorite Result FM'); + + await input(shortcut); + expect(store.isFavorite(result)).toBe(false); + expect(lastFrame()).toContain('Removed from favorites: Favorite Result FM'); + expect(lastFrame()).toContain('jazzf'); + await act(async () => { await vi.advanceTimersByTimeAsync(4500); }); + expect(lastFrame()).not.toContain('Removed from favorites: Favorite Result FM'); + }); + + it('keeps plain f favoriting available in station lists and Now Playing', async () => { + vi.spyOn(ProviderManager.prototype, 'resolve').mockResolvedValue({url: 'https://example.test/portable'}); + vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(false); + vi.spyOn(PlayerController.prototype, 'play').mockResolvedValue(undefined); + const {store, input} = await openApp(false); + const portable = store.snapshot().favorites.find(item => item.id === 'portable')!; + + await input('2'); + await input('\r'); + await input('f'); + expect(store.isFavorite(portable)).toBe(false); + + await input('b'); + await input('1'); + await input('f'); + expect(store.isFavorite(portable)).toBe(true); + }); + + it('keeps the TUI usable when its optional alarm presence directory is read-only', async () => { + vi.mocked(presence.registerTuiPresence).mockImplementation(() => {throw new Error('EACCES: private runtime directory');}); + const {input, lastFrame} = await openApp(false); + expect(lastFrame()).toContain('Alarm controls cannot register'); + await input('\u001B[B'); + await input('\r'); + expect(lastFrame()).toContain('Portable Radio'); + }); + + it('finishes unmount when optional alarm presence cleanup fails', async () => { + const warning = vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.mocked(presence.registerTuiPresence).mockReturnValue(() => {throw new Error('EACCES: private runtime directory');}); + await openApp(false); + expect(() => act(() => {const instance = instances.pop()!;instance.unmount();instance.cleanup();})).not.toThrow(); + expect(warning).toHaveBeenCalledWith(expect.stringContaining('could not remove its alarm-control presence marker')); + }); + + it.each(['RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH'])('skips automatic receiver discovery with %s while keeping library navigation', async variable => { + vi.stubEnv(variable, '1'); + const {input, lastFrame} = await openApp(false); + expect(airplayDiscovery.discoverAirPlayDevices).not.toHaveBeenCalled(); + await input('\u001B[B'); + await input('\r'); + expect(lastFrame()).toContain('Portable Radio'); + }); + + it('uses Ink accessibility state, keeps keyboard navigation, and leaves saved preferences intact', async () => { + setPlayback('playing'); + const visualizer = vi.spyOn(visualizers, 'buildVisualizer'); + const {store, input, frames, lastFrame} = await openApp(true); + const saved = store.snapshot().settings; + await input('\r'); + expect(lastFrame()).toContain('Now playing'); + expect(lastFrame()).toContain('Station: No station tuned'); + expect(visualizer).not.toHaveBeenCalled(); + const frameCount = frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + expect(frames).toHaveLength(frameCount); + await input('b'); + expect(lastFrame()).toContain('Live public radio from around the world'); + expect(lastFrame()).toContain('(selected)'); + expect(store.snapshot().settings).toEqual(saved); + expect(saved).toMatchObject({asciiMode: false, reduceMotion: false, transparentBackground: false}); + }); + + it.each([ + {env: {TERM: 'dumb'}, animated: false, redirected: false}, + {env: {RADIOCLI_SCREEN_READER: '1'}, animated: false, redirected: false}, + {env: {TERM: 'xterm-256color'}, animated: false, redirected: true}, + {env: {}, animated: true, redirected: false} + ])('keeps the loading spinner animated=$animated for $env with redirected=$redirected without changing settings', async ({env, animated, redirected}) => { + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value); + setPlayback('playing'); + vi.spyOn(ProviderManager.prototype, 'resolve').mockResolvedValue({url: 'https://example.test/portable'}); + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(() => new Promise(() => undefined)); + const {store, input, frames} = await openApp(false, redirected); + await input('\u001B[B'); + await input('\r'); + await input('\r'); + expect(play).toHaveBeenCalled(); + const frameCount = frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + if (animated) expect(frames.length).toBeGreaterThan(frameCount); + else expect(frames).toHaveLength(frameCount); + expect(store.snapshot().settings).toMatchObject({asciiMode: false, reduceMotion: false, transparentBackground: false}); + }); +}); + +function setPlayback(state: 'playing' | 'loading'): void { + const playback: PlaybackState = {backend: 'mpv', state, volume: 70, muted: false, ready: state === 'playing'}; + vi.spyOn(PlayerController.prototype, 'getState').mockReturnValue(playback); + vi.spyOn(PlayerController.prototype, 'onChange').mockImplementation(listener => { listener(playback); return () => undefined; }); +} + +async function openApp(screenReader: boolean, redirected = false) { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-portable-app-')); + directories.push(directory); + vi.stubEnv('RADIOCLI_HOME', directory); + const store = new JsonLibraryStore(join(directory, 'library.json')); + store.updateSettings({resumeOnLaunch: false, automaticUpdateChecks: false, asciiMode: false, reduceMotion: false, transparentBackground: false}); + store.toggleFavorite({id: 'portable', provider: 'radio-browser', name: 'Portable Radio', tags: [], streamUrl: 'https://example.test/portable'}); + const frames: string[] = []; + const stdout = Object.assign(new Writable({write(chunk, _encoding, done) { frames.push(String(chunk)); done(); }}), {columns: 100, rows: 30, ...(!redirected ? {isTTY: true, getColorDepth: () => 24} : {})}) as NodeJS.WriteStream; + const errors: string[] = []; + const stderr = new Writable({write(chunk, _encoding, done) { errors.push(String(chunk)); done(); }}) as NodeJS.WriteStream; + const stdin = Object.assign(new PassThrough(), {isTTY: true, setRawMode: () => undefined, ref: () => undefined, unref: () => undefined}) as unknown as NodeJS.ReadStream; + await act(async () => { + instances.push(render(, {stdout, stderr, stdin, debug: true, patchConsole: false, exitOnCtrlC: false, isScreenReaderEnabled: screenReader, kittyKeyboard: {mode: 'disabled'}})); + }); + expect(errors.join('')).toBe(''); + return { + store, frames, + lastFrame: () => stripVTControlCharacters(frames.slice().reverse().find(frame => frame.toLowerCase().includes('radiocli')) ?? ''), + input: async (data: string) => { await act(async () => { (stdin as unknown as PassThrough).write(data); }); } + }; +} diff --git a/src/ui/portable-render.test.tsx b/src/ui/portable-render.test.tsx new file mode 100644 index 0000000..46f9aa1 --- /dev/null +++ b/src/ui/portable-render.test.tsx @@ -0,0 +1,170 @@ +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {stripVTControlCharacters} from 'node:util'; +import {act, type ComponentProps} from 'react'; +import {Box, renderToString} from 'ink'; +import {cleanup, render} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {LibraryState, Screen, Station} from '../types.js'; +import {AppContent} from './AppContent.js'; +import {defaultExploreCursor} from './app-state.js'; +import {defaultAlarmDraft} from './alarm-editor.js'; +import {DisplayContext, resolveDisplayMode} from './display-context.js'; +import {computeTerminalLayout} from './layout.js'; +import * as visualizers from './visualizers/receiver-visualizers.js'; + +type ContentProps = ComponentProps; +const station: Station = {id: 'world', provider: 'radio-browser', name: '東京 FM / Café', country: 'Japan', countryCode: 'JP', city: '東京', tags: ['jazz'], codec: 'MP3', bitrate: 128}; +const library: LibraryState = { + favorites: [station], recent: [], imported: [], activity: {sessions: []}, trackHistory: [], searchHistory: [], alarms: [], + settings: {theme: 'green', receiverStyle: 'pulse-grid', volume: 70, enableRadioGarden: false, enableNearbyLocation: false, shareDirectoryVotes: true, preferredBackend: 'auto', tuneTimeoutSeconds: 12, skipBrokenStreams: true, mediaKeys: {previous: [], playPause: [], next: []}, resumeOnLaunch: false, asciiMode: false, reduceMotion: false, transparentBackground: false} +}; +const inkRequire = createRequire(import.meta.resolve('ink')); +const inkChalk = (await import(pathToFileURL(inkRequire.resolve('chalk')).href)).default as {level: number}; +const originalColorLevel = inkChalk.level; +const sizes = [{name: 'full', columns: 100, rows: 30}, {name: 'compact', columns: 50, rows: 16}, {name: 'micro', columns: 24, rows: 8}]; +const routes: Screen[] = ['home', 'now-playing', 'library', 'search', 'countries', 'explore', 'map', 'nearby', 'stations', 'stats', 'settings', 'help', 'airplay-code', 'airplay-settings', 'alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing']; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubEnv('INK_SCREEN_READER', 'false'); +}); +afterEach(() => { + act(() => cleanup()); + inkChalk.level = originalColorLevel; + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('portable terminal rendering', () => { + it.each(sizes)('preserves multilingual station and track data with ASCII decoration in $name', size => { + const props = contentProps('now-playing', size); + const display = resolveDisplayMode(library.settings, {LC_ALL: 'C', NO_COLOR: '1'}); + inkChalk.level = display.colorLevel; + const frame = staticFrame(, size.columns); + expect(frame).toContain('東京 FM / Café'); + if (size.name !== 'micro') expect(frame).toContain('音楽 / Café'); + expect(frame).not.toMatch(/[\u2500-\u259f\u2800-\u28ff]/u); + expect(frame).not.toContain('\u001B['); + }); + + it.each(sizes)('uses ASCII decoration across every route in $name', size => { + const display = resolveDisplayMode(library.settings, {TERM: 'dumb'}); + inkChalk.level = display.colorLevel; + for (const screen of routes) { + const frame = staticFrame(, size.columns); + expect(frame, screen).not.toMatch(/[\u2190-\u2193\u2500-\u259f\u2800-\u28ff·…›★☆∘◆“”]/u); + expect(frame, screen).not.toContain('\u001B['); + } + }); + + it.each(sizes)('hides receiver generation and renders readable screen-reader content in $name', async size => { + const visualizer = vi.spyOn(visualizers, 'buildVisualizer'); + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode(library.settings, {INK_SCREEN_READER: 'true'}); + const props = contentProps('now-playing', size); + let view!: ReturnType; + act(() => { view = render(); }); + const frame = stripVTControlCharacters(view.lastFrame() ?? ''); + expect(visualizer).not.toHaveBeenCalled(); + expect(frame).toContain('東京 FM / Café'); + expect(frame).toContain('音楽 / Café'); + expect(frame.toLowerCase()).toContain('playing'); + expect(frame).not.toMatch(/[\u2500-\u259f\u2800-\u28ff]/u); + const frames = view.frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(2400); }); + expect(view.frames).toHaveLength(frames); + }); + + it.each(['home', 'library', 'search', 'countries', 'explore', 'map', 'stats', 'settings', 'help', 'alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing'] as const)('retains the %s route in accessible output', screen => { + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode(library.settings, {INK_SCREEN_READER: 'true'}); + let view!: ReturnType; + act(() => { view = render(); }); + const frame = stripVTControlCharacters(view.lastFrame() ?? ''); + expect(frame.trim()).not.toBe(''); + expect(frame).not.toContain('Unknown screen.'); + if (screen === 'home') { + expect(frame).toContain('Playing'); + expect(frame).toContain('Settings'); + expect(frame).not.toContain('█'); + } + }); + + it.each([{name: 'Unicode', ascii: false}, {name: 'ASCII', ascii: true}])('summarizes the Stats heatmap without decorative rows in $name mode', ({ascii}) => { + vi.stubEnv('TZ', 'UTC'); + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '30'); + const props = contentProps('stats', sizes[0]!); + props.library = {...library, activity: {sessions: [ + {id: 'yesterday', station, startedAt: '2026-09-06T12:00:00.000Z', endedAt: '2026-09-06T13:00:00.000Z', listenedSeconds: 3600}, + {id: 'today', station, startedAt: '2026-09-07T09:00:00.000Z', endedAt: '2026-09-07T09:30:00.000Z', listenedSeconds: 1800} + ]}}; + const settings = {...library.settings, asciiMode: ascii}; + const visualDisplay = resolveDisplayMode(settings, {}); + const visual = staticFrame(, 100); + expect(stripVTControlCharacters(visual)).toMatch(/\bMon\b/u); + + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode(settings, {INK_SCREEN_READER: 'true'}); + let view!: ReturnType; + act(() => { view = render(); }); + const frame = stripVTControlCharacters(view.lastFrame() ?? ''); + expect(frame).toContain('Activity graph: 2 active days in 2026'); + expect(frame).toMatch(/Total hours listened:\s*1\.5h/u); + expect(frame).toMatch(/Sessions:\s*2/u); + expect(frame).toMatch(/Active days:\s*2\/371/u); + expect(frame).not.toMatch(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec|Mon|Wed|Fri|Less|More)\b/u); + }); + + it.each([{TERM: 'dumb'}, {RADIOCLI_SCREEN_READER: '1'}])('stops compact station marquees with %j', async env => { + const props = contentProps('library', sizes[2]!); + props.displayStations = [{...station, name: `${station.name} — A long international station name that would scroll`}]; + const display = resolveDisplayMode(library.settings, env); + let view!: ReturnType; + act(() => { view = render(); }); + const frames = view.frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(2400); }); + expect(view.frames).toHaveLength(frames); + }); + + it.each([{name: '16-color', env: {TERM: 'xterm'}, maximumLevel: 1}, {name: '256-color', env: {TERM: 'xterm-256color'}, maximumLevel: 2}])('keeps actual Ink colors within the $name terminal palette', ({env, maximumLevel}) => { + const display = resolveDisplayMode(library.settings, env); + inkChalk.level = display.colorLevel; + const frame = staticFrame(, 100); + expect(frame).toContain('\u001B['); + expect(frame).not.toMatch(/\u001B\[(?:38|48);2;/u); + if (maximumLevel === 1) expect(frame).not.toMatch(/\u001B\[(?:38|48);5;/u); + expect(stripVTControlCharacters(frame)).toContain('東京 FM / Café'); + }); +}); + +function staticFrame(tree: Parameters[0], columns: number): string { + let frame = ''; + act(() => { frame = renderToString(tree, {columns}); }); + return frame; +} + +function contentProps(screen: Screen, size: {columns: number; rows: number}): ContentProps { + const layout = computeTerminalLayout(size.columns, size.rows); + return { + airPlayDevices: [], airPlayCode: '', appVersion: '0.2.3', backends: ['mpv'], countryFilter: '', + diagnostics: {backend: 'mpv', availableBackends: ['mpv'], preferredBackend: 'auto', active: true, volume: 70, muted: false, ready: true}, + displayStations: [station], editingCountryFilter: false, editingSearch: true, + exploreCursor: defaultExploreCursor, favoriteKeys: new Set(['radio-browser:world']), filterLabel: 'none', + filteredCountries: [{name: 'Japan', code: 'JP', stationCount: 456}], frameWidth: layout.frameWidth, layout, library, + loadingCountries: false, loadingStations: false, nowPlaying: {title: '音楽 / Café', updatedAt: '2026-09-07T12:00:00.000Z'}, + playback: {backend: 'mpv', state: 'playing', volume: 70, muted: false, ready: true}, playingStation: station, providerHealth: {}, + searchQuery: '東京', screen, settingsPage: 'root', selected: 0, showDiagnostics: false, sleepLabel: 'Sleep off', + stationContext: {title: 'Library', subtitle: 'Saved stations', stations: [station]}, stationFavorite: true, stationTime: '12:00', storePath: '/fixture/radiocli.json', theme: 'green', + alarmTui: { + draft: defaultAlarmDraft(station), editorField: 'label', editingField: false, editorControl: null, timeSegment: 'hour', weekdayIndex: 0, + validationError: null, saving: false, pickerChoices: [], pickerFallback: false, runtime: null, verification: null, activeAlarms: [], activeSelected: 0, + snoozeMinutes: 10, busyAlarmIds: new Set(), itemCount: () => undefined, handleInput: () => false, openForStation: () => undefined, openActive: () => undefined + } + }; +} diff --git a/src/ui/screens/AirPlayCodeScreen.tsx b/src/ui/screens/AirPlayCodeScreen.tsx index dca3b5c..09af02c 100644 --- a/src/ui/screens/AirPlayCodeScreen.tsx +++ b/src/ui/screens/AirPlayCodeScreen.tsx @@ -3,6 +3,8 @@ import {Box, Text} from 'ink'; import type {AirPlayDevice, PlaybackState, ThemeName} from '../../types.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; import {textDim, textMuted, themeAccent} from '../theme.js'; type AirPlayCodeScreenProps = { @@ -20,6 +22,8 @@ export function AirPlayCodeScreen({ theme, width }: AirPlayCodeScreenProps): React.ReactElement { + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const lineWidth = Math.max(24, width - 4); const promptActive = isAirPlayCodePromptActive(playback); @@ -38,7 +42,7 @@ export function AirPlayCodeScreen({ - Receiver: {truncate(receiverName, Math.max(8, lineWidth - 10))} + Receiver: {a(truncate(receiverName, Math.max(8, lineWidth - 10)))} Playback: {playback.backend} / {playback.state} @@ -55,7 +59,7 @@ export function AirPlayCodeScreen({ Code {code ? masked : 'type the code from the receiver'} - Enter submits · Backspace edits · Esc returns to AirPlay + {a('Enter submits · Backspace edits · Esc returns to AirPlay')} ); diff --git a/src/ui/screens/AirPlaySettingsScreen.tsx b/src/ui/screens/AirPlaySettingsScreen.tsx index fb242d7..13bacf3 100644 --- a/src/ui/screens/AirPlaySettingsScreen.tsx +++ b/src/ui/screens/AirPlaySettingsScreen.tsx @@ -12,6 +12,8 @@ import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; import {textDim, textMuted, themeAccent} from '../theme.js'; import {visibleWindow} from '../list-window.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type AirPlaySettingsScreenProps = { selected: number; @@ -32,6 +34,8 @@ export function AirPlaySettingsScreen({ width, height }: AirPlaySettingsScreenProps): React.ReactElement { + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const availability = airPlayAvailability(backends, devices); const selectedDevice = selectedAirPlayDevice(settings, devices); @@ -53,13 +57,13 @@ export function AirPlaySettingsScreen({ - Audio output: {preferredOutput} · Streaming:{' '} - {availability.ready ? 'ready' : 'unavailable'} · Receivers:{' '} + Audio output: {preferredOutput}{a(' · Streaming: ')} + {availability.ready ? 'ready' : 'unavailable'}{a(' · Receivers: ')} {devices.length} - {truncate(availability.detail, lineWidth)} + {a(truncate(availability.detail, lineWidth))} - Selected: {truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10))} + Selected: {a(truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)))} {availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? ( @@ -68,7 +72,7 @@ export function AirPlaySettingsScreen({ ) : null} {selectedMissing ? ( - Saved receiver is not visible: {truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))} + Saved receiver is not visible: {a(truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31)))} ) : null} @@ -91,10 +95,10 @@ export function AirPlaySettingsScreen({ - {name} + {a(name)} {isSelected ? {selectedLabel} : null} - · {detail} + {a(` · ${detail}`)} ); }} @@ -103,7 +107,7 @@ export function AirPlaySettingsScreen({ No receivers discovered. )} - {canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers'} + {a(canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers')} diff --git a/src/ui/screens/ExploreScreen.tsx b/src/ui/screens/ExploreScreen.tsx index af83256..1401ccd 100644 --- a/src/ui/screens/ExploreScreen.tsx +++ b/src/ui/screens/ExploreScreen.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {Box, Text} from 'ink'; import type {Station, ThemeName} from '../../types.js'; import type {ExploreCursor} from '../app-state.js'; +import {formatExploreCursor} from '../app-state.js'; import {StationList} from '../components/StationList.js'; import {buildCosmoWorldMap, type CosmoMapCellKind, type CosmoMapRow} from '../cosmo-world-map.js'; import {computeExploreMapLayout} from '../explore-map-layout.js'; @@ -71,6 +72,7 @@ export function ExploreScreen({ width={mapPanelWidth} height={split ? bodyRows : mapRows + 2} flexDirection="column" + aria-label={`World map cursor: ${formatExploreCursor(cursor)}`} > {map.map((row, index) => ( diff --git a/src/ui/screens/HelpScreen.tsx b/src/ui/screens/HelpScreen.tsx index b09650b..7a8c679 100644 --- a/src/ui/screens/HelpScreen.tsx +++ b/src/ui/screens/HelpScreen.tsx @@ -6,6 +6,8 @@ import {ScreenHeader} from '../components/ScreenHeader.js'; import {commandHelp, keyHelpSections} from '../help-content.js'; import {padDisplayEnd, truncate} from '../format.js'; import {visibleWindow} from '../list-window.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type HelpScreenProps = { theme: ThemeName; @@ -17,6 +19,8 @@ type HelpScreenProps = { type HelpRow = {key: string; heading?: string; keys?: string; description?: string}; export function HelpScreen({theme, width, height, selected}: HelpScreenProps): React.ReactElement { + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const keyColumnWidth = 16; const lineWidth = Math.max(28, width - 2); @@ -47,12 +51,12 @@ export function HelpScreen({theme, width, height, selected}: HelpScreenProps): R {row.heading} ) : ( - {padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth)} - {truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth))} + {a(padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth))} + {a(truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth)))} ))} - Showing {window.start + 1}-{window.end} of {rows.length} · ↑/↓ scroll + {a(`Showing ${window.start + 1}-${window.end} of ${rows.length} · ↑/↓ scroll`)} ); } diff --git a/src/ui/screens/HomeScreen.tsx b/src/ui/screens/HomeScreen.tsx index a8d52df..ea2313b 100644 --- a/src/ui/screens/HomeScreen.tsx +++ b/src/ui/screens/HomeScreen.tsx @@ -6,6 +6,8 @@ import {textMuted, themeAccent} from '../theme.js'; import {homeItems} from '../screen-items.js'; import type {LibraryState, ThemeName} from '../../types.js'; import {displayWidth} from '../format.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type HomeProps = { selected: number; @@ -14,6 +16,8 @@ type HomeProps = { }; export function HomeScreen({selected, theme, library}: HomeProps): React.ReactElement { + const {ascii} = useDisplay(); + const summary = `${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`; const titleWidth = Math.max(...homeItems.map(item => displayWidth(item.label))) + 2; return ( @@ -39,7 +43,7 @@ export function HomeScreen({selected, theme, library}: HomeProps): React.ReactEl - {library.recent.length} recent · {library.favorites.length} favorites · {library.imported.length} imported + {ascii ? toAsciiSafe(summary) : summary} diff --git a/src/ui/screens/MapScreen.tsx b/src/ui/screens/MapScreen.tsx index 694e9ed..bd1b964 100644 --- a/src/ui/screens/MapScreen.tsx +++ b/src/ui/screens/MapScreen.tsx @@ -53,7 +53,7 @@ export function MapScreen({ right={filter ? `filter: ${filter}` : undefined} /> {loading ? {asciify('Loading country density…')} : null} - + {graph.rows.map((row, index) => ( ))} diff --git a/src/ui/screens/NowPlayingScreen.tsx b/src/ui/screens/NowPlayingScreen.tsx index 9b4610a..822aed0 100644 --- a/src/ui/screens/NowPlayingScreen.tsx +++ b/src/ui/screens/NowPlayingScreen.tsx @@ -53,7 +53,7 @@ export function NowPlayingScreen({ height }: NowPlayingProps): React.ReactElement { const pulse = useReceiverPulse(); - const {panel: panelBackground, ascii} = useDisplay(); + const {panel: panelBackground, ascii, screenReader} = useDisplay(); // In ASCII mode route every rendered string through the glyph mapper so no // braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal. const a = (value: string): string => (ascii ? toAsciiSafe(value) : value); @@ -71,7 +71,7 @@ export function NowPlayingScreen({ panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth ); - const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme); + const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme); const receiverState = playback.state.toUpperCase(); const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2); const stationIdentity = receiverStationIdentity(station, identityWidth); @@ -105,13 +105,13 @@ export function NowPlayingScreen({ height={panelHeight} > - + {a(stationIdentity.name)} {stationIdentity.location ? {a(` · ${stationIdentity.location}`)} : null} - {receiverState} + {receiverState} - + {!screenReader ? {renderRows.map((row, index) => ( {row.segments @@ -119,10 +119,10 @@ export function NowPlayingScreen({ : row.text} ))} - + : null} - {a(metadataLine)} - {a(favoriteText)} + {a(metadataLine)} + {a(favoriteText)} {showDiagnostics ? ( diff --git a/src/ui/screens/SearchScreen.tsx b/src/ui/screens/SearchScreen.tsx index df0e391..0529314 100644 --- a/src/ui/screens/SearchScreen.tsx +++ b/src/ui/screens/SearchScreen.tsx @@ -6,6 +6,7 @@ import {ScreenHeader} from '../components/ScreenHeader.js'; import {panelBorder, textMuted, themeAccent} from '../theme.js'; import {panelBorderStyle, useDisplay} from '../display-context.js'; import {truncate} from '../format.js'; +import {toAsciiSafe} from '../ascii.js'; type SearchScreenProps = { query: string; @@ -35,6 +36,7 @@ export function SearchScreen({ width }: SearchScreenProps): React.ReactElement { const {panel: panelBackground, ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const contentWidth = Math.max(40, width); const inputWidth = Math.max(34, Math.min(contentWidth, 96)); const inputTextWidth = Math.max(8, inputWidth - 8); @@ -59,10 +61,10 @@ export function SearchScreen({ marginBottom={1} flexShrink={0} > - {editing ? '› ' : ' '} - {truncate(query || 'Search stations, genres, languages, places…', inputTextWidth)} + {a(editing ? '› ' : ' ')} + {a(truncate(query || 'Search stations, genres, languages, places…', inputTextWidth))} - {loading ? Searching station directories… : null} + {loading ? {a('Searching station directories…')} : null} {!loading || stations.length > 0 ? ( ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const lineWidth = Math.max(32, width - 4); const group = settingsGroup(page); @@ -89,12 +93,12 @@ export function SettingsScreen({ - {truncate(label, labelWidth)} + {a(truncate(label, labelWidth))} - {truncate(value ?? '', valueWidth)} + {a(truncate(value ?? '', valueWidth))} ); @@ -103,7 +107,7 @@ export function SettingsScreen({ Selected - {truncate(detail, lineWidth)} + {a(truncate(detail, lineWidth))} ); diff --git a/src/ui/screens/StatsScreen.tsx b/src/ui/screens/StatsScreen.tsx index f3413d9..0d29de5 100644 --- a/src/ui/screens/StatsScreen.tsx +++ b/src/ui/screens/StatsScreen.tsx @@ -6,6 +6,7 @@ import {panelBorder, textMuted, themeAccent, themeContributionColors} from '../t import {panelBorderStyle, useDisplay} from '../display-context.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; +import {toAsciiSafe} from '../ascii.js'; type StatsScreenProps = { library: LibraryState; @@ -40,6 +41,7 @@ type ContributionGraph = { export function StatsScreen({library, theme, width, height}: StatsScreenProps): React.ReactElement { const {panel: panelBackground, ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const stats = computeListeningStats(library.activity.sessions); const contentWidth = Math.max(20, width - 4); const graph = buildContributionGraph(stats.days, contentWidth, height); @@ -70,10 +72,10 @@ export function StatsScreen({library, theme, width, height}: StatsScreenProps): > - Activity — {graph.year} + {a(`Activity — ${graph.year}`)} - + {' '.repeat(dayLabelWidth)}{graph.months} {graph.rows.map(row => ( @@ -101,13 +103,13 @@ export function StatsScreen({library, theme, width, height}: StatsScreenProps): Summary - {metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme)} + {metricPair('Favorite station', a(truncate(favorite, favoriteWidth)), 'Total hours listened', formatHours(totalHours), metricWidth, theme)} {metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme)} {metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme)} {metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Stations counted after', '2 min', metricWidth, theme)} - - Less · + + {a('Less · ')} {graphColors.map(color => ( {renderLegendCell(color, graph.cellText, graph.cellGap)} diff --git a/src/ui/screens/screen-render.test.tsx b/src/ui/screens/screen-render.test.tsx index 150b774..b212183 100644 --- a/src/ui/screens/screen-render.test.tsx +++ b/src/ui/screens/screen-render.test.tsx @@ -1,6 +1,6 @@ import {act} from 'react'; -import {render} from 'ink-testing-library'; -import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render as inkRender} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import type {AppSettings, LibraryState, PlaybackDiagnostics, PlaybackState, Station, TrackPlay} from '../../types.js'; import {DisplayContext, resolveDisplayMode} from '../display-context.js'; import {HelpScreen} from './HelpScreen.js'; @@ -16,6 +16,26 @@ import {defaultExploreCursor} from '../app-state.js'; import {StationList} from '../components/StationList.js'; import {displayWidth} from '../format.js'; +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // Ink's fixture supplies 100 columns but no rows. Keep its 24-row fallback + // deterministic without asking the host terminal through commands like tput. + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '24'); +}); +afterEach(() => { + act(() => cleanup()); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function render(tree: Parameters[0]): ReturnType { + let view!: ReturnType; + act(() => { view = inkRender(tree); }); + return view; +} + const station: Station = { id: 'station-1', provider: 'radio-browser', @@ -100,8 +120,6 @@ const library: LibraryState = { }; describe('HomeScreen rendering', () => { - afterEach(() => vi.useRealTimers()); - it('does not repeat playback status already shown in the app header', () => { const frame = render().lastFrame() ?? ''; @@ -120,8 +138,6 @@ describe('HomeScreen rendering', () => { }); describe('StationList rendering', () => { - afterEach(() => vi.useRealTimers()); - it('replaces standard metadata with selected-station tags on the same row', () => { const frame = render( { }); describe('CountriesScreen rendering', () => { - afterEach(() => vi.useRealTimers()); - it('marquees a focused long name while keeping the row to one terminal line', async () => { vi.useFakeTimers(); const view = render( diff --git a/src/ui/system-actions.test.ts b/src/ui/system-actions.test.ts index 66a013f..fcba2de 100644 --- a/src/ui/system-actions.test.ts +++ b/src/ui/system-actions.test.ts @@ -1,16 +1,116 @@ -import {describe, expect, it} from 'vitest'; -import {clipboardCommands, openExternalCommand} from './system-actions.js'; +import {EventEmitter} from 'node:events'; +import {PassThrough} from 'node:stream'; +import {once} from 'node:events'; +import type {ChildProcess} from 'node:child_process'; +import {describe, expect, it, vi} from 'vitest'; +import {copyToClipboard, openExternal} from './system-actions.js'; + +vi.mock('node:child_process', () => ({spawn: vi.fn(() => { throw new Error('Desktop tests must inject their launcher.'); })})); describe('system actions', () => { - it('opens URLs with the platform default handler', () => { - expect(openExternalCommand('darwin')).toEqual({command: 'open', args: []}); - expect(openExternalCommand('win32')).toEqual({command: 'explorer', args: []}); - expect(openExternalCommand('linux')).toEqual({command: 'xdg-open', args: []}); + it('does not report an asynchronous browser launch failure as success', async () => { + const launch = fakeLauncher('error'); + await expect(openExternal('https://example.test/?x=1&y=2', 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); + }); + + it('passes a validated URL as one literal argument and waits for completion', async () => { + const launch = fakeLauncher(0); + const url = 'https://example.test/?x=1&y=%22'; + await expect(openExternal(url, 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(true); + expect(launch.mock.calls[0]?.slice(0, 2)).toEqual(['explorer', [url]]); + await expect(openExternal('file:///etc/passwd', 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); + expect(launch).toHaveBeenCalledOnce(); + }); + + it('falls through to another clipboard tool after a nonzero exit', async () => { + const launch = fakeLauncher(1, 0); + const text = 'Zoë 日本 & $(literal)'; + await expect(copyToClipboard(text, 'linux', {spawn: launch, env: {DISPLAY: ':0'}, resolve: name => name})).resolves.toBe(true); + expect(launch.mock.calls.map(call => call[0])).toEqual(['wl-copy', 'xclip']); + expect(launch.inputs).toEqual([text, text]); + }); + + it('reports missing tools and headless sessions without launching', async () => { + const launch = fakeLauncher(0); + await expect(copyToClipboard('text', 'linux', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); + await expect(openExternal('https://example.test', 'darwin', {spawn: launch, env: {SSH_CONNECTION: 'test'}, resolve: name => name})).resolves.toBe(false); + await expect(copyToClipboard('text', 'darwin', {spawn: launch, env: {}, resolve: () => null})).resolves.toBe(false); + expect(launch).not.toHaveBeenCalled(); }); - it('lists platform clipboard tools in priority order', () => { - expect(clipboardCommands('darwin').map(entry => entry.command)).toEqual(['pbcopy']); - expect(clipboardCommands('win32').map(entry => entry.command)).toEqual(['clip']); - expect(clipboardCommands('linux').map(entry => entry.command)).toEqual(['wl-copy', 'xclip', 'xsel']); + it('handles clipboard stdin failure and bounds a hung helper', async () => { + const broken = fakeLauncher('pipe-error'); + await expect(copyToClipboard('text', 'darwin', {spawn: broken, env: {}, resolve: name => name})).resolves.toBe(false); + const hung = fakeLauncher('hang'); + await expect(copyToClipboard('text', 'darwin', {spawn: hung, env: {}, resolve: name => name, timeoutMs: 10})).resolves.toBe(false); + expect(hung.kills.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]); + }); + + it('terminates an actual helper that ignores graceful shutdown before reporting timeout', async () => { + const {spawn} = await vi.importActual('node:child_process'); + let child: ChildProcess | undefined; + let closed = false; + try { + child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});process.stdin.resume();setInterval(()=>{},1000);process.send('ready');"], {stdio: ['pipe', 'ignore', 'ignore', 'ipc']}); + await once(child, 'message'); + child.once('close', () => { closed = true; }); + const result = await copyToClipboard('literal text', 'darwin', { + env: {}, resolve: () => process.execPath, timeoutMs: 50, + spawn: () => child! + }); + expect(result).toBe(false); + expect(closed).toBe(true); + expect(child!.exitCode !== null || child!.signalCode !== null).toBe(true); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close'); + child.kill('SIGKILL'); + await closed; + } + } + }); + + it('accepts a long-lived browser opener without terminating its application', async () => { + const {spawn} = await vi.importActual('node:child_process'); + let child: ChildProcess | undefined; + try { + const result = await openExternal('https://example.test/?x=1&y=%22', 'linux', { + env: {DISPLAY: ':0'}, resolve: () => process.execPath, timeoutMs: 500, + spawn: (_command, _args, options) => { + child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});setInterval(()=>{},1000);"], options); + return child; + } + }); + expect(result).toBe(true); + expect(child!.exitCode).toBeNull(); + expect(child!.signalCode).toBeNull(); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close'); + child.kill('SIGKILL'); + await closed; + } + } }); }); + +function fakeLauncher(...results: Array) { + const inputs: string[] = []; + const kills = vi.fn(); + const launch = vi.fn((_command: string, _args: readonly string[], _options?: unknown) => { + const child = new EventEmitter() as ChildProcess; + child.stdin = new PassThrough(); + child.kill = kills; + child.unref = vi.fn(); + const index = inputs.push('') - 1; + child.stdin.on('data', chunk => { inputs[index] += String(chunk); }); + const result = results.shift() ?? 1; + queueMicrotask(() => { + if (result === 'error') child.emit('error', new Error('Command unavailable')); + else if (result === 'pipe-error') child.stdin?.emit('error', new Error('EPIPE')); + else if (result !== 'hang') child.emit('close', result); + }); + return child; + }); + return Object.assign(launch, {inputs, kills}); +} diff --git a/src/ui/system-actions.ts b/src/ui/system-actions.ts index a0fcc09..5f8339c 100644 --- a/src/ui/system-actions.ts +++ b/src/ui/system-actions.ts @@ -1,79 +1,86 @@ -import {spawn} from 'node:child_process'; -import {resolveCommand} from '../player/command.js'; +import {spawn, type ChildProcess, type SpawnOptions} from 'node:child_process'; +import {resolveCommand} from '../platform/executables.js'; import {safeExternalHttpUrl} from '../safety.js'; +import {browserCommands, clipboardCandidates, hasGraphicalSession} from '../platform/desktop.js'; +import {identifyPlatform} from '../platform/runtime.js'; +import {waitForLaunch} from '../platform/launch-command.js'; -type SystemCommand = { - command: string; - args: string[]; +type ActionDeps = { + env?: NodeJS.ProcessEnv; + resolve?: (command: string) => string | null; + spawn?: (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; + timeoutMs?: number; }; -// The command used to open a URL in the platform's default handler. -export function openExternalCommand(platform: NodeJS.Platform = process.platform): SystemCommand { - if (platform === 'darwin') { - return {command: 'open', args: []}; - } - - if (platform === 'win32') { - // Direct invocation avoids cmd.exe interpreting URL query characters. - return {command: 'explorer', args: []}; - } - - return {command: 'xdg-open', args: []}; -} - -// Candidate clipboard tools per platform, in priority order. pbcopy/clip ship -// with macOS/Windows; Linux/BSD rely on whichever of these is installed. -export function clipboardCommands(platform: NodeJS.Platform = process.platform): SystemCommand[] { - if (platform === 'darwin') { - return [{command: 'pbcopy', args: []}]; - } - - if (platform === 'win32') { - return [{command: 'clip', args: []}]; - } - - return [ - {command: 'wl-copy', args: []}, - {command: 'xclip', args: ['-selection', 'clipboard']}, - {command: 'xsel', args: ['--clipboard', '--input']} - ]; -} - -export function openExternal(url: string, platform: NodeJS.Platform = process.platform): boolean { +export async function openExternal(url: string, platform: NodeJS.Platform = process.platform, deps: ActionDeps = {}): Promise { const safeUrl = safeExternalHttpUrl(url); - if (!safeUrl) { - return false; + const env = deps.env ?? process.env; + const host = identifyPlatform({platform, env}); + if (!safeUrl || !hasGraphicalSession(host, env)) return false; + for (const {command, args} of browserCommands(host)) { + const executable = (deps.resolve ?? resolveCommand)(command); + if (!executable) continue; + try { + // xdg-open may run for the browser's entire lifetime. Observe startup + // failure, then let the desktop application own its detached process. + const child = (deps.spawn ?? spawn)(executable, [...args, safeUrl], {stdio: 'ignore', env, detached: true, windowsHide: true}); + await waitForLaunch(child); + return true; + } catch { /* Try the next available opener after a launch failure. */ } } + return false; +} - const {command, args} = openExternalCommand(platform); - try { - const child = spawn(resolveCommand(command) ?? command, [...args, safeUrl], {stdio: 'ignore', detached: true}); - child.on('error', () => undefined); - child.unref(); - return true; - } catch { - // Opening a browser is best-effort; never crash the TUI over it. - return false; +export async function copyToClipboard(text: string, platform: NodeJS.Platform = process.platform, deps: ActionDeps = {}): Promise { + const env = deps.env ?? process.env; + const host = identifyPlatform({platform, env}); + if (!hasGraphicalSession(host, env)) return false; + for (const {command, args} of clipboardCandidates(host)) { + const executable = (deps.resolve ?? resolveCommand)(command); + if (executable && await runHelper(executable, args, text, deps)) return true; } + return false; } -export function copyToClipboard(text: string, platform: NodeJS.Platform = process.platform): boolean { - for (const {command, args} of clipboardCommands(platform)) { - const resolved = resolveCommand(command); - if (!resolved) { - continue; - } - +function runHelper(command: string, args: string[], input: string, deps: ActionDeps): Promise { + return new Promise(resolve => { + let child: ChildProcess | undefined; + let settled = false; + let stopping = false; + let forceTimer: NodeJS.Timeout | undefined; + let cleanupTimer: NodeJS.Timeout | undefined; + const signal = (value: NodeJS.Signals) => { + try { child?.kill(value); } catch { /* The failed action remains false; cleanup is still bounded. */ } + }; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(forceTimer); + clearTimeout(cleanupTimer); + resolve(ok); + }; + const stop = () => { + if (settled || stopping) return; + stopping = true; + forceTimer = setTimeout(() => signal('SIGKILL'), 250); + cleanupTimer = setTimeout(() => { + // Even an uninterruptible OS process must not keep RadioCLI alive. + child?.unref(); + finish(false); + }, 1_000); + child?.stdin?.destroy(); + signal('SIGTERM'); + }; + const timer = setTimeout(stop, deps.timeoutMs ?? 5000); try { - const child = spawn(resolved, args, {stdio: ['pipe', 'ignore', 'ignore']}); - child.on('error', () => undefined); - child.stdin.write(text); - child.stdin.end(); - return true; + child = (deps.spawn ?? spawn)(command, args, {stdio: ['pipe', 'ignore', 'ignore'], env: deps.env ?? process.env, windowsHide: true}); + child.on('error', () => { if (child?.pid) stop(); else finish(false); }); + child.once('close', code => finish(!stopping && code === 0)); + child.stdin?.on('error', stop); + child.stdin?.end(input); } catch { - continue; + if (child?.pid) stop(); else finish(false); } - } - - return false; + }); } diff --git a/src/ui/terminal-renderer.ts b/src/ui/terminal-renderer.ts new file mode 100644 index 0000000..3a5ac7c --- /dev/null +++ b/src/ui/terminal-renderer.ts @@ -0,0 +1,17 @@ +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {resolveTerminalCapabilities, type TerminalCapabilities, type TerminalEvidence} from '../platform/terminal.js'; + +/** + * Ink uses Chalk's shared instance for both text and panel fills. FORCE_COLOR + * is a minimum in Chalk, so setting it cannot cap a Windows/CI/truecolor host + * at 16 or 256 colors. Set the instance's level before the first Ink render. + * Resolve from Ink itself so nested dependency installations work as well. + */ +export async function configureTerminalRenderer(env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): Promise { + const terminal = resolveTerminalCapabilities(env, evidence); + const inkRequire = createRequire(import.meta.resolve('ink')); + const {default: chalk} = await import(pathToFileURL(inkRequire.resolve('chalk')).href) as {default: {level: number}}; + chalk.level = terminal.colorLevel; + return terminal; +} diff --git a/src/ui/use-alarm-tui.ts b/src/ui/use-alarm-tui.ts index 27beb48..155b080 100644 --- a/src/ui/use-alarm-tui.ts +++ b/src/ui/use-alarm-tui.ts @@ -175,6 +175,39 @@ export function useAlarmTui(params: Params): AlarmTuiController { await refreshRuntime(); }, [refreshRuntime, service, setMessage]); + const deleteAlarm = useCallback(async (alarm: Alarm) => { + let phase: 'disable' | 'native' | 'definition' = 'disable'; + let message: string; + try { + // Persist the safe state before touching native jobs. A failed cleanup + // can then be retried without another write in the error handler. + const disabled = store.toggleAlarm(alarm.id, false); + const disabledSnapshot = store.snapshot(); + alarmStateRef.current = disabledSnapshot.alarms; + setLibrary(disabledSnapshot); + phase = 'native'; + await service.remove(disabled); + phase = 'definition'; + store.removeAlarm(alarm.id); + const snapshot = store.snapshot(); + alarmStateRef.current = snapshot.alarms; + setLibrary(snapshot); + setSelected(value => clamp(value, snapshot.alarms.length + 1)); + message = `Deleted ${alarm.label}.`; + } catch (error) { + message = phase === 'disable' + ? `Delete canceled: ${messageOf(error)}. Alarm and native jobs unchanged.` + : phase === 'native' + ? `Delete cleanup failed: ${messageOf(error)}. ${alarm.label} was kept disabled; press r to repair.` + : `Native cleanup completed. Save failed: ${messageOf(error)}. ${alarm.label} was kept disabled; retry deletion.`; + } finally { + busyAlarmIdsRef.current.delete(alarm.id); + setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); + } + await refreshRuntime(); + setMessage(message); + }, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]); + const saveDraft = useCallback(() => { if (!draft || savingRef.current) return; const error = validateAlarmDraft(draft); @@ -248,13 +281,8 @@ export function useAlarmTui(params: Params): AlarmTuiController { if(alarmIsActive){setMessage(`Dismiss or snooze ${alarm.label} before deleting it; current playback was left untouched.`);return true;} if (deletingIdRef.current !== alarm.id) { deletingIdRef.current = alarm.id; setDeletingId(alarm.id); setMessage(`Press x again to delete “${alarm.label}”.`); return true; } deletingIdRef.current = undefined; setDeletingId(undefined); busyAlarmIdsRef.current.add(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); - void service.remove(alarm).then(() => { - if (store.getAlarm(alarm.id)) store.removeAlarm(alarm.id); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); setSelected(value => clamp(value, snapshot.alarms.length + 1)); setMessage(`Deleted ${alarm.label}.`); - }).catch(error => { - const existing = store.getAlarm(alarm.id); - if (existing) { const disabled = store.toggleAlarm(alarm.id, false); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); setMessage(`Delete cleanup failed; ${disabled.label} was kept disabled so Repair can retry: ${messageOf(error)}`); } - else setMessage(`Delete cleanup failed after the local alarm disappeared: ${messageOf(error)}`); - }).finally(() => { busyAlarmIdsRef.current.delete(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); void refreshRuntime(); }); return true; + void deleteAlarm(alarm).catch(error => setMessage(`Unexpected alarm deletion failure: ${messageOf(error)}`)); + return true; } if (input !== 'x') { deletingIdRef.current = undefined; setDeletingId(undefined); } if (input === 't' && alarm) { void previewStation(alarm.station).then(() => setMessage(`Test-tuning ${alarm.station.name}; no alarm state changed.`)).catch(error => setMessage(`Test tune failed: ${messageOf(error)}`)); return true; } @@ -361,7 +389,7 @@ export function useAlarmTui(params: Params): AlarmTuiController { return true; } return false; - }, [activeAlarms, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]); + }, [activeAlarms, deleteAlarm, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]); const openActive = useCallback(() => { if (!activeAlarms.length) { setMessage('No alarm is currently playing.'); return; } diff --git a/src/ui/use-app-input.ts b/src/ui/use-app-input.ts index 77b05b5..114e5d6 100644 --- a/src/ui/use-app-input.ts +++ b/src/ui/use-app-input.ts @@ -1,5 +1,5 @@ -import {useEffect} from 'react'; -import {useInput} from 'ink'; +import {useCallback, useEffect} from 'react'; +import {useInput, type Key} from 'ink'; import type {Dispatch, SetStateAction} from 'react'; import type {Country, Screen, Station, AppSettings} from '../types.js'; import type {PlayerController} from '../player/player-controller.js'; @@ -9,9 +9,11 @@ import { favoriteTarget, isEditableInput, isPlainPrintableInput, + isSearchFavoriteShortcut, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, + shouldTuneSearchResult, shouldToggleNearbyLocationShortcut, type ExploreMoveDirection, type MediaTransportAction @@ -19,6 +21,7 @@ import { import {parseTerminalMouseEvents, primaryMousePress, wheelScrollDelta} from './terminal-mouse.js'; import {completeCommand} from './help-content.js'; import {commitImmediateSelection} from './selection-state.js'; +import {sanitizeTerminalText} from '../safety.js'; type CurrentRef = { current: T; @@ -40,7 +43,7 @@ type AppInputOptions = { commandText: string; confirmCtrlCExit: () => void; copyStationUrl: (station: Station | null) => Promise; - openStationHomepage: (station: Station | null) => void; + openStationHomepage: (station: Station | null) => Promise; currentItemCount: (screen: Screen) => number; cycleDisplayColor: () => void; cycleAudioOutput: () => void; @@ -190,6 +193,9 @@ export function useAppInput({ toggleSkipBrokenStreams, updateFromSettings }: AppInputOptions): void { + const reportInputError = useCallback((error: unknown) => { + setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.'); + }, [setMessage]); const commitSelection = (next: number): void => { commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screen)); }; @@ -199,7 +205,7 @@ export function useAppInput({ }; useEffect(() => { - const onData = (data: Buffer | string) => { + const handleData = (data: Buffer | string) => { const rawInput = String(data); if (capturingTransportAction) { if (rawInput === '\u001B') { @@ -253,6 +259,10 @@ export function useAppInput({ } }; + const onData = (data: Buffer | string) => { + try { handleData(data); } catch (error) { reportInputError(error); } + }; + stdin.on('data', onData); return () => { stdin.off('data', onData); @@ -268,6 +278,7 @@ export function useAppInput({ lastRawTransportAtRef, playAdjacent, player, + reportInputError, moveExploreCursorToCell, saveLearnedTransportKey, screen, @@ -280,7 +291,7 @@ export function useAppInput({ togglePause ]); - useInput((input, key) => { + const handleInput = async (input: string, key: Key): Promise => { if (key.ctrl && input === 'c') { confirmCtrlCExit(); return; @@ -300,9 +311,9 @@ export function useAppInput({ if (commandMode) { if (key.return) { - void executeCommand(commandText); setCommandText(''); setCommandMode(false); + await executeCommand(commandText); return; } @@ -329,16 +340,21 @@ export function useAppInput({ return; } - // An active text field owns its keystrokes before app-wide shortcuts. In - // particular, a normal "a" in a station query must not invoke the global - // schedule-this-station shortcut and leave Search for the alarm editor. + // An active text field owns ordinary keystrokes before app-wide shortcuts. + // Dedicated modified shortcuts are handled here so plain query characters + // never trigger actions or leave Search. if (screen === 'search' && editingSearch) { + if (isSearchFavoriteShortcut(input, key)) { + toggleFavorite(favoriteTarget(screen, selectedStationForInput(), playingStation)); + return; + } + if (key.return) { const inputStation = selectedStationForInput(); - if (searchQuery.trim() && searchQuery.trim() === lastSubmittedSearchRef.current && inputStation) { - void playStation(inputStation); + if (inputStation && shouldTuneSearchResult(searchQuery, lastSubmittedSearchRef.current)) { + await playStation(inputStation); } else { - void runSearch(); + await runSearch(); } return; } @@ -458,7 +474,7 @@ export function useAppInput({ if (input.startsWith(':')) { const seed = input.slice(1).replace(/[\r\n]+$/g, ''); if (/[\r\n]/.test(input)) { - void executeCommand(seed); + await executeCommand(seed); } else { setCommandMode(true); setCommandText(seed); @@ -618,12 +634,12 @@ export function useAppInput({ } if (input === 'O') { - openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation)); + await openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation)); return; } if (input === 'y') { - void copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation)); + await copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation)); return; } @@ -674,7 +690,7 @@ export function useAppInput({ if (screen === 'countries') { const country = filteredCountries[selectedRef.current]; if (country) { - void loadCountry(country); + await loadCountry(country); } return; } @@ -723,9 +739,9 @@ export function useAppInput({ } else if (item === 'Automatically check for updates') { toggleSetting('automaticUpdateChecks'); } else if (item === 'Allow local agent control') { - void setAgentIntegrationEnabled(); + await setAgentIntegrationEnabled(); } else if (item === 'Install or repair MCP integrations') { - void repairMcpIntegrations(); + await repairMcpIntegrations(); } else if (item === 'Open TUI for agent playback') { toggleAgentSetting('openUiOnPlay'); } else if (item === 'Show Now Playing for agent playback') { @@ -737,7 +753,7 @@ export function useAppInput({ setCommandText('import '); setCommandMode(true); } else if (item === 'Check for updates') { - void updateFromSettings(); + await updateFromSettings(); } else if (item === 'Refresh provider health') { refreshProviderHealth(); setMessage('Provider health refreshed.'); @@ -761,16 +777,20 @@ export function useAppInput({ if (screen === 'map') { const country = filteredCountries[selectedRef.current]; if (country) { - void loadCountry(country); + await loadCountry(country); } return; } const inputStation = selectedStationForInput(); if (inputStation) { - void playStation(inputStation); + await playStation(inputStation); } } + }; + + useInput((input, key) => { + void handleInput(input, key).catch(reportInputError); }); } diff --git a/src/ui/use-command-executor.ts b/src/ui/use-command-executor.ts index f526c86..b801376 100644 --- a/src/ui/use-command-executor.ts +++ b/src/ui/use-command-executor.ts @@ -17,6 +17,7 @@ type UseCommandExecutorOptions = { loadCountry: (country: Country) => Promise; openAirPlaySettings: () => void; openLibrary: () => void; + persistLibrary: (write: () => LibraryState) => LibraryState | undefined; player: PlayerController; playingStation: Station | null; providers: ProviderManager; @@ -46,6 +47,7 @@ export function useCommandExecutor({ loadCountry, openAirPlaySettings, openLibrary, + persistLibrary, player, playingStation, providers, @@ -271,7 +273,7 @@ export function useCommandExecutor({ } if (name === 'stop') { - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); return; } @@ -311,6 +313,7 @@ export function useCommandExecutor({ loadCountry, openAirPlaySettings, openLibrary, + persistLibrary, player, playingStation, providers, diff --git a/src/ui/visual-baseline.test.tsx b/src/ui/visual-baseline.test.tsx new file mode 100644 index 0000000..1df32fe --- /dev/null +++ b/src/ui/visual-baseline.test.tsx @@ -0,0 +1,228 @@ +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {stripVTControlCharacters} from 'node:util'; +import type {ComponentProps} from 'react'; +import {Box, renderToString} from 'ink'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {receiverStyleNames, themeNames, type Alarm, type LibraryState, type Screen, type Station} from '../types.js'; +import {AppContent} from './AppContent.js'; +import {defaultExploreCursor} from './app-state.js'; +import {draftFromAlarm} from './alarm-editor.js'; +import {DisplayContext, resolveDisplayMode} from './display-context.js'; +import {computeTerminalLayout} from './layout.js'; +import {settingsGroups} from './screen-items.js'; +import {alarmPickerChoices} from './screens/AlarmsScreen.js'; + +type ContentProps = ComponentProps; +type BaselineCase = { + name: string; + screen: Screen; + props?: Partial; + alarm?: Partial; + receiverStyle?: LibraryState['settings']['receiverStyle']; +}; + +// Resolve Ink's own Chalk instance, including nested dependency installations. +// Its explicit color level makes these captures independent of the host TTY, +// CI, and FORCE_COLOR while retaining the actual renderer's style sequences. +const inkRequire = createRequire(import.meta.resolve('ink')); +const inkChalk = (await import(pathToFileURL(inkRequire.resolve('chalk')).href)).default as {level: number}; +const originalColorLevel = inkChalk.level; +const now = '2026-09-07T12:00:00.000Z'; +const station: Station = { + id: 'kexp', provider: 'radio-browser', name: 'KEXP 90.3 FM', + country: 'United States', countryCode: 'US', city: 'Seattle', + tags: ['indie'], codec: 'MP3', bitrate: 128, + latitude: 47.6062, longitude: -122.3321, + streamUrl: 'https://example.test/kexp' +}; +const otherStation: Station = { + ...station, id: 'kcrw', name: 'KCRW 89.9 FM — A deliberately long station name', + city: 'Santa Monica', tags: ['eclectic', 'public radio'], + latitude: 34.0195, longitude: -118.4912 +}; +const alarm: Alarm = { + id: 'morning', label: 'Morning radio', enabled: true, station, + schedule: {type: 'recurring', time: '06:30', weekdays: [1, 2, 3, 4, 5], timezone: 'America/Los_Angeles'}, + playback: {volume: 70, fadeSeconds: 30, stopAfterMinutes: 60, fallbackStation: otherStation}, + reliability: {missedRunGraceMinutes: 15, wakeIfSupported: true, keepAwakeUntilAlarm: true}, + createdAt: '2026-08-22T00:00:00.000Z', updatedAt: '2026-08-22T00:00:00.000Z' +}; +const library: LibraryState = { + favorites: [station], + recent: [{station: otherStation, playedAt: '2026-09-06T12:00:00.000Z'}], + imported: [otherStation], + activity: {sessions: [ + {id: 'listen-1', station, startedAt: '2026-09-06T12:00:00.000Z', endedAt: '2026-09-06T13:00:00.000Z', listenedSeconds: 3600}, + {id: 'listen-2', station: otherStation, startedAt: '2026-09-07T09:00:00.000Z', endedAt: '2026-09-07T09:30:00.000Z', listenedSeconds: 1800} + ]}, + trackHistory: [ + {title: 'Bjork - Joga', stationKey: 'radio-browser:kexp', stationName: station.name, at: '2026-09-07T11:58:00.000Z'}, + {title: 'Aphex Twin - Avril 14th', stationKey: 'radio-browser:kexp', stationName: station.name, at: '2026-09-07T11:54:00.000Z'} + ], + searchHistory: ['tokyo jazz'], + alarms: [alarm], + settings: { + theme: 'green', receiverStyle: 'pulse-grid', volume: 70, + enableRadioGarden: false, enableNearbyLocation: false, shareDirectoryVotes: true, + preferredBackend: 'auto', preferredAirPlayDevice: 'living-room', tuneTimeoutSeconds: 12, + skipBrokenStreams: true, mediaKeys: {previous: [], playPause: [], next: []}, + resumeOnLaunch: true, asciiMode: false, reduceMotion: false, + transparentBackground: false, automaticUpdateChecks: true + } +}; +const playback: ContentProps['playback'] = {backend: 'mpv', state: 'playing', volume: 70, muted: false, ready: true}; +const runtime: ContentProps['alarmTui']['runtime'] = { + capabilities: {supported: true, exactWake: false, catchUpAfterWake: true, message: 'ready'}, + degradedAlarmIds: new Set(), message: 'Native scheduler ready.' +}; +const verification: NonNullable = { + state: 'warning', alarmLabel: alarm.label, startedAt: now, finishedAt: now, + steps: [ + {id: 'scheduler', label: 'Native scheduler', state: 'passed', detail: 'Disposable job registered and removed.', critical: true}, + {id: 'power', label: 'Sleep protection', state: 'warning', detail: 'Closed-lid policy cannot be overridden.', critical: false} + ] +}; +const activeAlarms: ContentProps['alarmTui']['activeAlarms'] = [{ + key: 'morning-session', + status: {alarmId: alarm.id, scheduledAt: '2026-09-07T11:59:00.000Z', stationName: station.name, station, startedAt: '2026-09-07T11:59:00.000Z', state: 'playing'}, + dismiss: async () => undefined, snooze: async () => undefined, keepPlaying: async () => undefined +}]; + +// Keep this exhaustive: adding a route must also make its baseline intentional. +const screens: Record> = { + home: {}, 'now-playing': {}, library: {}, explore: {}, search: {}, countries: {}, + nearby: {}, map: {}, stations: {}, stats: {}, settings: {}, + 'airplay-settings': {}, 'airplay-code': {playback: {...playback, backend: 'airplay', state: 'loading', ready: false, message: 'AirPlay code required.'}}, + help: {}, alarms: {selected: 1}, 'alarm-editor': {}, 'alarm-picker': {}, 'alarm-ringing': {} +}; +const screenCases: BaselineCase[] = [ + ...Object.entries(screens).map(([screen, props]) => ({name: screen, screen: screen as Screen, props})), + ...settingsGroups.map(group => ({name: `settings/${group.id}`, screen: 'settings' as const, props: {settingsPage: group.id}})), + {name: 'now-playing/diagnostics', screen: 'now-playing', props: {showDiagnostics: true}}, + {name: 'now-playing/idle', screen: 'now-playing', props: {playingStation: null, nowPlaying: null, playback: {...playback, state: 'idle', ready: false}}}, + {name: 'search/loading', screen: 'search', props: {loadingStations: true, displayStations: []}}, + {name: 'library/empty', screen: 'library', props: {displayStations: [], library: {...library, favorites: [], imported: [], recent: []}}}, + {name: 'help/commands', screen: 'help', props: {selected: 40}}, + {name: 'alarms/empty', screen: 'alarms', props: {library: {...library, alarms: []}}}, + {name: 'alarms/degraded', screen: 'alarms', props: {selected: 1}, alarm: {runtime: {...runtime, degradedAlarmIds: new Set([alarm.id])}, deletingId: alarm.id}}, + {name: 'alarms/verification', screen: 'alarms', alarm: {verification}}, + {name: 'alarm-editor/time', screen: 'alarm-editor', alarm: {editorField: 'time', editorControl: 'time', timeSegment: 'minute'}}, + {name: 'alarm-editor/weekdays', screen: 'alarm-editor', alarm: {editorField: 'weekdays', editorControl: 'weekdays', weekdayIndex: 2}}, + {name: 'alarm-editor/volume', screen: 'alarm-editor', alarm: {editorField: 'volume', editorControl: 'number'}}, + {name: 'alarm-editor/save', screen: 'alarm-editor', alarm: {editorField: 'save'}}, + {name: 'alarm-picker/fallback', screen: 'alarm-picker', alarm: {pickerFallback: true, pickerChoices: alarmPickerChoices([alarm], [station], [otherStation], library.recent, station, true)}} +]; +const sizes = [ + {name: 'full', columns: 100, rows: 30}, + {name: 'compact', columns: 50, rows: 16}, + {name: 'micro', columns: 24, rows: 8} +] as const; +const profiles = [ + {name: 'unicode', ascii: false, noColor: false}, + {name: 'ascii', ascii: true, noColor: false}, + {name: 'unicode-no-color', ascii: false, noColor: true}, + {name: 'ascii-no-color', ascii: true, noColor: true} +] as const; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(now)); + vi.stubEnv('TZ', 'UTC'); +}); +afterEach(() => { + inkChalk.level = originalColorLevel; + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +for (const size of sizes) { + for (const profile of profiles) { + describe(`screen baseline / ${size.name} / ${profile.name}`, () => { + it.each(screenCases)('$name', baseline => capture(baseline, size, profile)); + }); + } +} + +describe('receiver baseline / full / unicode', () => { + it.each(receiverStyleNames)('%s', receiverStyle => capture({name: receiverStyle, screen: 'now-playing', receiverStyle}, sizes[0], profiles[0])); +}); + +const representativeReceivers = ['gallop-fine', 'pulse-grid', 'ultracode', 'braille-wave', 'manhattan'] as const; +for (const size of sizes) { + for (const profile of profiles.filter(profile => !profile.noColor && (size.name !== 'full' || profile.ascii))) { + describe(`receiver baseline / ${size.name} / ${profile.name}`, () => { + it.each(representativeReceivers)('%s', receiverStyle => capture({name: receiverStyle, screen: 'now-playing', receiverStyle}, size, profile)); + }); + } +} + +// Most baselines stay readable as terminal text. These focused ANSI captures +// retain the exact accents, heatmap levels, selection fills, and scene colors. +describe('palette baseline', () => { + const paletteCases = themeNames.flatMap(theme => ['home', 'stats'].map(screen => ({ + name: `${theme}/${screen}`, screen: screen as Screen, props: {theme} + }))); + it.each(paletteCases)('$name', baseline => capture(baseline, sizes[0], profiles[0], true)); + it.each([ + {name: 'receiver accent', screen: 'now-playing'}, + {name: 'alarm selection', screen: 'alarms', props: {selected: 1}}, + {name: 'settings selection', screen: 'settings', props: {settingsPage: 'appearance'}}, + {name: 'default receiver', screen: 'now-playing', receiverStyle: 'gallop-fine'}, + {name: 'scene', screen: 'now-playing', receiverStyle: 'manhattan'}, + {name: 'mono scene', screen: 'now-playing', receiverStyle: 'manhattan', props: {theme: 'mono'}} + ])('$name', baseline => capture(baseline, sizes[0], profiles[0], true)); +}); + +function capture(baseline: BaselineCase, size: (typeof sizes)[number], profile: (typeof profiles)[number], includeColor = false): void { + inkChalk.level = profile.noColor ? 0 : 3; + const layout = computeTerminalLayout(size.columns, size.rows); + const baselineLibrary = baseline.props?.library ?? library; + const renderLibrary: LibraryState = { + ...baselineLibrary, + settings: {...baselineLibrary.settings, theme: baseline.props?.theme ?? baselineLibrary.settings.theme, asciiMode: profile.ascii, receiverStyle: baseline.receiverStyle ?? baselineLibrary.settings.receiverStyle} + }; + const display = resolveDisplayMode(renderLibrary.settings, profile.noColor ? {NO_COLOR: '1'} : {}); + const props: ContentProps = { + airPlayDevices: [{id: 'living-room', name: 'Living Room', host: 'speaker.local', port: 7000, txt: [], requiresPassword: true, airplay2: true}], + airPlayCode: '1234', appVersion: '0.2.3', backends: ['mpv', 'airplay'], + countryFilter: '', diagnostics: {backend: 'mpv', availableBackends: ['mpv', 'airplay'], preferredBackend: 'auto', active: true, volume: 70, muted: false, ready: true, startedAt: '2026-09-07T11:30:00.000Z'}, + displayStations: [station, otherStation], editingCountryFilter: false, editingSearch: true, + exploreCursor: defaultExploreCursor, favoriteKeys: new Set(['radio-browser:kexp']), filterLabel: 'none', + filteredCountries: [{name: 'United States', code: 'US', stationCount: 12_345}, {name: 'Japan', code: 'JP', stationCount: 456}], + frameWidth: layout.frameWidth, layout, library: renderLibrary, loadingCountries: false, loadingStations: false, + nowPlaying: {title: 'Bjork - Joga', updatedAt: now}, playback, playingStation: station, + providerHealth: {'radio-browser': 'online', 'radio-garden': 'disabled'}, searchQuery: 'tokyo jazz', + screen: baseline.screen, settingsPage: 'root', selected: 0, showDiagnostics: false, + sleepLabel: 'Sleep off', stationContext: {title: baseline.screen === 'library' ? 'Library' : baseline.screen === 'nearby' ? 'Nearby' : baseline.screen === 'stations' ? 'United States' : 'Explore', subtitle: 'Saved and discovered stations', stations: [station, otherStation]}, + stationFavorite: true, stationTime: '12:00', storePath: '/fixture/radiocli.json', theme: 'green', + updateCheck: {checkedAt: now, currentVersion: '0.2.3', latestVersion: '0.3.0', updateAvailable: true}, + alarmTui: { + draft: draftFromAlarm(alarm), editorField: 'label', editingField: false, editorControl: null, + timeSegment: 'hour', weekdayIndex: 0, validationError: null, saving: false, + pickerChoices: alarmPickerChoices([alarm], [station], [otherStation], library.recent, station, false), + pickerFallback: false, runtime, verification: null, activeAlarms, activeSelected: 0, + snoozeMinutes: 10, busyAlarmIds: new Set(), itemCount: () => undefined, + handleInput: () => false, openForStation: () => undefined, openActive: () => undefined, + ...baseline.alarm + }, + ...baseline.props + }; + props.library = renderLibrary; + // Match App's content viewport. renderToString runs the real Ink renderer and + // tears down its Yoga tree and effects after capturing the initial frame. + const frame = renderToString( + + + + + , + {columns: layout.frameWidth} + ); + const lines = stripVTControlCharacters(frame).split('\n'); + expect(lines.length).toBeLessThanOrEqual(layout.contentRows); + expect(frame).not.toContain('Unknown screen.'); + if (profile.noColor) expect(frame).not.toContain('\u001B['); + const snapshot = includeColor ? frame.replaceAll('\u001B', '') : stripVTControlCharacters(frame); + expect(snapshot.split('\n').map(line => line.trimEnd()).join('\n')).toMatchSnapshot(); +} diff --git a/src/update-check.test.ts b/src/update-check.test.ts index ae66a5e..bee193d 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -13,6 +13,7 @@ import { describe('update checks', () => { afterEach(() => { vi.unstubAllEnvs(); + vi.useRealTimers(); }); it('compares semantic versions', () => { @@ -53,6 +54,33 @@ describe('update checks', () => { expect(updateCheck.error).toContain('503'); }); + it('does not make automatic or explicit registry requests while offline', async () => { + vi.stubEnv('CI', 'false'); + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetchImpl = vi.fn(async () => new Response('{"version":"99.0.0"}')); + expect(shouldCheckForUpdate(undefined)).toBe(false); + expect(automaticUpdateChecksAllowed(true)).toBe(false); + const result = await checkForUpdate({fetchImpl}); + expect(result.updateAvailable).toBe(false); + expect(result.error).toMatch(/offline/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('bounds the registry response body after headers arrive', async () => { + vi.useFakeTimers(); + let body!: ReadableStreamDefaultController; + const fetchImpl = async () => new Response(new ReadableStream({start(controller) {body = controller;}})); + let result: Awaited> | undefined; + const checking = checkForUpdate({fetchImpl, timeoutMs: 100}).then(value => {result = value;}); + try { + await vi.advanceTimersByTimeAsync(101); + expect(result?.error).toMatch(/timed out/i); + } finally { + body.error(new Error('test cleanup')); + await checking; + } + }); + it('uses a 24 hour cache and honors disable flags', () => { vi.stubEnv('CI', 'false'); diff --git a/src/update-check.ts b/src/update-check.ts index a141b64..1bb9a24 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -2,6 +2,7 @@ import {realpathSync} from 'node:fs'; import {spawn} from 'node:child_process'; import {appVersion} from './version.js'; import type {UpdateCheckState} from './types.js'; +import {networkPolicy, withExternalResponse} from './platform/network.js'; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const UPDATE_PACKAGE_NAME = '@ciphore/radiocli'; @@ -42,12 +43,10 @@ export async function checkForUpdate({ timeoutMs = 3000 }: CheckForUpdateOptions = {}): Promise { try { - const response = await fetchWithTimeout(registryLatestUrl(packageName), fetchImpl, timeoutMs); - if (!response.ok) { - throw new Error(`npm registry returned ${response.status}`); - } - - const parsed = await response.json() as {version?: unknown}; + const parsed = await withExternalResponse(registryLatestUrl(packageName), {fetchImpl, timeoutMs}, async response => { + if (!response.ok) throw new Error(`npm registry returned ${response.status}`); + return await response.json() as {version?: unknown}; + }); const latestVersion = typeof parsed.version === 'string' ? parsed.version : undefined; if (!latestVersion) { throw new Error('npm registry response did not include a version'); @@ -70,7 +69,7 @@ export async function checkForUpdate({ } export function shouldCheckForUpdate(updateCheck: UpdateCheckState | undefined, now = Date.now()): boolean { - if (process.env.RADIOCLI_DISABLE_UPDATE_CHECK === '1' || process.env.CI === 'true') { + if (!automaticUpdateChecksAllowed()) { return false; } @@ -83,7 +82,7 @@ export function shouldCheckForUpdate(updateCheck: UpdateCheckState | undefined, } export function automaticUpdateChecksAllowed(enabled = true): boolean { - return enabled && process.env.RADIOCLI_DISABLE_UPDATE_CHECK !== '1' && process.env.CI !== 'true'; + return enabled && !networkPolicy().offline && process.env.RADIOCLI_DISABLE_UPDATE_CHECK !== '1' && process.env.CI !== 'true'; } export function updateAvailableForVersion( @@ -179,16 +178,6 @@ function registryLatestUrl(packageName: string): string { return `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`; } -async function fetchWithTimeout(url: string, fetchImpl: FetchLike, timeoutMs: number): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetchImpl(url, {signal: controller.signal}); - } finally { - clearTimeout(timer); - } -} - function semverParts(version: string): [number, number, number] { const normalized = version.trim().replace(/^v/i, '').split(/[+-]/)[0] ?? ''; const [major = '0', minor = '0', patch = '0'] = normalized.split('.'); From d21af7981073f0d6a426eddab6a1108ef93ca913 Mon Sep 17 00:00:00 2001 From: Ciphore Date: Sat, 12 Sep 2026 01:54:45 -0700 Subject: [PATCH 25/25] Release 0.2.4 Co-authored-by: MrSeizy <228224+PonchoPig@users.noreply.github.com> --- .github/workflows/ci.yml | 236 +- CHANGELOG.md | 69 + CONTRIBUTING.md | 42 +- README.md | 50 +- apps/docs/DISCOVERY.md | 58 - apps/docs/content/docs/architecture.mdx | 104 +- apps/docs/content/docs/changelog.mdx | 65 + apps/docs/content/docs/contributing.mdx | 41 +- apps/docs/content/docs/design.mdx | 58 +- .../content/docs/getting-started/install.mdx | 137 +- .../docs/getting-started/tui-controls.mdx | 70 +- apps/docs/content/docs/meta.json | 5 +- apps/docs/content/docs/platforms.mdx | 217 + apps/docs/content/docs/privacy-security.mdx | 104 +- apps/docs/content/docs/release-packaging.mdx | 70 +- apps/docs/content/docs/reliability.mdx | 130 +- apps/docs/content/docs/roadmap.mdx | 36 - apps/docs/content/docs/troubleshooting.mdx | 201 + apps/docs/lib/seo.ts | 2 +- apps/docs/package-lock.json | 344 +- apps/docs/package.json | 4 +- package-lock.json | 4 +- package.json | 2 +- scripts/fresh-check.mjs | 91 +- scripts/install-smoke-mpv.mjs | 160 + scripts/install-smoke-mpv.test-node.mjs | 57 + scripts/packed-smoke.mjs | 305 + scripts/packed-smoke.test-node.mjs | 75 + src/agent/headless-host.test.ts | 234 + src/agent/headless-host.ts | 56 +- src/agent/launcher.test.ts | 45 + src/agent/launcher.ts | 71 +- src/agent/mcp-install.ts | 32 +- src/agent/service.ts | 4 +- src/agent/session.test.ts | 116 +- src/agent/session.ts | 25 +- src/alarms/active-session.test.ts | 69 + src/alarms/active-session.ts | 18 +- src/alarms/guard.test.ts | 158 +- src/alarms/guard.ts | 84 +- src/alarms/inhibitor.test.ts | 20 +- src/alarms/inhibitor.ts | 39 +- src/alarms/power-guard-store.ts | 11 +- src/alarms/runner.test.ts | 98 +- src/alarms/runner.ts | 42 +- src/alarms/schedule.test.ts | 19 +- src/alarms/schedule.ts | 9 +- src/alarms/scheduler.test.ts | 360 +- src/alarms/scheduler.ts | 184 +- src/alarms/setup-verification.test.ts | 1 + src/alarms/setup-verification.ts | 3 +- src/alarms/system-volume-native.test.ts | 105 + src/alarms/system-volume-ownership.test.ts | 100 + src/alarms/system-volume-ownership.ts | 129 + src/alarms/system-volume-process.test.ts | 102 + src/alarms/system-volume.test.ts | 170 +- src/alarms/system-volume.ts | 114 +- src/alarms/terminal-launcher.test.ts | 78 +- src/alarms/terminal-launcher.ts | 203 +- src/alarms/tui-presence.ts | 4 +- src/cli-terminal.test.tsx | 60 + src/cli.test.ts | 93 + src/cli.tsx | 102 +- src/platform/capabilities.test.ts | 52 + src/platform/capabilities.ts | 68 + src/platform/desktop.test.ts | 36 + src/platform/desktop.ts | 32 + .../executable-locations.test.ts} | 2 +- src/platform/executables.test.ts | 92 + .../command.ts => platform/executables.ts} | 46 +- src/platform/ipc.test.ts | 17 + src/platform/ipc.ts | 16 + src/platform/launch-command.test.ts | 164 + src/platform/launch-command.ts | 118 + src/platform/loopback.test.ts | 70 + src/platform/loopback.ts | 47 + src/platform/network.test.ts | 343 + src/platform/network.ts | 296 + src/platform/packages.test.ts | 126 + src/platform/packages.ts | 215 + src/platform/paths.test.ts | 45 + src/platform/paths.ts | 44 + src/platform/runtime.test.ts | 61 + src/platform/runtime.ts | 97 + src/platform/shell.test.ts | 43 + src/platform/shell.ts | 27 + src/platform/storage.test.ts | 92 + src/platform/storage.ts | 46 + src/platform/support.test.ts | 274 + src/platform/support.ts | 222 + src/platform/terminal.test.ts | 141 + src/platform/terminal.ts | 72 + src/platform/terminals.test.ts | 231 + src/platform/terminals.ts | 182 + src/player/airplay-discovery.test.ts | 18 +- src/player/airplay-discovery.ts | 6 +- src/player/backend-install.test.ts | 48 +- src/player/backend-install.ts | 133 +- src/player/command-diagnostics.ts | 4 +- src/player/mpv-ipc-client.test.ts | 20 + src/player/mpv-ipc-client.ts | 3 +- src/player/player-controller.test.ts | 130 +- src/player/player-controller.ts | 175 +- src/providers/cache.ts | 37 +- src/providers/radio-browser.test.ts | 157 + src/providers/radio-browser.ts | 226 +- src/providers/radio-garden.test.ts | 58 + src/providers/radio-garden.ts | 38 +- src/setup.test.ts | 243 +- src/setup.ts | 238 +- src/storage/store.test.ts | 243 +- src/storage/store.ts | 132 +- src/ui/AdaptiveContent.test.tsx | 36 +- src/ui/AdaptiveContent.tsx | 76 +- src/ui/App.tsx | 250 +- src/ui/AppContent.tsx | 6 +- .../visual-baseline.test.tsx.snap | 11325 ++++++++++++++++ src/ui/alarm-tui-integration.test.tsx | 430 +- src/ui/app-state.test.ts | 23 +- src/ui/app-state.ts | 12 + src/ui/ascii.test.ts | 2 + src/ui/ascii.ts | 10 +- src/ui/components/AdaptiveMarquee.tsx | 9 +- src/ui/components/Logo.tsx | 9 +- src/ui/components/Menu.tsx | 6 +- src/ui/components/ScreenHeader.tsx | 2 +- src/ui/components/StationList.tsx | 14 +- src/ui/components/TopTabs.tsx | 4 +- src/ui/display-context.test.ts | 28 +- src/ui/display-context.ts | 22 +- src/ui/help-content.ts | 4 +- src/ui/page-footer.test.ts | 16 +- src/ui/page-footer.ts | 6 +- src/ui/portable-app.test.tsx | 296 + src/ui/portable-render.test.tsx | 170 + src/ui/screens/AirPlayCodeScreen.tsx | 8 +- src/ui/screens/AirPlaySettingsScreen.tsx | 20 +- src/ui/screens/ExploreScreen.tsx | 2 + src/ui/screens/HelpScreen.tsx | 10 +- src/ui/screens/HomeScreen.tsx | 6 +- src/ui/screens/MapScreen.tsx | 2 +- src/ui/screens/NowPlayingScreen.tsx | 16 +- src/ui/screens/SearchScreen.tsx | 8 +- src/ui/screens/SettingsScreen.tsx | 21 +- src/ui/screens/StatsScreen.tsx | 12 +- src/ui/screens/screen-render.test.tsx | 52 +- src/ui/system-actions.test.ts | 120 +- src/ui/system-actions.ts | 137 +- src/ui/terminal-renderer.ts | 17 + src/ui/use-alarm-tui.ts | 44 +- src/ui/use-app-input.ts | 62 +- src/ui/use-command-executor.ts | 5 +- src/ui/visual-baseline.test.tsx | 228 + src/update-check.test.ts | 28 + src/update-check.ts | 25 +- 155 files changed, 22763 insertions(+), 1737 deletions(-) delete mode 100644 apps/docs/DISCOVERY.md create mode 100644 apps/docs/content/docs/platforms.mdx delete mode 100644 apps/docs/content/docs/roadmap.mdx create mode 100644 apps/docs/content/docs/troubleshooting.mdx create mode 100644 scripts/install-smoke-mpv.mjs create mode 100644 scripts/install-smoke-mpv.test-node.mjs create mode 100644 scripts/packed-smoke.mjs create mode 100644 scripts/packed-smoke.test-node.mjs create mode 100644 src/agent/headless-host.test.ts create mode 100644 src/agent/launcher.test.ts create mode 100644 src/alarms/system-volume-native.test.ts create mode 100644 src/alarms/system-volume-ownership.test.ts create mode 100644 src/alarms/system-volume-ownership.ts create mode 100644 src/alarms/system-volume-process.test.ts create mode 100644 src/cli-terminal.test.tsx create mode 100644 src/platform/capabilities.test.ts create mode 100644 src/platform/capabilities.ts create mode 100644 src/platform/desktop.test.ts create mode 100644 src/platform/desktop.ts rename src/{player/command.test.ts => platform/executable-locations.test.ts} (97%) create mode 100644 src/platform/executables.test.ts rename src/{player/command.ts => platform/executables.ts} (73%) create mode 100644 src/platform/ipc.test.ts create mode 100644 src/platform/ipc.ts create mode 100644 src/platform/launch-command.test.ts create mode 100644 src/platform/launch-command.ts create mode 100644 src/platform/loopback.test.ts create mode 100644 src/platform/loopback.ts create mode 100644 src/platform/network.test.ts create mode 100644 src/platform/network.ts create mode 100644 src/platform/packages.test.ts create mode 100644 src/platform/packages.ts create mode 100644 src/platform/paths.test.ts create mode 100644 src/platform/paths.ts create mode 100644 src/platform/runtime.test.ts create mode 100644 src/platform/runtime.ts create mode 100644 src/platform/shell.test.ts create mode 100644 src/platform/shell.ts create mode 100644 src/platform/storage.test.ts create mode 100644 src/platform/storage.ts create mode 100644 src/platform/support.test.ts create mode 100644 src/platform/support.ts create mode 100644 src/platform/terminal.test.ts create mode 100644 src/platform/terminal.ts create mode 100644 src/platform/terminals.test.ts create mode 100644 src/platform/terminals.ts create mode 100644 src/providers/radio-garden.test.ts create mode 100644 src/ui/__snapshots__/visual-baseline.test.tsx.snap create mode 100644 src/ui/portable-app.test.tsx create mode 100644 src/ui/portable-render.test.tsx create mode 100644 src/ui/terminal-renderer.ts create mode 100644 src/ui/visual-baseline.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22b7fc2..8be5c1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,13 @@ concurrency: on: pull_request: workflow_dispatch: + inputs: + experimental_bsd: + description: Run experimental packed-runtime checks in BSD guest VMs + type: boolean + default: false + schedule: + - cron: '37 7 * * 1' push: branches: - master @@ -18,6 +25,7 @@ on: jobs: verify: name: Verify + if: github.event_name != 'schedule' runs-on: ${{ matrix.os }} timeout-minutes: 25 strategy: @@ -25,16 +33,49 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] node-version: [22.x, 24.x] + # https://docs.github.com/en/actions/reference/runners/github-hosted-runners + # Keep the original six jobs, then add the missing CPU architectures. + include: + - os: ubuntu-latest + architecture: x64 + - os: macos-latest + architecture: arm64 + - os: windows-latest + architecture: x64 + - os: ubuntu-24.04-arm + node-version: 22.x + architecture: arm64 + - os: ubuntu-24.04-arm + node-version: 24.x + architecture: arm64 + - os: windows-11-arm + node-version: 22.x + architecture: arm64 + - os: windows-11-arm + node-version: 24.x + architecture: arm64 + - os: macos-15-intel + node-version: 22.x + architecture: x64 + - os: macos-15-intel + node-version: 24.x + architecture: x64 steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} + architecture: ${{ matrix.architecture }} cache: npm + - name: Verify native runtime identity + env: + RADIOCLI_EXPECTED_ARCH: ${{ matrix.architecture }} + run: node --input-type=module -e "import assert from 'node:assert/strict'; import os from 'node:os'; assert.equal(process.arch, process.env.RADIOCLI_EXPECTED_ARCH); console.log(JSON.stringify({method:'GitHub-hosted native OS/CPU VM',platform:process.platform,architecture:process.arch,kernel:os.release(),node:process.version}));" + - name: Install run: npm ci @@ -50,6 +91,9 @@ jobs: - name: Test run: npm run test + - name: Package smoke harness tests + run: node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs + - name: Build run: npm run build @@ -63,30 +107,58 @@ jobs: run: npm run check:package - name: Data smoke - if: github.event_name != 'pull_request' + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' run: npm run smoke:data + - name: Install native Linux playback fixture + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y mpv + + - name: Install pinned native macOS or Windows playback fixture + if: runner.os != 'Linux' + env: + RADIOCLI_SMOKE_EVIDENCE_DIR: packed-evidence + run: node scripts/install-smoke-mpv.mjs + - name: Fresh install smoke - if: github.event_name != 'pull_request' - run: npm run fresh:check + env: + RADIOCLI_EXPECTED_ARCH: ${{ matrix.architecture }} + RADIOCLI_SMOKE_EXECUTION_METHOD: GitHub-hosted native OS/CPU VM (${{ matrix.os }}) + RADIOCLI_SMOKE_EVIDENCE_DIR: packed-evidence + run: npm run fresh:check -- --require-mpv + + - name: Preserve packed runtime evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: packed-${{ matrix.os }}-${{ matrix.node-version }} + path: packed-evidence/*.json + if-no-files-found: warn playback-smoke: name: Playback Smoke - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' - timeout-minutes: 15 + runs-on: ${{ matrix.os }} + if: github.event_name != 'schedule' + timeout-minutes: 25 strategy: fail-fast: false matrix: + os: [ubuntu-latest, ubuntu-24.04-arm] node-version: [22.x, 24.x] + include: + - os: ubuntu-latest + architecture: x64 + - os: ubuntu-24.04-arm + architecture: arm64 steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} + architecture: ${{ matrix.architecture }} cache: npm - name: Install system playback backend @@ -96,10 +168,12 @@ jobs: run: npm ci - name: Playback smoke + # Packed normal/omit-optional installs for this OS/CPU/Node matrix run in verify. run: npm run smoke:playback docs: name: Docs + if: github.event_name != 'schedule' runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -108,10 +182,10 @@ jobs: node-version: [22.x, 24.x] steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} cache: npm @@ -131,3 +205,141 @@ jobs: - name: Build working-directory: apps/docs run: npm run build + + # Experimental jobs run in full guest kernels, not Linux containers. They are + # opt-in/weekly and do not establish maintained or audible-playback support. + # VM action source is pinned; upstream release assets/images are maintained + # separately by vmactions. The selected guest versions and actual runtimes are logged. + experimental-package: + name: Build experimental portability artifact + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.experimental_bsd) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup build Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.x + cache: npm + + - name: Install build dependencies + run: npm ci --omit=optional + + - name: Build the npm artifact for guest installation + run: | + mkdir -p portability-artifact + npm pack --pack-destination portability-artifact + cp scripts/packed-smoke.mjs portability-artifact/ + sha256sum portability-artifact/*.tgz + + - name: Preserve the built package and standalone harness + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: portability-package + path: portability-artifact/ + if-no-files-found: error + + experimental-freebsd: + name: Experimental FreeBSD 14.4 x64 guest / Node 22 + needs: experimental-package + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Download the Linux-built npm package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: portability-package + path: portability-artifact + + - name: Install and exercise JavaScript in FreeBSD + uses: vmactions/freebsd-vm@f0552d3b69211736abd97f02ff3d4674c56b73b1 # v1.5.5 + with: + release: '14.4' + arch: x86_64 + copyback: false + prepare: pkg install -y node22 npm-node22 mpv + run: | + set -eu + uname -a + node --version + npm --version + cd portability-artifact + set -- ./*.tgz + test "$#" -eq 1 + node packed-smoke.mjs "$1" --omit-optional --require-mpv --execution-method 'QEMU FreeBSD 14.4 x86_64 guest kernel; null audio' + + experimental-openbsd: + name: Experimental OpenBSD 7.9 x64 guest / packaged Node + needs: experimental-package + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Download the Linux-built npm package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: portability-package + path: portability-artifact + + - name: Install and exercise JavaScript in OpenBSD + uses: vmactions/openbsd-vm@86cdc08415d9d0865267e686561e276c52d62530 # v1.4.7 + with: + release: '7.9' + arch: x86_64 + copyback: false + prepare: pkg_add -I node mpv + run: | + set -eu + uname -a + node --version + npm --version + cd portability-artifact + set -- ./*.tgz + test "$#" -eq 1 + node packed-smoke.mjs "$1" --omit-optional --require-mpv --execution-method 'QEMU OpenBSD 7.9 x86_64 guest kernel; null audio' + + experimental-netbsd: + name: Experimental NetBSD 10.1 x64 guest / Node 22 + needs: experimental-package + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Download the Linux-built npm package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: portability-package + path: portability-artifact + + - name: Install and exercise JavaScript in NetBSD + uses: vmactions/netbsd-vm@7f23a491dcc69c34195721ab67934917707f84c7 # v1.4.9 + with: + release: '10.1' + arch: x86_64 + copyback: false + prepare: | + set -eu + export PATH=/usr/pkg/bin:/usr/pkg/sbin:$PATH + # The minimal guest omits X11 libraries required by pkgsrc mpv, + # even for null audio/video. Install the matching official OS set. + # Digest: NetBSD-10.1/amd64/binary/sets/SHA512 on cdn.NetBSD.org. + radiocli_xbase=$(mktemp /tmp/radiocli-xbase.XXXXXX) + trap 'rm -f "$radiocli_xbase"' EXIT + ftp -q 60 -o "$radiocli_xbase" https://cdn.NetBSD.org/pub/NetBSD/NetBSD-10.1/amd64/binary/sets/xbase.tar.xz + test "$(cksum -a SHA512 -q "$radiocli_xbase")" = b6ac2195f91d7ae395ec2734c9ae2a4727014629fbdc94562531225e0d51e3b2705047e8440fcf75e1dacdcecb3cb980df7a82f838c12ad5274ba8ce95e49b72 + tar -xpf "$radiocli_xbase" -C / + /usr/sbin/pkg_add pkgin + pkgin -y update + pkgin -y install 'nodejs>=22<23' mpv + mpv --version + run: | + set -eu + export PATH=/usr/pkg/bin:/usr/pkg/sbin:$PATH + uname -a + node --version + npm --version + cd portability-artifact + set -- ./*.tgz + test "$#" -eq 1 + node packed-smoke.mjs "$1" --omit-optional --require-mpv --execution-method 'QEMU NetBSD 10.1 x86_64 guest kernel; null audio' diff --git a/CHANGELOG.md b/CHANGELOG.md index 562329d..9f51b13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,74 @@ All notable changes to RadioCLI are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.4] - 2026-09-08 + +### Added + +- Added a shared platform layer for runtime eligibility, capabilities, executable + discovery, package installation, storage paths, networking, IPC, desktop + helpers, and terminal launching. +- Added first-class Node.js 22 and 24 CI coverage for macOS Apple Silicon and + Intel, Ubuntu x64 and arm64, Windows x64, and Windows 11 arm64, including + packed installation and native mpv IPC playback checks. +- Added repeatable packed CLI and mpv playback checks in full FreeBSD, OpenBSD, + and NetBSD guest kernels, plus explicit installation and capability guidance + for additional Unix, WSL, Termux, Haiku, illumos, and AIX environments. +- Added offline, low-bandwidth, screen-reader, reduced-motion, ASCII, limited- + color, and noninteractive terminal modes with deterministic full, compact, + and micro rendering coverage. + +### Changed + +- Centralized native package recipes and guided setup so dry runs and executed + commands use the same platform-specific definitions. +- Expanded `radiocli doctor --json` with host, runtime, network, storage, + terminal, playback, desktop, scheduling, and optional-feature capabilities. +- Reorganized installation, platform, architecture, reliability, privacy, and + contributor documentation as stable reference material with explicit support + boundaries and troubleshooting paths. + +### Fixed + +- Kept playback and the TUI usable when optional history, presence, cache, or + library writes fail, while preserving recoverable data after interrupted or + permission-denied writes. +- Hardened alarm deletion, native scheduler cleanup, terminal reopening, Alarm + Guard ownership, system-volume restoration, and forced-process teardown. +- Preserved exact arguments, data locations, and supported environment selectors + across macOS, Linux, BSD, and Windows child processes without forwarding + unrelated shell state. +- Improved player discovery, mpv readiness and IPC handling, provider timeouts, + mirror fallback, offline cache behavior, and degraded terminal rendering. +- Recovered ARM Linux playback from mpv audio-service initialization failures + by retrying once with ALSA first, retained bounded player diagnostics, and + stopped system-wide backend failures from skipping through every station. +- Replaced stale Radio Browser hosts with live DNS service discovery and added + a TLS-verified resolver fallback for temporary hostname failures without + bypassing configured proxies. +- Preserved full RGB theme colors when a terminal explicitly advertises + truecolor, even when its Node.js stream reports a conservative 256-color + depth; explicit 256-color, 16-color, and no-color modes remain unchanged. +- Kept retained Search results playable after clearing the submitted query and + added `Ctrl+F` to favorite the selected result while query input is active, + without intercepting an ordinary `f` typed into the search text. +- Dismissed added and removed favorite confirmations after 4.5 seconds on + Overview, Now Playing, Library, Explore, Search, Nearby, and country station + views. +- Marked AirPlay controls as macOS-only, hid the unsupported Settings shortcut, + and kept non-macOS users on their current screen with a direct platform notice. +- Corrected Windows terminal launches to create independent interactive console + handles and retain the intended user data location. + +### Security + +- Updated documentation-site dependencies to patched releases. +- Limited child-process environments to the settings required for RadioCLI data + identity and native integrations, excluding unrelated credentials and proxy + secrets. +- Made native cleanup and ownership checks fail closed when a job, helper, + process, or saved state cannot be identified safely. + ## [0.2.3] - 2026-09-07 ### Added @@ -397,6 +465,7 @@ Initial public release. [0.2.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.1 [0.2.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.2 [0.2.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.3 +[0.2.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.4 [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4 [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3 [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd2f4ed..809dc62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,19 +12,22 @@ npm run build npm run test ``` -Install `mpv` for playback controls, metadata polling, and the best playback -experience: +Install `mpv` for playback controls and metadata polling. On a macOS +configuration supported by Homebrew: ```bash brew install mpv ``` -On Linux, use your package manager's `mpv` package. On Windows, use: +On Linux, use your package manager's `mpv` package. On Windows x64, use: ```powershell winget install --id shinchiro.mpv -e ``` +Use the [platform installation routes](apps/docs/content/docs/platforms.mdx#installation-and-playback-prerequisites) +for Windows arm64 and other package managers. + ## Verification Run these before opening a PR: @@ -34,11 +37,19 @@ npm run check npm run lint npm run test npm run build +npm run pack:check +npm run fresh:check -- --require-mpv +npm run check:package +npm run smoke:mcp +npm run docs:check +npm run docs:build ``` -Run `npm run smoke:playback` when changing playback code. It opens a public -stream briefly, so do not run it in environments where audio/network access is -not acceptable. +Install documentation dependencies with `npm --prefix apps/docs ci` first. +Run `npm run smoke:playback` when changing playback code. Its default fixture is +a local WAV file; mpv uses null audio in deterministic package +checks. These checks exercise the player process and controls, and do not prove +speaker output. `npm run smoke:data` intentionally contacts public providers. Live provider checks are useful before releases, but ordinary pull requests should stay deterministic. Run `npm run verify:release` for a full maintainer @@ -52,9 +63,28 @@ checks. - Never make location lookup implicit. - Prefer clear terminal text over decorative filler. - Keep command-line and TUI behavior aligned. +- Put OS decisions in `src/platform`; keep capability availability separate from + runtime eligibility and recorded platform verification. - Add tests for parsing, storage, layout, or provider transformations when the behavior can be checked without live audio. +## Platform verification + +The required Node 22/24 CI matrix tests x64 and arm64 installations and real mpv +control. Weekly or manually triggered BSD checks execute the packed JavaScript +in guest kernels. Changes to script harnesses +also require `node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs`. + +Record OS release, CPU, endianness, libc, Node/npm versions, artifact hash, and +whether execution was native, virtualized, emulated, or mocked. An installation +test, native command mock, or foreign binary inspection alone cannot promote a +platform to supported. Follow the [support tiers](apps/docs/content/docs/platforms.mdx). + +The deterministic visual fixtures cover full, compact, and micro layouts, all +major screens, and receiver styles. Preserve Unicode captures. When rendering +changes intentionally, describe the visible effect in the pull request and do +not update snapshots merely to hide failures. + ## Issue Triage Playback issues should include station name, country, backend, and diff --git a/README.md b/README.md index ff42002..bd08be0 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Built with [Ink](https://github.com/vadimdemedes/ink), ## Quick start -macOS with Homebrew: +On macOS configurations supported by Homebrew: ```bash brew install ciphore/tap/radiocli @@ -44,11 +44,43 @@ radiocli RadioCLI requires Node.js 22 or newer. The Homebrew formula installs `mpv` and FFmpeg. After an npm install, `radiocli setup` detects the operating system and package manager, lets you choose `mpv`, FFmpeg, and VLC, installs the selected -native tools with branded progress feedback, and verifies playback readiness. +native tools with branded progress feedback, and checks that executables are +available. Play a station to verify the actual audio path. Intel or older macOS +installations should consult the platform matrix for alternative package routes. See the [installation guide](apps/docs/content/docs/getting-started/install.mdx) for Windows, Linux distributions, AirPlay prerequisites, and fallback players. +## Platform coverage + +RadioCLI separates core playback, storage, terminal rendering, desktop helpers, +and native alarm services. A missing scheduler or clipboard helper leaves the +other features available. `radiocli doctor --json` reports each capability, +terminal and network policy, and runtime eligibility separately. + +The [platform matrix](apps/docs/content/docs/platforms.mdx) distinguishes required +CI targets from verified installations, experimental community paths, and known +runtime blockers. Node 22 and 24 are the reference test lines. BSD package plans, +Termux, Haiku and illumos guidance do not imply that every OS/CPU combination has +been exercised. AIX playback and Haiku's packaged Node 20 remain explicit gaps. +Linux without a usable systemd user session can still run the player and TUI; +reliable background alarms require a verified native adapter. + +Invocation preferences do not change saved settings: + +```bash +RADIOCLI_ASCII=1 radiocli # ASCII decoration; station names preserved +NO_COLOR=1 radiocli # no color or background fills +RADIOCLI_SCREEN_READER=1 radiocli # readable controls; no visualizer animation +RADIOCLI_OFFLINE=1 radiocli # cached directories and saved stations +RADIOCLI_LOW_BANDWIDTH=1 radiocli # cached atlas; no automatic receiver scan +``` + +Offline mode disables directory, location, update, vote and receiver-discovery +requests. An external player still needs a connection to listen to a live stream. +For proxy, SSH, read-only storage and custom-player paths, see +[troubleshooting](apps/docs/content/docs/troubleshooting.mdx). + ## Visual tour These recordings come from the built TUI. Generate them locally with @@ -84,7 +116,7 @@ These recordings come from the built TUI. Generate them locally with | `space` or `F8` | Pause or resume with `mpv` | | `,` / `.` or `F7` / `F9` | Previous or next station | | `+` / `-` | Change volume | -| `f` | Save or remove a favorite | +| `f` (`Ctrl+F` while typing in Search) | Save or remove a favorite | | `?` | Open all shortcuts and commands | | `q` or `Ctrl+C` twice | Quit cleanly | @@ -214,6 +246,8 @@ complete data-flow description. - `src/ui` — screens, input, layout, and terminal rendering - `src/providers` — station directories, resolution, and caches - `src/player` — playback backends, metadata, and AirPlay +- `src/platform` — host identity, independent capabilities, native command plans, + paths, terminal policy, networking and runtime eligibility - `src/alarms` — schedules, native registration, Alarm Guard, and active controls - `src/storage` — local library persistence and migration - `apps/docs` — documentation website and manual @@ -231,9 +265,10 @@ npm run verify npm run dev ``` -`npm run verify` checks types, lint, tests, the production build, and package -contents. Playback and live-data smoke tests are available separately because -they contact public services or start a local player. +`npm run verify` checks types, lint, tests, the production build, and live provider +data. `npm run fresh:check -- --require-mpv` tests packed installs with and without +optional dependencies using a local WAV, real mpv IPC controls and MCP. See +[contribution checks](CONTRIBUTING.md) for documentation and packaging validation. ## Contributing @@ -244,9 +279,10 @@ include `radiocli check` output with playback reports. - [Getting started](apps/docs/content/docs/index.mdx) - [Installation](apps/docs/content/docs/getting-started/install.mdx) +- [Platform matrix](apps/docs/content/docs/platforms.mdx) +- [Troubleshooting](apps/docs/content/docs/troubleshooting.mdx) - [Controls](apps/docs/content/docs/getting-started/tui-controls.mdx) - [Architecture](apps/docs/content/docs/architecture.mdx) -- [Roadmap](apps/docs/content/docs/roadmap.mdx) - [Release packaging](apps/docs/content/docs/release-packaging.mdx) Run the documentation site locally with `npm run docs:dev`. diff --git a/apps/docs/DISCOVERY.md b/apps/docs/DISCOVERY.md deleted file mode 100644 index 50c66dc..0000000 --- a/apps/docs/DISCOVERY.md +++ /dev/null @@ -1,58 +0,0 @@ -# Search and AI discovery - -The RadioCLI website exposes the same public documentation to people, search -engines, and AI tools without creating crawler-only content. - -## Public discovery endpoints - -- `/robots.txt` allows public pages and links to the XML sitemap. -- `/sitemap.xml` lists the homepage and canonical documentation pages. -- `/llms.txt` is the concise AI-readable project and documentation index. -- `/llms-full.txt` contains the complete documentation in one text response. -- `/llms.mdx` is a Markdown version of the homepage summary. -- `/llms.mdx/docs//content.md` exposes each documentation page as Markdown. -- `/manifest.webmanifest` describes the documentation site as an installable web app. - -The HTML pages remain the canonical source. LLM routes are alternate -representations and are intentionally omitted from the XML sitemap. - -## Canonical production URL - -Set `NEXT_PUBLIC_SITE_URL` to the public production origin, including `https://`: - -```bash -NEXT_PUBLIC_SITE_URL=https://docs.example.org -``` - -All canonical links, sitemap entries, JSON-LD URLs, Open Graph URLs, and LLM -indexes derive from that value. Vercel deployments can fall back to -`VERCEL_PROJECT_PRODUCTION_URL` and previews to `VERCEL_URL`. Cloudflare Pages -can fall back to `CF_PAGES_URL`. Local builds use `http://localhost:3000`. - -Optional webmaster verification meta tags can be configured without changing -source files: - -```bash -GOOGLE_SITE_VERIFICATION=google-token -BING_SITE_VERIFICATION=bing-token -``` - -## After the first production deployment - -These steps require control of the public domain and therefore cannot be -automated from the repository alone: - -1. Verify the domain in Google Search Console and Bing Webmaster Tools, using - DNS verification or the optional deployment variables above. -2. Submit the canonical `/sitemap.xml` URL to both services. -3. Run the homepage and representative docs pages through Google's Rich - Results Test and the Schema.org validator. -4. Inspect canonical, robots, Open Graph, Twitter, and JSON-LD tags in rendered - HTML. -5. Add the production docs URL to the GitHub repository website field and to - the npm package `homepage` once the URL is permanent. -6. Monitor indexed-page counts and crawl errors after releases. - -Do not add fabricated reviews, ratings, prices, FAQs, or organization claims to -structured data. RadioCLI is represented as free, MIT-licensed open-source -software and source code. diff --git a/apps/docs/content/docs/architecture.mdx b/apps/docs/content/docs/architecture.mdx index 33ec211..3102723 100644 --- a/apps/docs/content/docs/architecture.mdx +++ b/apps/docs/content/docs/architecture.mdx @@ -3,8 +3,9 @@ title: Architecture description: Runtime shape, provider adapters, playback control, TUI state, and persistence. --- -RadioCLI is a terminal-first product built around four seams: TUI state, -provider adapters, playback control, and local persistence. +RadioCLI separates TUI state, provider adapters, playback control, and local +persistence. A shared platform layer describes the host and selects native +integrations for those components. ## Runtime Shape @@ -45,6 +46,26 @@ AirPlay stream before the alarm switches to local output. If that owner cannot be reached, alarm delivery continues and runtime health records the degraded takeover instead of suppressing the alarm. +## Platform Boundaries + +The modules in `src/platform` keep host detection separate from runtime +readiness and the project's support policy: + +| Modules | Responsibility | +| --- | --- | +| `runtime.ts`, `support.ts` | Describe the OS, CPU, libc, WSL, and Termux environment; assess runtime eligibility and verified support evidence separately | +| `capabilities.ts` | Report each feature as available, unavailable, or unverified from the evidence gathered in this session | +| `paths.ts`, `storage.ts` | Preserve library and runtime namespaces; inspect storage access without creating a write probe | +| `executables.ts`, `packages.ts`, `shell.ts` | Resolve commands and construct installation or launch arguments for the host | +| `desktop.ts`, `terminals.ts`, `terminal.ts` | Select optional desktop helpers and describe terminal capabilities | +| `network.ts`, `loopback.ts` | Bound public HTTP requests and keep authenticated local control independent of public-network proxy settings | + +Finding a player, scheduler command, or terminal executable does not establish +that its audio device, user service, or desktop session works. Native operations +check their results, and `radiocli doctor` reports the available evidence. BSD, +Termux, and other experimental targets do not inherit Linux alarm adapters or a +support claim from a successful platform mock. See [Platform support](./platforms.mdx). + ## Alarm Runtime Alarm definitions are portable library data, while delivery is rebuilt from @@ -67,6 +88,12 @@ uses an occurrence lock to suppress duplicate dispatch, records the result, and then installs the next occurrence. A four-worker bound keeps bulk synchronization from spawning unbounded native commands. +An early native dispatch releases its occurrence lock without marking the +occurrence complete, so the real due dispatch can still run. If removal of a +native job fails, the adapter accepts the result only when a query confirms that +specific job is absent. An ambiguous failure keeps the definition and repair +artifacts available. + Recurring schedules store `HH:mm`, ISO weekdays, and an IANA timezone. Civil time is resolved one minute at a time: a spring-forward gap advances to the first valid minute on that civil date, while a fall-back overlap fires only the @@ -84,9 +111,15 @@ start at the configured target volume. Alarm Guard is a second detached process, separate from native scheduling. It holds the platform idle-sleep inhibitor until just after the next occurrence and uses an authenticated loopback ownership challenge so stale PID files cannot -cause an unrelated process to be signaled. Guard state, scheduler health, -occurrence locks, and active-control discovery are operational machine state, -not portable library state. +cause an unrelated process to be signaled. Unreadable ownership records and +records for live processes that fail the challenge are retained for repair; the +unverified PID is not signaled. An inhibitor lease reports an unexpected helper +exit, allowing runtime health to show that sleep protection was lost. + +System-volume leases snapshot the prior mixer state. A partial change triggers +a rollback attempt, and a failed restore leaves the lease retryable. Guard +state, scheduler health, occurrence locks, and active-control discovery are +operational machine state, not portable library state. ## Provider Layer @@ -98,9 +131,23 @@ not portable library state. by edge protection. - Playlist imports are modeled as first-class `playlist` stations. -Radio Browser calls use mirror fallback and a durable cache. If a live request -fails and stale cached data exists, the provider returns stale data rather than -blanking the interface. +Radio Browser calls use the canonical endpoint first, discover current HTTPS +servers from the directory's DNS service record when fallback is needed, and +keep a durable cache. If a live request fails and stale cached data exists, the +provider returns stale data rather than blanking the interface. A cache write failure +does not discard a successful directory response. Request deadlines remain +active while response bodies are consumed, and unused bodies are canceled +where possible. + +`RADIOCLI_OFFLINE=1` blocks public requests made by RadioCLI's Node HTTP client. +Cached directory results and saved direct stream URLs remain usable. +`RADIOCLI_LOW_BANDWIDTH=1` makes the geotagged atlas cache-only; smaller directory +queries remain available. Both flags skip automatic AirPlay discovery. Offline +mode also blocks manual discovery; low-bandwidth mode allows an explicit +refresh. Authenticated local controls remain available. These policies do not +block the external player's network access when a user requests a direct +stream. Proxy configuration and the exact network boundaries are described in +[Reliability](./reliability.mdx#network-requests). Explore and Nearby share the same geospatial path: RadioCLI caches the full available Radio Browser geotagged station atlas, filters out invalid coordinates, @@ -113,7 +160,7 @@ so map exploration is not capped to globally popular stations. `PlayerController` owns playback process lifecycle. - Default local output: `mpv` -- Fallback backend: `ffplay` for playback only +- Playback-only fallbacks: `ffplay` and VLC - Experimental macOS output: AirPlay/RAOP through Bonjour discovery, FFmpeg, and RadioCLI's bundled sender bridge - `mpv` is controlled over JSON IPC for pause, mute, volume, readiness checks, @@ -130,11 +177,11 @@ other non-Radio-Browser stations use their exposed stream URL. Once a URL is resolved, `PlayerController` starts `mpv` with `--no-video`, `--force-window=no`, a local JSON IPC endpoint, a forced media title, and the -configured volume. That endpoint is a Unix socket on macOS/Linux and a named -pipe on native Windows. `ffplay` is available as a playback-only fallback, but it -does not provide a reliable IPC surface for pause, mute, volume, play/pause -media-key handling, metadata, or readiness checks. RadioCLI labels that mode as -limited instead of updating UI state optimistically. +configured volume. That endpoint is a named pipe on native Windows and a Unix +socket on Unix-like targets whose runtime supports it. `ffplay` and VLC are +playback-only fallbacks; they do not provide a reliable IPC surface for pause, +mute, volume, play/pause media-key handling, metadata, or readiness checks. +RadioCLI labels that mode as limited instead of updating UI state optimistically. On macOS, the optional AirPlay backend discovers RAOP receivers with Bonjour and streams through a child worker that feeds decoded PCM from `ffmpeg` into a @@ -215,11 +262,30 @@ The store is local JSON. It keeps: - alarm definitions, next snooze override, and recent run outcome - settings -Corrupt store/cache files are renamed with a `.bad-*` suffix before defaults are -used, so user data is not silently overwritten. - -Writes use a temp-file-and-rename flow so interrupted writes are less likely to -leave partial JSON behind. +The library distinguishes missing data, malformed JSON or schema data, and an +unreadable file. A missing library starts with defaults. Corrupt +library bytes must first be preserved under a `.bad-*` name; a failed backup +leaves the original file in place and reports an error. Permission and I/O +failures are never treated as corruption or as a missing current library that +allows fallback to an older legacy file. Provider caches use separate best-effort +recovery. + +Library writes take a lock, merge concurrent changes, write a private temporary +file beside the destination, and atomically rename it into place. POSIX file +permissions are set before replacement. A failed write preserves the previous +disk and in-memory state, and cleanup cannot mask the original error. Windows +uses the user directory's ACLs rather than POSIX modes. + +A readable library remains usable when its file or directory is read-only. The +TUI catches optional recent, listening, track, search, and update-history save +failures separately from playback and navigation. Stop, sleep-timer expiry, +alarm takeover, and quit still stop the player. Explicit settings and favorite +changes report a failed save without claiming success. Error messages suggest +a writable `RADIOCLI_HOME` without exposing the private path. + +The platform changes introduce no new library or backup schema. Existing +`RADIOCLI_HOME`, `RADIO_ATLAS_HOME`, and legacy Radio Atlas locations retain +their meaning, including the existing macOS runtime namespaces. Full library backup/restore includes alarm definitions. Native scheduler files, runtime health, Alarm Guard state, occurrence locks, and authenticated local diff --git a/apps/docs/content/docs/changelog.mdx b/apps/docs/content/docs/changelog.mdx index ab6e621..e1a8645 100644 --- a/apps/docs/content/docs/changelog.mdx +++ b/apps/docs/content/docs/changelog.mdx @@ -6,6 +6,71 @@ description: Release history for RadioCLI. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.2.4 - 2026-09-08 + +### Added + +- Added a shared platform layer for runtime eligibility, capabilities, executable + discovery, package installation, storage paths, networking, IPC, desktop + helpers, and terminal launching. +- Added first-class Node.js 22 and 24 CI coverage for macOS Apple Silicon and + Intel, Ubuntu x64 and arm64, Windows x64, and Windows 11 arm64, including + packed installation and native mpv IPC playback checks. +- Added repeatable packed CLI and mpv playback checks in full FreeBSD, OpenBSD, + and NetBSD guest kernels, plus explicit installation and capability guidance + for additional Unix, WSL, Termux, Haiku, illumos, and AIX environments. +- Added offline, low-bandwidth, screen-reader, reduced-motion, ASCII, limited- + color, and noninteractive terminal modes with deterministic full, compact, + and micro rendering coverage. + +### Changed + +- Centralized native package recipes and guided setup so dry runs and executed + commands use the same platform-specific definitions. +- Expanded `radiocli doctor --json` with host, runtime, network, storage, + terminal, playback, desktop, scheduling, and optional-feature capabilities. +- Reorganized installation, platform, architecture, reliability, privacy, and + contributor documentation as stable reference material with explicit support + boundaries and troubleshooting paths. + +### Fixed + +- Kept playback and the TUI usable when optional history, presence, cache, or + library writes fail, while preserving recoverable data after interrupted or + permission-denied writes. +- Hardened alarm deletion, native scheduler cleanup, terminal reopening, Alarm + Guard ownership, system-volume restoration, and forced-process teardown. +- Preserved exact arguments, data locations, and supported environment selectors + across macOS, Linux, BSD, and Windows child processes without forwarding + unrelated shell state. +- Improved player discovery, mpv readiness and IPC handling, provider timeouts, + mirror fallback, offline cache behavior, and degraded terminal rendering. +- Recovered ARM Linux playback from mpv audio-service initialization failures + by retrying once with ALSA first, retained bounded player diagnostics, and + stopped system-wide backend failures from skipping through every station. +- Replaced stale Radio Browser hosts with live DNS service discovery and added + a TLS-verified resolver fallback for temporary hostname failures without + bypassing configured proxies. +- Preserved full RGB theme colors when a terminal explicitly advertises + truecolor, even when its Node.js stream reports a conservative 256-color + depth; explicit 256-color, 16-color, and no-color modes remain unchanged. +- Kept retained Search results playable after clearing the submitted query and + added `Ctrl+F` to favorite the selected result while query input is active, + without intercepting an ordinary `f` typed into the search text. +- Matched Search favorite confirmations to the Library by dismissing added and + removed messages automatically after the same brief interval. +- Corrected Windows terminal launches to create independent interactive console + handles and retain the intended user data location. + +### Security + +- Updated documentation-site dependencies to patched releases. +- Limited child-process environments to the settings required for RadioCLI data + identity and native integrations, excluding unrelated credentials and proxy + secrets. +- Made native cleanup and ownership checks fail closed when a job, helper, + process, or saved state cannot be identified safely. + ## 0.2.3 - 2026-09-07 ### Added diff --git a/apps/docs/content/docs/contributing.mdx b/apps/docs/content/docs/contributing.mdx index b8f5d66..2c5e9f8 100644 --- a/apps/docs/content/docs/contributing.mdx +++ b/apps/docs/content/docs/contributing.mdx @@ -15,18 +15,22 @@ npm run build npm run test ``` -Install `mpv` for the best playback experience: +Install `mpv` for playback controls and metadata polling. On a macOS +configuration supported by Homebrew: ```bash brew install mpv ``` -On Linux, use your package manager's `mpv` package. On Windows, use: +On Linux, use your package manager's `mpv` package. On Windows x64, use: ```powershell winget install --id shinchiro.mpv -e ``` +Use the [platform installation routes](platforms.mdx#installation-and-playback-prerequisites) +for Windows arm64 and other package managers. + ## Verification Run these before opening a pull request: @@ -36,17 +40,42 @@ npm run check npm run lint npm run test npm run build +npm run smoke:mcp +npm run pack:check +npm run check:package +npm run fresh:check -- --require-mpv +npm run docs:check +npm run docs:build +node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs ``` -Run `npm run smoke:playback` when changing playback code. It opens a public -stream briefly, so do not run it in environments where audio or network access -is not acceptable. +Run `npm run smoke:playback` when changing playback code. By default it generates +a local silent WAV and exercises an installed player. The packed smoke uses +real mpv with null audio and verifies IPC controls. A requested external stream +is a separate live-network test. + +Run `npm ci` in `apps/docs` before the documentation checks. Test maintained +Node 22/24 lines separately, and keep dependency installation separate from +concurrent tests that use the same `node_modules` directory. Live provider checks are useful before releases, but ordinary pull requests -should stay deterministic. Run `npm run verify:release` for a full maintainer +should stay deterministic. Run `npm run smoke:data` explicitly for live directory +verification. Run `npm run verify:release` for a full maintainer pre-release pass that includes live data, playback, packaging, and fresh-install checks. +For platform changes, record OS/product version, kernel, CPU and endianness, +libc where relevant, exact Node/player versions, artifact hash, and whether +execution was native, a same-CPU guest, foreign-CPU emulation, or a mock. +Compilation, a lockfile entry, or a downloaded executable cannot establish +runtime support. Use the [platform matrix](platforms.mdx) tier requirements. + +Review visual changes against the fixed-size captures described in +[Design](design.mdx#visual-regression-captures). Explain intentional changes and +preserve rich-terminal fixtures when adding degraded rendering. Exercise +permission failures and missing optional tools without weakening core playback, +authentication, or cleanup behavior. + ## Development Principles - Keep public provider adapters isolated. diff --git a/apps/docs/content/docs/design.mdx b/apps/docs/content/docs/design.mdx index 37be53a..3e8ae7c 100644 --- a/apps/docs/content/docs/design.mdx +++ b/apps/docs/content/docs/design.mdx @@ -1,6 +1,6 @@ --- title: Design -description: Product rationale for Ink, mpv, provider adapters, location behavior, and the receiver UI. +description: Product rationale for the receiver UI, terminal adaptation, playback backends, and provider adapters. --- RadioCLI is meant to feel like a product, not a terminal proof of concept. @@ -17,10 +17,10 @@ Radio streams are messy: redirects, playlists, HLS, odd codecs, and flaky metadata are normal. `mpv` already handles that complexity well and exposes JSON IPC for controls and metadata. -`ffplay` remains as a playback fallback because it is often already installed -with FFmpeg, but it does not provide the control and state surface RadioCLI needs -for a polished interactive receiver. `mpv` is the intended backend for pause, -mute, volume, media keys, metadata, and readiness checks. +`ffplay` and VLC provide playback fallbacks when `mpv` is unavailable. Their +limited control and state reporting is reflected in the UI. `mpv` is the +intended backend for pause, mute, volume, media keys, metadata, and readiness +checks. ## Why Provider Adapters @@ -48,9 +48,10 @@ The UI borrows from old receiver and car head-unit displays: direct labels, compact hierarchy, visible status, and no fake copy. The app should be usable in a terminal session, not a landing page rendered in ANSI. -The app shell paints its own near-black background instead of relying on the -terminal profile color. That keeps the receiver, tabs, stats panels, and footer -visually cohesive across terminal themes. +On terminals with sufficient color support, the app shell paints a near-black +background that keeps the receiver, tabs, stats panels, and footer visually +cohesive. Transparent Background, limited-color terminals, and `NO_COLOR` +let the terminal keep its own background. The Now Playing screen supports multiple visualizer styles so the display can feel like a receiver without locking the product into one animation. The default @@ -81,6 +82,47 @@ and computes local distance inside the app. The list reports the radius covered by the returned stations so the user can tell whether a cursor position is dense, sparse, or limited by provider geotag coverage. +## Terminal Adaptation + +Full, compact, and micro layouts keep the current selection and keyboard actions +available as space shrinks. Compact and micro views shorten labels and adjust +the receiver and list areas. The selected station's full name remains available +to screen-reader output. + +Unicode and rich color are retained when the terminal supports them. Explicit +ASCII settings or a bare `C`/`POSIX` locale replace decorative glyphs; UTF-8 +locales and SSH sessions retain Unicode. ASCII mode preserves international +station text. Sixteen-color terminals drop the dark fills, and `NO_COLOR` +removes color entirely. + +Reduce Motion stops receiver animation, loading animation, and scrolling labels. +Screen-reader mode also omits receiver visualizers and mouse reporting while +keeping keyboard controls and text status. `TERM=dumb` produces static frames +with ASCII decoration and no color. These environment adaptations apply to the +current launch and leave saved appearance preferences intact. The exact +overrides are listed in [TUI Controls](getting-started/tui-controls.mdx). + +### Visual Regression Captures + +The deterministic Ink snapshot suite covers every screen and settings group, +all receiver styles at full size, representative receivers at smaller sizes, +Unicode and ASCII decoration, output without color, and focused ANSI color and +selection checks. The fixtures use these sizes: + +| Layout | Columns × rows | +| --- | --- | +| Full | 100 × 30 | +| Compact | 50 × 16 | +| Micro | 24 × 8 | + +Time, timezone, station data, and animation frames are fixed so failures show +real rendering differences. ASCII fixtures use ASCII only for decoration while +preserving international station names. The +[snapshot suite](https://github.com/Ciphore/RadioCLI/blob/master/src/ui/visual-baseline.test.tsx) +verifies renderer output; native fonts, physical displays, audio, and desktop +integration are outside its scope. See [Platforms](/docs/platforms) for native +feature coverage. + ## Navigation And Stats The horizontal tab rail is intentionally dense: it keeps the app feeling like a diff --git a/apps/docs/content/docs/getting-started/install.mdx b/apps/docs/content/docs/getting-started/install.mdx index 59c08fc..b24cda0 100644 --- a/apps/docs/content/docs/getting-started/install.mdx +++ b/apps/docs/content/docs/getting-started/install.mdx @@ -14,17 +14,20 @@ radiocli setup radiocli ``` -On macOS, Homebrew is the all-in-one option because the formula also installs -Node.js, `mpv`, and FFmpeg: +On macOS configurations supported by Homebrew, its formula also installs Node.js, +`mpv`, and FFmpeg: ```bash brew install ciphore/tap/radiocli radiocli ``` -Both paths install the same `radiocli` command. The npm package is the universal -option; its guided `radiocli setup` step installs selected system playback tools -through Homebrew, WinGet, Scoop, Chocolatey, or the detected Linux package manager. +Both paths install the same `radiocli` command. The npm package also has +experimental community installation paths described below. `radiocli setup` +installs selected system playback tools only after showing a plan and receiving +confirmation or `--yes`. npm lifecycle scripts never install system packages. +Consult the [platform matrix](../platforms.mdx) before selecting a runtime or +claiming that a particular OS/CPU combination has been verified. ## Upgrade An Existing Installation @@ -49,7 +52,8 @@ older registration is already stale, run `radiocli mcp status` followed by RadioCLI needs: -- Homebrew on macOS: installs RadioCLI, Node.js, `mpv`, and FFmpeg +- Homebrew on its supported macOS configurations: installs RadioCLI, Node.js, + `mpv`, and FFmpeg - npm, pnpm, or Bun on macOS, Linux, and Windows: Node.js 22 or newer - `mpv` for playback, pause, mute, volume, media keys, metadata, and readiness checks @@ -71,19 +75,22 @@ npx @ciphore/radiocli This is the fastest way to try the TUI. You still need `mpv`, `ffplay`, or VLC for audio — run `npx @ciphore/radiocli doctor` to check. -## Recommended: macOS With Homebrew +## macOS With Homebrew -Homebrew is the lowest-friction macOS path. The tap formula installs RadioCLI, -Node.js, `mpv`, and FFmpeg through Homebrew: +The tap formula installs RadioCLI, Node.js, `mpv`, and FFmpeg through Homebrew. +Check Homebrew's current OS/CPU policy; Intel and older macOS installations may +need a compatible Node distribution and MacPorts or another native player route. +See the [platform matrix](../platforms.mdx#installation-and-playback-prerequisites). ```bash brew install ciphore/tap/radiocli radiocli ``` -Run `radiocli doctor` any time you want to verify local playback readiness. +Run `radiocli doctor` to check executable discovery and launch diagnostics. +Tuning a station verifies the actual audio path. -## Universal npm Install +## npm Install The npm package is `@ciphore/radiocli`, and the installed executable is `radiocli`. npm installs RadioCLI and its JavaScript dependencies only; it does @@ -97,7 +104,7 @@ radiocli The setup receiver detects tools that are already installed, offers `mpv`, FFmpeg/`ffplay`, and VLC, shows every native command before it runs, and finishes -with playback verification. `mpv` is selected by default everywhere. FFmpeg is +by checking executable availability. `mpv` is selected by default everywhere. FFmpeg is also selected by default on macOS because it enables experimental AirPlay. ```text @@ -127,8 +134,9 @@ radiocli setup --mcp --headless-agent # opt out of external terminal windows radiocli setup --no-mcp # disable/remove MCP entries ``` -Agent playback opens the RadioCLI TUI in a terminal window by default on every -platform. On macOS, the first request from each agent host (for example, ChatGPT) +Agent playback requests the RadioCLI TUI in a supported installed graphical +terminal. Missing desktop/session support is reported explicitly; select +`--headless-agent` for command-only playback. On macOS, the first request from each agent host (for example, ChatGPT) also produces Apple's Automation consent dialog. RadioCLI cannot grant that permission on the host's behalf; approve it when prompted. If a user does not want external terminal windows or OS app-control prompts, choose headless mode @@ -137,7 +145,9 @@ during setup or under **Settings → Agent control & MCP**. RadioCLI uses an animated activity bar and elapsed time while each package manager runs. Package managers do not report a portable, reliable percentage, so the bar intentionally shows activity rather than inventing completion data. -In redirected output or CI, setup emits plain start/result log lines instead. +In redirected output, `TERM=dumb`, screen-reader or reduced-motion mode, setup +emits plain start/result log lines instead. ASCII mode and the terminal's color +depth also apply to setup. pnpm and Bun can install the same package for users who already use those package managers: @@ -159,8 +169,9 @@ brew install ffmpeg ``` RadioCLI discovers AirPlay/RAOP receivers with Bonjour and decodes streams with -FFmpeg. The sender bridge is bundled with RadioCLI, so users should not install -extra AirPlay packages manually. Passcode-protected receivers prompt in the TUI; +FFmpeg. The sender is an optional package dependency and must install and load +successfully; `--omit=optional` leaves it out. Sender diagnostics report an absent +or unusable bridge. Passcode-protected receivers prompt in the TUI; enter the code with `:airplay-code 1234` or the AirPlay Code screen. RadioCLI requires an explicit AirPlay receiver selection from Settings or `:airplay` before it tunes through AirPlay. Changing Audio output while a station is @@ -197,9 +208,16 @@ radiocli setup radiocli ``` +On Alpine use a musl-native Node package; a glibc binary is not interchangeable. +Alpine and other musl targets remain experimental until the selected CPU and +runtime have repeatable execution evidence. On OpenRC, runit, a container, or any +system without an accessible systemd user manager, the player, library and TUI +remain usable. Background alarm scheduling is unavailable; RadioCLI does not +substitute cron/at jobs with weaker occurrence or snooze guarantees. + ## Windows -Use Windows Terminal or PowerShell with Node.js 22 or newer and native `mpv`: +Use Windows Terminal or PowerShell with a native Node 22/24 runtime and `mpv`: ```powershell winget install --id OpenJS.NodeJS.LTS -e @@ -222,8 +240,80 @@ scoop bucket add extras scoop install mpv ``` -WSL remains supported through the Linux install path, but native Windows is the -recommended Windows Terminal path. +Windows x86 has a Node 22 route; Node 24 no longer provides it. Windows ARM64 +needs matching native binaries: the WinGet mpv recipe above is x64-only, while +the Node LTS recipe and Scoop's mpv package have ARM64 routes. These catalogs do not establish +runtime or audio verification on their own. Windows XP and older Windows +versions below the standard runtime minimum have no legacy client. + +WSL uses the Linux installation path and remains experimental. Its Linux audio +path, user manager and graphical environment need separate verification; +Windows package discovery does not provide Linux playback tools. + +## BSD And Additional Runtimes + +Install a native Node 22+ runtime and matching npm first, then install the same +npm package. These are package instructions, with verification status in the +[platform matrix](../platforms.mdx). No new background alarm adapter is provided +for these systems. + +| Environment | Explicit prerequisite path | Limitations | +| --- | --- | --- | +| FreeBSD | As root: `pkg install -y node22 npm-node22 mpv` | Confirm packages for the OS/CPU; ffplay needs FFmpeg's SDL option. | +| OpenBSD | `doas pkg_add -I node mpv` | Packaged Node must be 22+; use the release's own repository. | +| NetBSD/pkgsrc | Install the matching X11 base set, then `pkgin -y install 'nodejs>=22<23' mpv` | The binary mpv package links to OS X11 libraries even for headless audio. Configure the correct pkgsrc repository; run as root or through a privilege helper. | +| Android/Termux | `pkg install -y nodejs-lts npm mpv` | Run as the app user without sudo/root; Android audio/background limits apply. | +| Haiku | `pkgman install -y mpv` | HaikuPorts' Node 20 is below the minimum; a working custom Node 22+ port is required before RadioCLI can run. | +| illumos/SmartOS | `pkgin -y install 'nodejs>=22<23' mpv` | Requires a configured compatible pkgsrc catalog; IPS `pkg` is a different tool and is not used. | +| AIX | Provision a compatible native Node 22/24 runtime manually | No verified mpv/ffplay package recipe or RadioCLI runtime/audio evidence. DNF availability alone is insufficient. | + +Setup detects FreeBSD `pkg`, OpenBSD `pkg_add`, pkgsrc `pkgin`, Haiku `pkgman`, +and a distinct `termux-pkg` recipe that invokes Termux's `pkg`. For example: + +```bash +radiocli setup --only=mpv --dry-run +radiocli setup --package-manager=termux-pkg --only=mpv --dry-run +``` + +Do not select the FreeBSD `pkg` recipe on Termux or illumos. Setup checks the host +before invoking these recipes and uses root, sudo or doas where applicable. +Termux's main FFmpeg package omits ffplay; enabling an X11 repository and its +display/audio services remains a manual choice. Haiku's `ffmpeg8_tools` recipe +does not resolve its Node runtime blocker. pkgsrc uses versioned FFmpeg/ffplay +pairs, so setup reports that component as manual rather than guessing a package. +On a minimal NetBSD installation, select the matching X11 base distribution set +through the OS installation tools before installing mpv. See NetBSD's +[distribution-set instructions](https://www.netbsd.org/docs/guide/en/chap-exinst.html); +the package manager cannot supply missing base-system X11 libraries itself. + +The recorded BSD guests installed a local packed tarball into fresh projects, +omitting the optional native macOS AirPlay sender. After providing the matching +Node/npm/player prerequisites, a manual registry installation uses: + +```bash +npm install -g --omit=optional @ciphore/radiocli +``` + +## Player Paths And Constrained Sessions + +Set an absolute executable path when the player is outside the standard search +locations. The value is a single executable, with no shell or appended arguments: + +```bash +RADIOCLI_MPV_PATH='/opt/Audio tools/mpv' radiocli doctor +``` + +PowerShell uses `$env:RADIOCLI_MPV_PATH = 'C:\Audio tools\mpv.exe'` before the +command. The other overrides are `RADIOCLI_FFPLAY_PATH`, `RADIOCLI_VLC_PATH` and +`RADIOCLI_FFMPEG_PATH`. Invalid explicit paths produce diagnostics instead of +silently choosing another executable. Run `radiocli alarm sync` after changing +the paths retained by native alarm jobs. + +For SSH, use a terminal allocation such as `ssh -t` for the TUI, or use CLI/MCP +commands and explicit headless playback. Browser, clipboard and terminal +reopening depend on the actual desktop session. See +[troubleshooting](../troubleshooting.mdx) for ASCII/color controls, offline caches, +proxies, IPv6 and writable data directories. ## Optional ffplay Fallback @@ -248,8 +338,9 @@ control. In `ffplay` mode RadioCLI labels the backend as `ffplay fallback`, shows limited-control footer text, and `radiocli doctor` reports `controls=limited`. -CI covers command-mode typecheck, tests, builds, package checks, and fresh -install smoke checks on Ubuntu, macOS, and Windows. +The required CI matrix covers Node 22/24 on Ubuntu, macOS and Windows with native +x64/arm64 jobs and packed-install mpv checks. BSD guest jobs are opt-in/weekly. +The platform matrix records actual results and the limits of those checks. ## Alarm Scheduling Requirements @@ -260,7 +351,7 @@ RadioCLI on supported hosts: - Native Windows uses Task Scheduler interactive-token tasks. - Linux requires a working systemd user manager and `systemctl --user`. -Linux systems without systemd user scheduling are reported as unsupported; WSL +Background alarms are unavailable without systemd user scheduling on Linux; WSL must provide a working user manager and an interactive audio path to use alarms. All platforms require a logged-in audio session and local speakers when the job runs. The terminal itself may close after an alarm is saved and registered. diff --git a/apps/docs/content/docs/getting-started/tui-controls.mdx b/apps/docs/content/docs/getting-started/tui-controls.mdx index 03f6dc5..b726f46 100644 --- a/apps/docs/content/docs/getting-started/tui-controls.mdx +++ b/apps/docs/content/docs/getting-started/tui-controls.mdx @@ -1,6 +1,6 @@ --- title: TUI Controls -description: Navigation, playback, settings, command palette, and learned media keys. +description: Navigation, playback, terminal rendering, accessibility, and learned media keys. --- RadioCLI keeps shortcuts at the bottom of the terminal. When playback is active, @@ -17,7 +17,8 @@ volume or mute state, and an active sleep timer. | `t` | Cycle display color | | `v` | Cycle receiver style | | `+` / `-` | Adjust volume | -| `f` | Favorite the selected or playing station | +| `f` | Favorite the selected or playing station outside active text input | +| `Ctrl+F` | Favorite the selected Search result while its query input is active | | `a` | Schedule the selected or playing station | | `!` | Open controls for any alarm already playing in the background | | `O` | Open the station homepage in your browser | @@ -26,7 +27,7 @@ volume or mute state, and an active sleep timer. | `q` or `Ctrl+C` twice | Quit cleanly | Pause, mute, volume, and play/pause media-key control require the `mpv` backend. -When only `ffplay` is active, RadioCLI keeps playback and station skipping +When `ffplay` or VLC is active, RadioCLI keeps playback and station skipping available but labels interactive playback controls as limited and shows an `Install mpv` warning instead of pretending the control worked. @@ -35,7 +36,7 @@ available but labels interactive playback controls as limited and shows an | Screen | Controls | | --- | --- | | Overview | `Up` / `Down` move, `Enter` open, number jump, `:` command | -| Search input | type query, `Up` / `Down` move results, `Ctrl+Up` / `Ctrl+Down` recall search history, `Enter` search or tune, `Esc` finish | +| Search input | type query, `Up` / `Down` move results, `Ctrl+Up` / `Ctrl+Down` recall search history, `Enter` search or tune, `Ctrl+F` favorite, `Esc` finish | | Search results | `/` edit query, `Up` / `Down` or `n` / `p` move, `Enter` tune, `f` favorite, `b` home | | Explore | click map, `WASD` fine move, `Shift+WASD` jump, `Up` / `Down` station, `Enter` tune, `f` favorite, `[` / `]` page, `b` home | | Countries | `/` filter, `Up` / `Down` move, `Enter` open stations, `w` map, `b` home | @@ -56,7 +57,8 @@ Explore keeps an aspect-correct Braille world map in full, compact, and micro layouts. Compact windows always place stations beside the map, while true micro places the selected station below it. Long selected station and metadata lines scroll after a short pause in compact and micro layouts. Enable Reduce Motion to -keep those lines truncated instead. +keep those lines truncated instead. ASCII mode substitutes plain characters for +the map's decorative glyphs. Press `?` from any screen to open Help, a full reference of keybindings and `:` commands. In the command palette, `Tab` completes the command name. @@ -82,7 +84,7 @@ The editor covers: - independent alarm volume, fade-in seconds, automatic stop time, optional fallback station, and fixed local-speaker output - missed-run grace, best-effort OS wake request, and Alarm Guard -- a next-occurrence review, a no-state-change test tune, Save, and Cancel +- a next-occurrence preview, a no-state-change test tune, Save, and Cancel Time is a segmented digital control whose active hours or minutes are highlighted in the current display color. Use `Up` and `Down` to change hours, press `Enter` @@ -175,17 +177,63 @@ Useful command entries: ## Display And Compatibility Modes Settings includes toggles for terminals and devices that need them, each also -available as a command: +available as a command. These choices are saved in your local library: - **Resume last station on launch** (`:resume on`) re-tunes your most recent station when RadioCLI starts, like a radio powering back on. - **Transparent background** (`:background on`) drops the dark panel fill so - light-themed terminals keep their own background. RadioCLI also honors the - [`NO_COLOR`](https://no-color.org) convention automatically. + light-themed terminals keep their own background. - **ASCII-safe display** (`:ascii on`) maps braille, block, and box-drawing - glyphs to ASCII for terminals and fonts without those glyphs. + glyphs, borders, and decorative punctuation to ASCII. International station + names and metadata remain intact; this is not a transliteration setting. - **Reduce motion** (`:motion on`) freezes the receiver and loading animations - to save CPU and bandwidth over SSH and on low-power devices. + and keeps long lines truncated instead of scrolling. + +RadioCLI retains its Unicode receiver display on terminals that support it, +including UTF-8 sessions over SSH. Locale detection checks the first non-empty +value in `LC_ALL`, `LC_CTYPE`, then `LANG`. A bare `C` or `POSIX` locale selects +ASCII decoration; UTF-8 locales such as `C.UTF-8` do not. SSH alone does not +change the display mode. + +Environment variables override rendering for that launch without rewriting +saved appearance settings: + +| Variable | Effect | +| --- | --- | +| `RADIOCLI_ASCII=1` | Force ASCII decoration | +| `RADIOCLI_UNICODE=1` | Force Unicode decoration unless ASCII is also explicitly enabled or `TERM=dumb` | +| `NO_COLOR=1` | Remove foreground colors and background fills; any non-empty value enables this | +| `FORCE_COLOR=0`, `1`, `2`, or `3` | Select no color, 16 colors, 256 colors, or truecolor | +| `RADIOCLI_DISABLE_ANIMATION=1` | Freeze receiver and loading animations and stop scrolling text | +| `RADIOCLI_SCREEN_READER=1` or `INK_SCREEN_READER=true` | Enable screen-reader output with text status, no receiver visualizers, no animation, and no mouse reporting | +| `TERM=dumb` | Show static frames with ASCII decoration and no color or animation | + +Detected 16-color terminals retain their own background; 256-color and truecolor +terminals can use the dark receiver panels. `NO_COLOR`, screen-reader mode, and +`TERM=dumb` take precedence over `FORCE_COLOR`. + +For one launch in a POSIX shell: + +```bash +RADIOCLI_ASCII=1 RADIOCLI_DISABLE_ANIMATION=1 radiocli +``` + +In PowerShell, set the variables for the current shell before launching: + +```powershell +$env:RADIOCLI_SCREEN_READER = '1' +radiocli +``` + +Screen-reader mode keeps keyboard navigation, station and playback labels, +selection, and command entry available. Decorative receiver frames are omitted, +and long labels are exposed as text without a scrolling marquee. + +The TUI requires interactive keyboard input, including with `TERM=dumb`. +Use [CLI commands](cli.mdx) such as `radiocli doctor --json` and +`radiocli search "jazz"` in pipelines or scripts. For display problems, see +[Troubleshooting](../troubleshooting.mdx); runtime and optional feature coverage +is listed under [Platforms](/docs/platforms). ## Source-List Queues diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 6ba0170..3a79433 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -3,15 +3,16 @@ "pages": [ "index", "getting-started", + "platforms", "alarms", "demo", "architecture", "design", "reliability", + "troubleshooting", "privacy-security", "contributing", "release-packaging", - "changelog", - "roadmap" + "changelog" ] } diff --git a/apps/docs/content/docs/platforms.mdx b/apps/docs/content/docs/platforms.mdx new file mode 100644 index 0000000..0662f87 --- /dev/null +++ b/apps/docs/content/docs/platforms.mdx @@ -0,0 +1,217 @@ +--- +title: Platforms +description: Runtime requirements, installation routes, native feature limits, and the evidence behind RadioCLI's platform tiers. +--- + +RadioCLI requires Node.js 22 or newer. Local audio needs a working player. +The reference matrix below covers Node.js 22 and 24. Runtime eligibility, +installed tools, and verified RadioCLI behavior are tracked separately for each +OS version, CPU, libc, and Node release line. + +Run `radiocli doctor --json` to inspect the capabilities of the current machine. +Use `radiocli setup --dry-run` to preview the native playback packages and +commands for the detected operating system. + +## Support Tiers + +| Tier | Required evidence | +| --- | --- | +| First-class | A repeatable installation with verified prerequisites, packed runtime and playback checks on the target, and maintained required CI whose failures block releases | +| Supported | A verified, repeatable runtime, packed installation, and playback path without required CI coverage | +| Experimental | A code path or package route exists, but project verification is incomplete | +| Unsupported | A known requirement blocks the configuration, such as Node.js below 22 or an incompatible OS/CPU runtime | + +A package recipe, a mocked platform test, or a downloaded binary does not +establish a verified installation. A Linux container exercises its host kernel; +a full BSD guest exercises a BSD kernel. Foreign-CPU emulation is recorded as +emulation. Each result applies to the configuration and artifact actually tested. + +The upstream Node rating is a separate axis: repeatable project evidence can +establish a supported configuration on a community runtime. Known runtime +requirements still apply. + +Doctor reports `support.scope=current-installation`. It checks runtime +requirements and available tools without running packed-install or audio +acceptance tests, and does not import a reference machine's CI evidence. Its +`support.tier` therefore remains experimental for an unverified local installation, +even when the reference configuration below is supported or first-class. A known +runtime blocker is reported as unsupported. Individual capability results explain +what can be used in the current session. + +## Tested Configurations + +The required CI matrix covers macOS, Ubuntu, and Windows on Node.js 22 and 24. +BSD package checks run in full guest kernels. The table describes exactly what +those checks exercise; it does not imply support for unlisted desktop, audio, +terminal, or hardware combinations. + +| Configuration | Automated coverage | Project tier | +| --- | --- | --- | +| macOS arm64; Node 22 and 24 | Full tests plus packed installation with normal and omitted optional dependencies; native mpv decoding and IPC controls through null audio output | First-class | +| macOS Intel x64; Node 22 and 24 | Full tests plus both packed-install and native mpv playback modes | First-class | +| Ubuntu glibc x64 and arm64; Node 22 and 24 | Full tests, both packed-install modes, native mpv IPC playback, and separate source playback checks | First-class | +| Windows Server x64; Node 22 and 24 | Full tests, both packed-install modes, native mpv named-pipe playback, PowerShell argument handling, and console TTY checks | First-class for the tested server environment; Windows desktop behavior depends on the local terminal and audio setup | +| Windows 11 arm64; Node 22 and 24 | Full tests, both packed-install modes, native arm64 mpv named-pipe playback, PowerShell argument handling, and console TTY checks | First-class for the tested Windows 11 environment | +| FreeBSD 14.4 and OpenBSD 7.9 x64; Node 22 | Packed CLI, cache, import/export, MCP, and native mpv IPC playback with `--omit=optional` in full QEMU guests | Supported for the core package route; desktop applications and background scheduling are not covered | +| NetBSD 10.1 x64; Node 22 | The same core guest checks after installation of the matching X11 base libraries | Supported for the core package route; desktop applications and background scheduling are not covered | +| Alpine/musl, WSL, other Linux CPUs, other BSD CPUs, Termux, illumos, AIX | Runtime and package detection paths are available, but complete installation and playback checks are not part of the maintained matrix | Experimental | +| Standard HaikuPorts Node 20 installation; Node below 22; Windows XP; 32-bit macOS; Windows x86 with Node 24 | The normal runtime route does not meet RadioCLI's requirement. A Haiku player package does not resolve its Node version blocker. | Unsupported for these configurations | + +The native matrix contains 12 required OS, CPU, and Node combinations: macOS +arm64 and Intel, Ubuntu x64 and arm64, and Windows x64 and arm64, each on Node 22 +and 24. A configured job counts as first-class only while its installation, +test, packaging, and playback checks remain required release gates. + +The mpv checks verify decoding and IPC through null audio output. Physical audio +devices, terminal applications, screen readers, desktop window opening, and +actual wake delivery depend on the user's hardware and operating-system policy. +The [visual regression suite](design.mdx#visual-regression-captures) verifies +terminal output at fixed sizes. + +## Node.js Runtime Reference + +The version numbers here are upstream binary or build baselines. Node.js also +excludes OS releases whose vendor support has ended, even if they meet a listed +minimum. A newer Node major may meet RadioCLI's package minimum while remaining +outside this Node 22/24 reference matrix. The primary references are the +[Node 22 platform list](https://github.com/nodejs/node/blob/v22.x/BUILDING.md#platform-list) +and [Node 24 platform list](https://github.com/nodejs/node/blob/v24.x/BUILDING.md#platform-list). + +| System and CPU | OS / libc baseline | Node 22 upstream status | Node 24 upstream status | +| --- | --- | --- | --- | +| macOS x64 / arm64 | macOS 11 for Node 22; macOS 13.5 for Node 24 | Tier 1 | Tier 1 | +| Windows x64 | Windows 10 / Server 2016 or newer | Tier 1 | Tier 1 | +| Windows arm64 | Windows 10 or newer | Tier 2 | Tier 2 | +| Windows x86 | Windows 10 or newer; upstream tests use WoW64 | Binaries provided; running is Tier 1, native compilation experimental | No Windows x86 runtime | +| glibc Linux x64 / arm64 | Kernel 4.18+, glibc 2.28+ | Tier 1 | Tier 1 | +| glibc Linux ARMv7 | Kernel 4.18+, glibc 2.28+ | Tier 1 | Experimental; community build needed | +| glibc Linux ppc64le / s390x | Kernel 4.18+, glibc 2.28+; POWER8+ for ppc64le | Tier 2 | Tier 2 | +| glibc Linux x86 | Kernel 3.10+, glibc 2.17+ | Experimental | Experimental | +| glibc Linux riscv64 | Node 24 reference: kernel 5.19+, glibc 2.36+ | Not listed; community route needs verification | Experimental | +| Linux big-endian ppc64 | A compatible community Node 22+ port must be established | No standard upstream route listed | No standard upstream route listed | +| musl Linux x64, including Alpine | Kernel 3.10+, musl 1.1.19+; use a musl build | Experimental | Experimental | +| musl Linux arm64 / s390x; ARMv7 on Node 22 | Distribution-specific build and runtime libraries | Distribution route; verify the selected package | Distribution route for arm64 / s390x; ARMv7 unverified | +| WSL | A compatible Linux runtime inside the selected WSL distribution | WSL-specific issues are outside upstream native Linux support | Same limitation | + +The glibc release binaries also need a compatible libstdc++; the reference +symbol version is `GLIBCXX_3.4.25`, or `GLIBCXX_3.4.28` for Node 22 ARMv7. +Linux ppc64le is little-endian; it is distinct from both Linux big-endian ppc64 +and the big-endian POWER runtime used by AIX. A generic `arm` label alone does +not establish ARMv7 compatibility. + +Node 24 publishes x64-musl archives while retaining an experimental musl +rating. Its current release files do not include ARMv7 binaries. Use the +[Node 24 release manifest](https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt) +and [distribution image architecture records](https://github.com/nodejs/docker-node/blob/main/versions.json) +to check the available build; a glibc Linux archive is not a musl or Android +Bionic archive. Distribution images do not establish RadioCLI audio or desktop +verification. + +### Additional OS Runtime Routes + +| System | OS / CPU reference | Node route and limit | +| --- | --- | --- | +| FreeBSD | Upstream experimental baseline: FreeBSD 13.2+ x64; verified guest: 14.4 x64 | Node 22/24 ports exist. Other CPUs use port-specific routes; an architecture allowlist does not establish a binary package for every release. | +| OpenBSD | Release and CPU must provide Node 22+; verified guest: 7.9 x64 | The current Node port is 24.x and lists amd64, aarch64, i386, powerpc64, and riscv64. Release binary repositories can supply an older Node; the recorded 7.9 guest supplied Node 22.23.2. | +| NetBSD | Release-specific pkgsrc packages; selected guest: 10.1 x64 | Node 22/24 pkgsrc recipes exist. The guest supplied Node 22.23.0. Other CPUs need separate package and runtime verification. | +| Android / Termux | Android 7+ with Termux's Bionic packages | `nodejs-lts` currently provides Node 24. Recipe targets include aarch64, arm, i686, and x86_64; device execution is unverified. Android outside Termux has no established RadioCLI install route. | +| SmartOS | Upstream reference: SmartOS 18+ x64 | Node 22/24 list Tier 2; use a suitable maintained distribution or pkgsrc build. | +| Other illumos distributions | x64 package routes depend on the distribution | Generic SunOS kernel 5.11 does not identify SmartOS or prove its version requirements. Verify the selected Node port and backend. | +| AIX | AIX 7.2 TL04+, big-endian POWER8+ | Node 22/24 list Tier 2; Node 24 also needs libstdc++12. A native player installation route remains unverified. | +| Haiku | A custom working Node 22+ port would be required | The inspected HaikuPorts catalog provides Node 20.15.1, below RadioCLI's minimum. The normal package route is blocked. | + +These routes come from the [FreeBSD Node ports](https://github.com/freebsd/freebsd-ports/blob/main/www/node22/Makefile), +[OpenBSD Node port](https://github.com/openbsd/ports/blob/master/lang/node/Makefile), +[pkgsrc Node 22](https://github.com/NetBSD/pkgsrc/blob/trunk/lang/nodejs22/Makefile) +and [Node 24 recipes](https://github.com/NetBSD/pkgsrc/blob/trunk/lang/nodejs24/Makefile), +[Termux installation requirements](https://github.com/termux/termux-app#installation), +[Termux Node LTS recipe](https://github.com/termux/termux-packages/blob/master/packages/nodejs-lts/build.sh), +and [HaikuPorts Node catalog](https://github.com/haikuports/haikuports/tree/master/net-libs/nodejs). +They describe available routes, not completed RadioCLI tests. + +## Installation And Playback Prerequisites + +After installing a compatible Node runtime and npm, the standard package command +is `npm install -g @ciphore/radiocli`. `mpv` provides IPC playback controls; +`ffplay` and VLC are limited-control fallbacks. Custom player installations can +use the [absolute path overrides](troubleshooting.mdx#playback-tool-installed-but-not-found). + +The recipes below were checked against package sources. Only the recorded +configurations in the evidence table have completed RadioCLI verification. +System package commands need the host's appropriate administrator access; +Termux `pkg` and Haiku `pkgman` run without `sudo`. + +| System | Playback / runtime route | Packaging limits and primary source | +| --- | --- | --- | +| macOS | Compatible Node plus npm; Homebrew `mpv`, or `sudo port install mpv` with MacPorts | Homebrew's OS/CPU policy is narrower than Node's. [MacPorts mpv](https://ports.macports.org/port/mpv/) is a separate route. | +| Windows | x64: `winget install --id shinchiro.mpv -e`; arm64: Scoop's `mpv`; x86: a matching native i686 build | The checked [WinGet mpv manifest](https://github.com/microsoft/winget-pkgs/blob/master/manifests/s/shinchiro/mpv/0.41.0/shinchiro.mpv.installer.yaml) is x64-only. [Scoop](https://github.com/ScoopInstaller/Extras/blob/master/bucket/mpv.json) lists x64 and arm64; [mpv release assets](https://github.com/mpv-player/mpv/releases/tag/v0.41.0) also include i686. | +| glibc Linux | `mpv` from the configured distribution catalog; setup selects apt, dnf, pacman, or zypper | Verify the actual Node version and media repository. A distribution's `nodejs` package can still be below 22. See [mpv's package references](https://mpv.io/installation/). | +| Alpine / musl | `apk add nodejs npm mpv`, with a compatible repository | The inspected [Alpine 3.24 x64 Node package](https://pkgs.alpinelinux.org/package/v3.24/main/x86_64/nodejs) is 24.x. Other releases/CPUs need separate checks. | +| FreeBSD | `pkg install -y node22 npm-node22 mpv`, or a matching Node 24/npm pair | Node ports omit npm, so install the matching npm package. [FFmpeg's SDL option](https://github.com/freebsd/freebsd-ports/blob/main/multimedia/ffmpeg/Makefile) controls whether `ffplay` is present. | +| OpenBSD | `doas pkg_add -I node mpv` | Check that the selected release supplies Node 22+. The [`pkg_add` manual](https://man.openbsd.org/pkg_add) defines `-I` as noninteractive. | +| NetBSD / pkgsrc | Install the matching NetBSD X11 base set, then `pkgin -y install 'nodejs>=22<23' mpv`, when that line exists in the repository | The binary mpv package links to system X11 libraries even for null output; a minimal OS image can lack them. Binary Node packages are named `nodejs`, with npm included. Use an available Node 24 range if needed. [pkgin](https://pkgin.net/) manages the catalog; [pkgsrc ffplay](https://github.com/NetBSD/pkgsrc/blob/trunk/multimedia/ffplay7/Makefile) uses versioned packages and executables such as `ffplay7`. | +| SmartOS / illumos | Bootstrap the appropriate pkgsrc environment, then use its `pkgin` Node/mpv packages | RadioCLI's setup recipe uses existing `pkgin`; it does not treat IPS `pkg` as FreeBSD `pkg`. See [SmartOS packages](https://docs.smartos.org/working-with-packages/) and [illumos binary packages](https://pkgsrc.smartos.org/). | +| Termux | `pkg install -y nodejs-lts npm mpv` as the normal app user | Setup identifies this as `termux-pkg`. Its [main FFmpeg recipe](https://github.com/termux/termux-packages/blob/master/packages/ffmpeg/build.sh) omits `ffplay`; the [separate ffplay package](https://github.com/termux/termux-packages/blob/master/x11-packages/ffplay/build.sh) needs `x11-repo` and its display/audio environment. | +| Haiku | `pkgman install -y mpv`; `ffmpeg8_tools` provides FFmpeg/ffplay | These [player recipes](https://github.com/haikuports/haikuports/tree/master/media-video) do not resolve the Node 20 runtime blocker. No working normal RadioCLI install is claimed. | +| AIX | Provision a compatible Node runtime and native player manually | No verified `mpv` or `ffplay` package recipe was established. The [IBM Toolbox catalog](https://www.ibm.com/support/pages/aix-toolbox-open-source-software-downloads-alpha) is not evidence that a `dnf install mpv` command exists. | + +Homebrew applies its own [support-tier policy](https://docs.brew.sh/Support-Tiers) +to Intel macOS. That policy is independent of Node's x64 runtime eligibility. +Likewise, a Windows x64 media package does not establish native ARM64 or x86 +playback; use an executable built for the target architecture. + +The optional AirPlay sender has native installation dependencies. A package +installed with `--omit=optional` retains the normal local-player path but omits +that sender. Installs with and without optional dependencies are separate +acceptance checks; neither establishes an AirPlay receiver connection. +The recorded BSD guests installed the packed tarball with `--omit=optional` +in fresh temporary projects and executed the generated npm bin link. For a +manual registry installation, use `npm install -g --omit=optional @ciphore/radiocli`; +that global registry route and normal optional-dependency installation were +not the recorded guest checks. + +## Playback And Desktop Compatibility + +These tables describe implemented adapters and their prerequisites. Tool +discovery is checked separately from successful execution. Shared TUI code +supports Unicode, ASCII decoration, limited color, screen-reader output, and +reduced motion; it still needs interactive keyboard input. See +[TUI Controls](getting-started/tui-controls.mdx) for rendering options. + +| Host | Local playback / TUI | Browser and clipboard | Terminal reopening | +| --- | --- | --- | --- | +| macOS | mpv Unix-socket IPC; ffplay/VLC fallback; shared TUI | `open` and `pbcopy` in a local desktop session | Supported installed macOS terminal; Apple Terminal/iTerm2 may require Automation consent | +| Windows | mpv named-pipe IPC; ffplay/VLC fallback; shared TUI | `explorer` and `clip` in a local desktop session | Windows Terminal or a PowerShell console | +| Linux, glibc or musl | Shared player/TUI paths; actual backend and audio service required | `xdg-open`; `wl-copy`, `xclip`, or `xsel`, with an appropriate desktop session | An installed supported GUI terminal and display session | +| WSL | Linux player/TUI paths require separate WSL verification | Linux session helpers; Windows desktop access is not inferred | Linux terminal path; depends on the guest's display/session | +| FreeBSD / OpenBSD / NetBSD | Shared player/TUI paths; exact guest results above do not establish a desktop terminal application | `xdg-open` and the available Wayland/X11 clipboard helper | Unix terminal adapter; installed terminal and display required, launch unverified | +| Termux | Bionic runtime and Termux mpv path; device/TUI/audio execution unverified | `termux-open-url`; `termux-clipboard-set` needs Termux:API | No separate terminal-reopening adapter | +| illumos / AIX / custom Haiku or other ports | Core execution requires a separately verified Node/player pair | No automatic browser or clipboard adapter | No terminal-reopening adapter | + +The Termux helper contracts are defined by +[`termux-open-url`](https://github.com/termux/termux-tools/blob/master/scripts/termux-open-url.in) +and [`termux-clipboard-set`](https://github.com/termux/termux-api-package/blob/master/scripts/termux-clipboard-set.in). +Clipboard access also requires the helper package and a compatible, correctly +signed [Termux:API app](https://github.com/termux/termux-api#installation); +Android permission and execution checks still apply. + +## Alarm, Power, And Output Paths + +| Host | Background alarms | System volume | Sleep inhibition and wake | AirPlay | +| --- | --- | --- | --- | --- | +| macOS | Per-user launchd LaunchAgent | macOS output-volume scripting | `caffeinate` for idle sleep; no privileged exact-wake event | Experimental; FFmpeg, Bonjour, usable sender, and explicit receiver selection required | +| Windows | Task Scheduler with interactive user token | Core Audio through PowerShell | `SetThreadExecutionState`; `WakeToRun` can be requested, subject to hardware and policy | No adapter | +| Linux, including musl | Accessible systemd user manager and timer required | Working `wpctl`, `pactl`, or `amixer` | Live `systemd-inhibit` lease; no privileged `WakeSystem` request | No adapter | +| WSL | Linux systemd user path only when its session is usable; unverified | Linux mixer path, not Windows host volume | Linux inhibitor path; Windows wake behavior is not inferred | No adapter | +| FreeBSD / OpenBSD / NetBSD | No background scheduler adapter | No system-volume adapter | No sleep-inhibitor or wake adapter | No adapter | +| Termux / illumos / AIX / Haiku / other ports | No background scheduler adapter | No system-volume adapter | No sleep-inhibitor or wake adapter | No adapter | + +Finding `systemctl` does not prove a working systemd user manager. Finding a +mixer or inhibitor command does not prove it can control this session. Player +volume remains separate from system output volume, and local playback can remain +usable when optional desktop or scheduling features are unavailable. + +Use `radiocli alarm doctor` and **Verify alarm setup** for the current machine. +The latter performs native actions and bounded sample playback. The +[Alarms guide](alarms.mdx), [Reliability](reliability.mdx), and +[Troubleshooting](troubleshooting.mdx) describe repair paths and delivery limits. diff --git a/apps/docs/content/docs/privacy-security.mdx b/apps/docs/content/docs/privacy-security.mdx index 83c064c..707892c 100644 --- a/apps/docs/content/docs/privacy-security.mdx +++ b/apps/docs/content/docs/privacy-security.mdx @@ -8,11 +8,17 @@ RadioCLI does not require secrets or accounts. ## Local Agent Control Agent control is opt-in. Its MCP transport is stdio, and active playback uses a -short-lived authenticated HTTP endpoint bound only to `127.0.0.1`. The random -bearer token and port are stored in a user-private runtime file. Requests are -serialized, stale discovery records are removed, and a single-owner lock keeps -two local agents from starting competing players. The endpoint is not exposed -to the LAN or internet. +short-lived authenticated HTTP endpoint bound to literal loopback: `127.0.0.1`, +with `::1` as the fallback when IPv4 loopback is unavailable. The random bearer +token, address, and port are stored in a user-private runtime file. Requests +are serialized, and a single-owner lock keeps two local agents from starting +competing players. The endpoint is not exposed to the LAN or internet. + +Agent, alarm, and Guard clients accept only those literal loopback addresses. +They make direct Node HTTP requests with `agent: false`, so public HTTP proxy +settings cannot forward local control or bearer tokens to a proxy. Loopback +requests still require authentication; their local address does not replace +the token check. MCP and `radiocli agent` can play only stations saved by RadioCLI or opaque IDs returned by its search and browse operations. The agent interface does not @@ -28,7 +34,7 @@ external terminal windows or app-control prompts. ## Privacy Notes - Nearby location is enabled by default for new libraries, but no location - request is made until you open Nearby. + request is made until you open Nearby; offline mode blocks that request. - Location lookup uses approximate IP-based location from `ipapi.co`. Disable it with `l`, Settings, or `:location off`. - Explore uses only the cursor coordinate you move in the terminal. @@ -37,10 +43,42 @@ external terminal windows or app-control prompts. - RadioCLI does not proxy audio. It resolves public stream URLs and hands playback to `mpv`, `ffplay`, or the experimental macOS AirPlay worker. - AirPlay discovery uses Bonjour on your local network. AirPlay streaming runs - through a local worker and the bundled sender bridge. + through a local worker and the bundled sender bridge. Offline mode blocks + receiver discovery. Low-bandwidth mode skips automatic discovery but permits + an explicit refresh. - Favoriting a Radio Browser station sends a best-effort vote to that public directory by default. Disable **Share favorite votes with Radio Browser** in - Settings to keep favorites local-only. + Settings to keep favorites local-only. Offline mode also prevents the vote + request. + +## Public Network Controls + +`RADIOCLI_OFFLINE=1` blocks public HTTP requests made by RadioCLI's Node client. +It can still read cached directory data and hand a saved direct URL to the +native player when the user asks to tune. The player's stream connection is +outside this directory-request policy; offline mode is not an OS firewall or +an audio-download feature. + +`RADIOCLI_LOW_BANDWIDTH=1` avoids downloading the geotagged station atlas and +uses a cached copy when available. Smaller searches, location lookups, health +checks, and manual controls remain available, so it is not a privacy substitute +for offline mode or disabling location and directory voting. + +Both flags suppress automatic AirPlay discovery, and offline mode also blocks +manual receiver discovery before starting Bonjour. Existing authenticated +loopback controls and the stdio MCP transport continue to work offline. + +Public HTTP(S) proxies require a compatible Node release and +`NODE_USE_ENV_PROXY=1` at process startup. `NO_PROXY` and the proxy URLs are +handled by Node; RadioCLI does not install a global dispatcher or change TLS +verification. Invalid proxy configuration errors identify the setting without +echoing credentials. SOCKS and `ALL_PROXY` alone are unsupported. See +[Reliability](./reliability.mdx#network-requests) for version requirements and +the distinction between public requests and local control. + +Homepage actions validate HTTP(S) URLs before opening or displaying them. +Stream-copy fallbacks validate media targets and sanitize terminal text, so a +rejected URL is not echoed into the terminal. ## Alarm Data And Local Control @@ -55,28 +93,56 @@ The following state is deliberately machine-local and excluded from backups: - scheduler and power-inhibitor health records - Alarm Guard process/lease state and occurrence locks - active-alarm discovery files and their random bearer tokens +- shared system-volume snapshots and participating process IDs + +System-volume ownership lives in `.radiocli/system-volume` under the OS account's +home directory. It is shared across `RADIOCLI_HOME` and XDG profiles so separate +alarm runners cannot restore the same output device independently. The saved +snapshot is removed after successful final restoration or playback handoff. After restoring a backup on another machine, run `radiocli alarm sync` to create that machine's native jobs. Alarm playback resolves the configured public station and connects to its stream just like normal playback; RadioCLI does not upload the alarm or proxy its audio. -While an alarm is playing, the detached runner opens an HTTP control endpoint on -`127.0.0.1` only. Discovery files and 32-byte random bearer tokens use private -machine-local storage permissions where the OS supports POSIX modes. Dismiss, -snooze, keep-playing, and Guard ownership requests must authenticate; there is no -network-facing control server. The detached runner may open the locally saved -terminal emulator to expose those controls. It does not pass the bearer token on -the command line; the TUI discovers it through the private machine-local file. +While an alarm is playing, the detached runner opens its own authenticated +loopback endpoint using the same IPv4-to-IPv6 fallback. New control files store +32-byte random bearer tokens with mode `0600`; new directories request mode +`0700` where the filesystem enforces POSIX modes. Windows relies on private +user-directory ACLs; POSIX mode arguments do not configure those ACLs. Dismiss, +snooze, keep-playing, and Guard ownership requests must authenticate. The detached +runner may open the locally saved terminal emulator to expose those controls. +It does not pass the bearer token on the command line; the TUI discovers it +through the private machine-local file. + +A Guard PID is insufficient evidence of ownership. If its authenticated +challenge fails while the process is still alive, or its ownership record is +unreadable, RadioCLI retains the record for repair and does not signal that PID. +An ambiguous native-job removal likewise retains the definition and artifacts +instead of hiding a possibly active registration. + +## Local Data Integrity + +Library access failures are reported without exposing the private storage path. +Permission and I/O errors do not rename intact data as corrupt or reset the +library. A corrupt library is reset only after its original bytes have been +preserved in a backup. Failed atomic writes leave the previous saved state +intact; readable read-only libraries remain available to the TUI, with warnings +for optional history that could not be saved. + +The platform changes preserve the library and backup schemas, existing +`RADIOCLI_HOME` and `RADIO_ATLAS_HOME` overrides, legacy Radio Atlas data paths, +and existing runtime namespaces. See [Architecture](./architecture.mdx#persistence) +for the separation between portable library data and machine-local records. ## Location Lookup Nearby stations are useful, but IP-based location lookup is privacy-sensitive. For new libraries the feature is on, and opening Nearby requests an approximate -location from `ipapi.co`. RadioCLI uses the returned city, region, country, -latitude, and longitude to sort the local geotagged station atlas. Turn it off -before opening Nearby with Settings or `:location off` if you do not want that -request; the choice is stored locally. +location from `ipapi.co` unless offline mode is active. RadioCLI uses the +returned city, region, country, latitude, and longitude to sort the local +geotagged station atlas. Turn it off before opening Nearby with Settings or +`:location off` if you do not want that request; the choice is stored locally. ## Reporting Security Issues diff --git a/apps/docs/content/docs/release-packaging.mdx b/apps/docs/content/docs/release-packaging.mdx index 655868f..cb0c2d4 100644 --- a/apps/docs/content/docs/release-packaging.mdx +++ b/apps/docs/content/docs/release-packaging.mdx @@ -14,9 +14,10 @@ packages from lifecycle scripts. The explicit, user-invoked `radiocli setup` command may install selected native tools after displaying and confirming its plan. Native playback and interactive controls come from `mpv`, with `ffplay` from FFmpeg as an optional playback-only fallback. -Linux users install `mpv` through their distro package manager. Windows users -install Node.js and `mpv` through `winget` or Scoop, then install RadioCLI -through npm. +Install a compatible Node runtime and a native player before using local audio. +The [platform matrix](platforms.mdx) records the verified OS/CPU combinations, +runtime minimums, package routes, and limitations. npm installation alone does +not establish native playback or desktop integration. ## NPM Release @@ -31,7 +32,7 @@ npm pack --dry-run Test the packed tarball locally: ```bash -VERSION=0.2.3 +VERSION=0.2.4 npm pack npm install -g "./ciphore-radiocli-$VERSION.tgz" radiocli --help @@ -58,15 +59,52 @@ bun add -g @ciphore/radiocli radiocli doctor ``` -Cross-platform release smoke checks should cover macOS, Linux, and Windows -startup paths: +The fresh-install harness builds one tarball, installs it into separate temporary +projects with normal dependencies and with `--omit=optional`, and executes only +that installed artifact. It checks CLI/doctor, offline cached providers, library +import/export, and MCP status. Its temporary paths contain +spaces and non-ASCII characters. Require a real native mpv for release evidence: ```bash -npm run fresh:check # macOS/Linux -npm run fresh:check # Windows, from PowerShell -radiocli doctor +npm run fresh:check -- --require-mpv +node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs ``` +The playback check generates a local WAV, uses mpv's null audio output, and +verifies readiness, IPC volume, pause, resume, and stop. It does not prove physical +audio-device output, a receiver connection, wake delivery, or a particular +terminal application. Set `RADIOCLI_SMOKE_EVIDENCE_DIR` to preserve JSON records +containing the artifact SHA-256, runtime identity, installation mode, and results. +Set `RADIOCLI_MPV_PATH` when the verified native executable is outside `PATH`. + +### Release Gates And Experimental Checks + +The required CI matrix preserves Ubuntu, macOS, and Windows on Node 22/24 and +adds Linux arm64, macOS Intel, and Windows 11 arm64. All 12 jobs require packed +mpv smoke checks in both installation modes; Linux also has source playback +checks. Documentation checks run on Node 22/24. All required jobs must pass on +the release candidate before publication; a failed required job blocks release. +Inspect the recorded runner OS/version and CPU before carrying evidence into +a release. Repository protection settings and publication remain owner-managed. + +All workflow actions use immutable commit pins. macOS and Windows playback +fixtures come from explicitly selected official mpv release assets, checked +against pinned SHA-256 values and native CPU headers before execution. Linux +uses its runner's distribution package. These are CI fixtures, not packages +installed by npm or a new public distribution channel. + +Weekly or manually requested FreeBSD, OpenBSD, and NetBSD checks boot full QEMU +guest kernels, install the Linux-built JavaScript tarball with guest-native Node +and mpv, and label the result as guest verification. They do not infer BSD +behavior from a Linux container, and they do not establish foreign-CPU or desktop +audio support. The [evidence matrix](platforms.mdx#project-tiers-and-current-evidence) +records actual outcomes and remaining verification gaps. + +Do not publish a release with a failed required job. An experimental job failure +must remain visible and its platform tier must retain the corresponding limit. +Repository protection settings, registry access, and release ownership remain +under maintainer control. + ## Homebrew Tap The public tap is `Ciphore/homebrew-tap`, and users install with: @@ -79,7 +117,7 @@ The reusable formula template lives in `packaging/homebrew/radiocli.rb.template` After each npm publish, fetch the npm tarball URL and SHA: ```bash -VERSION=0.2.3 +VERSION=0.2.4 TARBALL="$(npm view @ciphore/radiocli@$VERSION dist.tarball)" curl -L "$TARBALL" -o "/tmp/radiocli-$VERSION.tgz" shasum -a 256 "/tmp/radiocli-$VERSION.tgz" @@ -103,7 +141,7 @@ radiocli doctor ## Windows And Linux Native Playback -Windows setup uses native Node.js and native `mpv`: +Windows x64 setup uses native Node.js and native `mpv`: ```powershell winget install --id OpenJS.NodeJS.LTS -e @@ -117,6 +155,9 @@ directory to `PATH`. RadioCLI resolves that standard installation directly and also checks registered Windows application locations. Doctor should report an `mpv_path`, its `mpv_discovery` source, and `mpv_launch=ready`. +The inspected WinGet `mpv` recipe is x64-only. Scoop's `mpv` recipe includes x64 and +arm64 builds; verify the installed executable matches the intended runtime. +Windows x86 remains a Node 22 route requiring separate player and native evidence. Scoop users can install `mpv` with: ```powershell @@ -139,8 +180,9 @@ smoke checks should treat it as playback-only. `radiocli doctor` reports `controls=limited` when only `ffplay` is available and `controls=full` when `mpv` is available. -AirPlay is experimental on macOS. The npm package bundles the sender bridge, -and the Homebrew formula installs FFmpeg so the one-command macOS path exposes -AirPlay without extra user setup. Release checks should keep high/critical +AirPlay is experimental on macOS. Its optional sender dependency must install +and load successfully, and Bonjour and FFmpeg must be usable. The Homebrew +formula installs FFmpeg; that does not establish a working receiver connection. +Release checks should keep high/critical sender dependency advisories out of the shipped tree. AirPlay should remain an explicit session output rather than the automatic startup fallback. diff --git a/apps/docs/content/docs/reliability.mdx b/apps/docs/content/docs/reliability.mdx index dd86f53..5405d61 100644 --- a/apps/docs/content/docs/reliability.mdx +++ b/apps/docs/content/docs/reliability.mdx @@ -15,14 +15,16 @@ requirement. - station codecs can be mislabeled - ICY metadata can be absent, malformed, or delayed - terminal dimensions can change mid-session +- a data directory can become read-only, inaccessible, or full +- a native service can fail even when its command is installed ## Current Defenses -- Radio Browser mirror fallback +- Radio Browser DNS service discovery and mirror fallback - sharded, bounded provider cache with per-entry corruption isolation and stale fallback - cached geotagged station atlas for Explore and Nearby, with local distance-first ranking -- bounded network calls for provider and location lookups +- public HTTP deadlines that cover headers and response bodies - tune timeout - skip-to-next on failed tune when enabled - `mpv` IPC readiness check @@ -34,8 +36,9 @@ requirement. playback states - metadata polling through `mpv`, avoiding a second stream listener - user-controllable approximate-location lookup, requested only when Nearby opens -- corrupt store/cache backups +- required corrupt-library backups and separate best-effort cache recovery - locked, atomic local JSON writes with concurrent-process merge protection +- nonfatal optional TUI history saves, with explicit errors for failed edits - defensive playlist import - functional compact navigation for tiny terminals - native per-user alarm registration on launchd, Windows Task Scheduler, and @@ -45,6 +48,70 @@ requirement. - authenticated loopback controls for alarms that began while no TUI was open - best-effort playback sleep inhibition and optional pre-alarm Guard +## Network Requests + +Fallback Radio Browser endpoints come from its current DNS service record +instead of a fixed list of server names. Mirror attempts share a bounded +deadline. If the native hostname lookup reports a temporary `EAI_AGAIN` +failure, RadioCLI can retry through Node's DNS-protocol resolver. The original +hostname remains in the HTTPS request and certificate check. This recovery is +disabled whenever a proxy is configured so it cannot bypass the requested +network route. + +A response that sends headers but stalls while sending JSON cannot keep a +request open indefinitely. Usable stale cache entries remain available after a +request fails, and a failed cache write does not turn a successful response into +a directory outage. + +RadioCLI's public HTTP client supports HTTP(S) proxies through Node's native +`fetch`. On Node 22.21 or newer in the 22 release line, or Node 24 or newer, set +`NODE_USE_ENV_PROXY=1` before starting RadioCLI along with `HTTP_PROXY` or +`HTTPS_PROXY`; Node also reads `NO_PROXY`. A configured proxy without the +required startup opt-in is reported instead of silently using a direct +connection. See [Node's proxy configuration documentation](https://nodejs.org/learn/http/enterprise-network-configuration#proxy-configuration). + +SOCKS proxies and `ALL_PROXY` as the sole proxy configuration are unsupported by +this client and produce an actionable error. Proxy support leaves Node's TLS +verification and certificate configuration unchanged. It does not configure +the external player's networking. Authenticated local control uses literal +loopback addresses and direct HTTP requests regardless of proxy settings. + +| Setting | Behavior | +| --- | --- | +| `RADIOCLI_OFFLINE=1` | Block RadioCLI's public Node HTTP requests, including directory, location, vote, and update requests; use available cached directory results and saved direct stream URLs | +| `RADIOCLI_LOW_BANDWIDTH=1` | Use only a cached geotagged atlas for Explore and Nearby; smaller searches, health checks, location lookups, and manual controls remain available | + +A missing cache entry is reported rather than fetched in offline mode. These +flags do not put the operating system or native player offline: a requested +direct stream can still connect to its station. They do not download audio for +offline listening. + +Both flags skip automatic AirPlay receiver discovery. Offline mode also blocks +manual discovery and reports the setting; low-bandwidth mode allows an explicit +receiver refresh. Existing authenticated loopback controls and the stdio MCP +transport remain available offline. + +## Library Write Failures + +Library access errors preserve the existing bytes. An unreadable file is not +renamed as corrupt or replaced with defaults, and an inaccessible current +library does not trigger a switch to an older legacy file. If corrupt +JSON cannot be backed up, RadioCLI leaves it intact and reports the backup +failure. + +Atomic replacement requires a writable library file and destination directory. +A failed lock, write, permission change, or rename leaves the previous committed +state intact. Cleanup errors cannot replace the original failure or make a +completed replacement appear to have failed. + +When the library can be read but cannot be written, the TUI can still tune, +navigate, stop, and quit. Optional history failures produce a library warning; +they do not turn successful playback into a failed tune or prevent a sleep +timer or alarm takeover from stopping it. Settings and favorite edits remain +explicit saves and report failure without changing the saved state. Check the +directory's permissions or choose a private writable location with +`RADIOCLI_HOME`. + ## Scheduled Radio Delivery Saving an enabled alarm resolves its next occurrence and registers one native @@ -70,14 +137,27 @@ that audio. | Windows | Task Scheduler interactive-token task | `StartWhenAvailable` | `WakeToRun` is requested when enabled, but hardware and power policy decide | | Linux with systemd user session | systemd user timer | persistent timer catches up after the user session resumes | No `WakeSystem` claim without system privileges | | Linux without systemd user scheduling | Unsupported | None | None | +| BSD, Termux, and other experimental targets | No verified alarm adapter | None claimed | None claimed | + +An installed scheduler executable alone is insufficient. Registration and +diagnostics check the user service; a Linux host without a reachable systemd +user manager cannot register reliable background alarms. RadioCLI does not +substitute cron or a process-local timer for an unsupported native scheduler. +The [platform support matrix](./platforms.mdx) separates runtime eligibility +from verified RadioCLI behavior. Terminal reopening supports Apple Terminal, iTerm2, WezTerm, Ghostty, and kitty -on macOS; Windows Terminal with Command Prompt fallback on Windows; and common +on macOS; Windows Terminal or a PowerShell console on Windows; and common GUI terminal emulators on Linux. Integrated terminals that cannot be launched independently fall back to the platform terminal where possible. If a graphical terminal is unavailable, audio still plays and opening `radiocli` manually reveals the authenticated ringing controls. +The Windows console launcher waits for its temporary PowerShell bootstrap to +finish creating a separate console. Successful bootstrap completion still does +not establish TUI readiness; the caller verifies the expected session. The +console adapter requires PowerShell to permit .NET/native interop. + All three supported adapters require the computer to be powered on and the user to have a logged-in interactive audio session with usable local speakers. A powered-off machine cannot run RadioCLI. Exact behavior across ordinary sleep, @@ -95,6 +175,16 @@ power and does not override shutdown, hibernate, explicit sleep, lid policy, logout, or other OS decisions. Guard state is machine-local and is reconciled again after each alarm change. +If an inhibitor helper exits unexpectedly, runtime health reports lost sleep +protection while playback can continue. If Guard ownership cannot be verified, +its record is retained for repair and its PID is not signaled. A failed native +job removal is accepted only after the exact job is confirmed absent; otherwise +the alarm definition and repair artifacts remain available. Deleting an alarm +first saves it as disabled. If that save fails, deletion stops before native +cleanup; if cleanup or the final save fails, the disabled definition remains +available for repair or another deletion attempt. RadioCLI reports +these cases as unresolved rather than claiming protection or cleanup succeeded. + Recurring alarms use minute precision, ISO weekdays, and an explicit IANA timezone. In a spring daylight-saving gap, RadioCLI uses the first valid minute later on that civil date. In a fall overlap, it fires the first matching instant @@ -104,7 +194,8 @@ definitions keep their own IANA timezone, while the platform job contains the already-resolved next instant. When a native job starts late, the missed-run grace decides whether it still -plays. A due alarm tries its primary station twice, with a short bounded backoff, +plays. An early launch does not mark the occurrence complete or consume its due +dispatch. A due alarm tries its primary station twice, with a short bounded backoff, then tries the configured fallback. It stops after the requested number of minutes unless playback is transferred into the interactive TUI. True fade-in requires `mpv`; `ffplay` and VLC start at the target volume because they lack dependable runtime @@ -112,13 +203,25 @@ volume control. Scheduled audio is local-speaker only; unattended AirPlay is intentionally disabled. Before starting the player, RadioCLI also checks the default local system output. -If it is muted or below the alarm's configured volume, the runner unmutes it and -raises it to at least that level, then restores the previous device state when -the alarm ends. This uses macOS output-volume scripting, Windows Core Audio +If it is muted or below the alarm's configured volume, the runner attempts to +unmute it and raise it to at least that level. Overlapping alarm runners share +the original output state across library profiles: only the last runner to +release it attempts restoration. Transferring playback to the interactive TUI +preserves the current output level. A partial mixer change triggers a rollback +attempt; a failed restore is reported and remains retryable. If every runner +is forcibly terminated, recovery occurs on a later system-volume operation +after any surviving mixer helper exits. An incomplete helper record blocks +automatic adjustment and preserves the saved output state. This uses macOS +output-volume scripting, Windows Core Audio through built-in PowerShell, or `wpctl`, `pactl`, or `amixer` on Linux. If the host exposes none of those controls, player volume still applies and runtime diagnostics report that the system output could not be raised. +If diagnostics report an unknown mixer helper after a crash, restart the +computer before clearing `native-write.json` and the `lock` directory from +`.radiocli/system-volume` under the OS account's home directory. Keep +`state.json`: it contains the output state needed for the next recovery attempt. + ## Alarm Troubleshooting Use the following in order: @@ -164,13 +267,4 @@ the launchd label alone does not change executable identity. with a movable scan cursor and true distance ranking, but it can only place stations that providers expose with valid coordinates. -## Next Reliability Work - -- per-station health cache -- explicit failed-stream reasons in station lists -- "never show again" station hiding -- retry/backoff by station/provider -- optional stream HEAD/probe before tune - -See [Architecture](./architecture.mdx) for the provider and playback boundaries, -and [Roadmap](./roadmap.mdx) for planned follow-up work. +See [Architecture](./architecture.mdx) for the provider and playback boundaries. diff --git a/apps/docs/content/docs/roadmap.mdx b/apps/docs/content/docs/roadmap.mdx deleted file mode 100644 index d10b0b7..0000000 --- a/apps/docs/content/docs/roadmap.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: Roadmap -description: Shipped scope and focused follow-up work for RadioCLI. ---- - -## Shipped In 0.1 - -- Ink TUI -- Radio Browser provider -- `mpv` playback, `ffplay` fallback, and experimental macOS AirPlay output -- search, countries, world map, nearby, library -- playlist import/export -- provider cache and tune timeouts -- open-source repo hygiene - -## Shipped In 0.2.2 - -- TUI-first one-time and recurring radio alarms, listed under Overview before - Settings -- native per-user background scheduling with launchd, Windows Task Scheduler, - and systemd user timers -- missed-run grace, primary retry, fallback station, stop-after, snooze, and - authenticated ringing controls -- automatic reopening of the saved supported terminal for ringing controls -- optional OS wake requests where available and machine-local Alarm Guard idle - sleep inhibition - -## Follow-up Work - -- native scheduling adapters for Linux systems without systemd user sessions -- broader wake-from-sleep capability detection as OS and hardware APIs allow -- a signed macOS app bundle and Service Management helper so Background Activity - can identify RadioCLI instead of the Node runtime -- unattended remote audio outputs; alarms currently use local speakers only - -See [Changelog](./changelog.mdx) for the full release history. diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx new file mode 100644 index 0000000..3f44cb0 --- /dev/null +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -0,0 +1,201 @@ +--- +title: Troubleshooting +description: Diagnose playback tools, terminal rendering, local storage, and optional desktop integrations. +--- + +Start with the report for the feature that is failing: + +```bash +radiocli doctor +radiocli doctor --json +radiocli check +radiocli alarm doctor +``` + +`doctor` reports local playback tools and individual platform capabilities. +`check` adds provider health and the library path. `alarm doctor` reports native +scheduling and recorded delivery health. An available player does not establish that a +desktop session, system mixer, or scheduler is usable. See +[Platforms](/docs/platforms) for runtime requirements and verified coverage. + +## Playback Tool Installed But Not Found + +`radiocli doctor` prints the resolved `mpv` path, discovery method, version, and +launch result. RadioCLI checks `PATH`, known package-manager locations, and +supported application directories. An executable found outside `PATH` can be +used directly. + +For a custom installation, set an absolute path to the native executable: + +| Variable | Tool | +| --- | --- | +| `RADIOCLI_MPV_PATH` | `mpv` | +| `RADIOCLI_FFPLAY_PATH` | `ffplay` | +| `RADIOCLI_VLC_PATH` | `vlc` or `cvlc` | +| `RADIOCLI_FFMPEG_PATH` | FFmpeg, including the decoder used for macOS AirPlay | + +The value is a file path, with no command arguments. Windows overrides must +point to a native `.exe` or `.com` file; `.cmd` and `.bat` wrappers are not player +executables. A configured path that is invalid or cannot run is reported instead +of silently selecting another installation of that tool. Correct or remove the +override, then run Doctor again. + +Replace the example path with your installed executable. In a POSIX shell: + +```bash +RADIOCLI_MPV_PATH="/path with spaces/mpv" radiocli doctor +``` + +In PowerShell: + +```powershell +$env:RADIOCLI_MPV_PATH = 'C:\path with spaces\mpv.exe' +radiocli doctor +``` + +If no player is installed, use `radiocli setup --dry-run` to inspect the proposed +installation, or follow [Install](getting-started/install.mdx). `ffplay` and VLC +provide fallback playback, but pause, mute, interactive volume, metadata, and +play/pause media keys require `mpv`. + +On minimal NetBSD installations, the packaged mpv also needs the matching OS +X11 base set. Missing libraries under `/usr/X11R7/lib` can make the package +installation fail or prevent the executable from starting, even with null audio +and video. Install the matching distribution set through NetBSD's OS tools, +then retry the player package and `mpv --version` before checking RadioCLI. + +## Missing Glyphs, Incorrect Colors, Or Too Much Motion + +Use **Settings → Appearance** or the command palette: + +```text +:ascii on +:background on +:motion on +``` + +ASCII mode replaces decorative glyphs while preserving station names and +metadata. Transparent Background removes the panel fill. Reduce Motion freezes +receiver and loading animations and stops scrolling long labels. + +For a single launch, use `RADIOCLI_ASCII=1`, `NO_COLOR=1`, or +`RADIOCLI_DISABLE_ANIMATION=1`. Any non-empty `NO_COLOR` value removes colors and +fills, even when `FORCE_COLOR` is set. Sixteen-color terminals automatically +keep their own background. + +If ASCII appeared unexpectedly, check `LC_ALL`, `LC_CTYPE`, and `LANG`, in that +order. The first non-empty value determines the locale check: bare `C` and +`POSIX` select ASCII decoration, while UTF-8 locales retain Unicode. An explicit +`RADIOCLI_ASCII=1` also selects ASCII; `RADIOCLI_UNICODE=1` restores Unicode when +ASCII is not explicitly enabled and the terminal is not `dumb`. SSH alone does +not select ASCII or reduced motion. + +Set `RADIOCLI_SCREEN_READER=1` or `INK_SCREEN_READER=true` for screen-reader +output. Receiver visualizers, mouse reporting, and scrolling labels are +disabled; keyboard navigation and commands remain available. All +environment overrides leave your saved appearance settings intact. See +[TUI Controls](getting-started/tui-controls.mdx) for the complete list. + +## TUI Input Or Desktop Actions Are Unavailable + +Open `radiocli` from a terminal with interactive keyboard input. `TERM=dumb` +uses static frames with ASCII decoration and no color or animation. The TUI +still needs interactive stdin. Use command mode for redirected input or scripts: + +```bash +radiocli doctor --json +radiocli search "jazz" +radiocli alarm list --json +``` + +Browser opening, clipboard access, and opening another terminal also depend on +the current desktop session. On Linux, clipboard integration needs a working +Wayland or X11 session and an available helper such as `wl-copy`, `xclip`, or +`xsel`. When automatic copying is unavailable, RadioCLI displays the stream URL +for manual copying. Keyboard navigation remains available without mouse support. + +For agent playback on a machine without a usable graphical terminal, explicitly +choose headless mode with `radiocli setup --mcp --headless-agent`. See +[CLI Usage](getting-started/cli.mdx) for agent controls. + +## Offline Directories, Proxies, And IPv6 + +Check the network policy in `radiocli doctor --json`. `RADIOCLI_OFFLINE=1` +blocks RadioCLI's public directory, location, vote, update, and receiver-discovery +requests. Cached directories and saved or imported direct station +URLs remain usable. A cache miss stays a cache miss until you run online again; +offline mode does not download stations or audio in the background. + +`RADIOCLI_LOW_BANDWIDTH=1` uses the cached geotagged atlas and skips automatic +receiver discovery. Smaller searches and manual requests remain available. +Both settings leave the external player's networking separate: tuning a live +stream still needs a working route to that station. + +For public HTTP(S) proxies, use a compatible Node release and set +`NODE_USE_ENV_PROXY=1` before starting RadioCLI, together with `HTTP_PROXY` or +`HTTPS_PROXY` and any required `NO_PROXY` exceptions. Node 22 needs 22.21 or newer +for this public-fetch route; Node 24 also provides it. A missing startup opt-in, +invalid proxy URL, SOCKS proxy, or `ALL_PROXY`-only configuration produces a +diagnostic. Correct that configuration before retrying. The setting does not +configure mpv or another player's proxy behavior. See +[Network requests](reliability.mdx#network-requests). + +Authenticated local controls bind to `127.0.0.1`, falling back to `::1` when +IPv4 loopback is unavailable, and bypass public proxy settings. Public directory +and media connectivity still depend on each endpoint's addresses and the host's +DNS and routing. A successful local IPv6 control check does not establish public +IPv6 reachability. Diagnose the provider request and the external player's +stream connection separately; neither requires exposing local control to the LAN. + +RadioCLI discovers current Radio Browser servers automatically. It also recovers +from many temporary `EAI_AGAIN` lookup failures without weakening HTTPS checks. +If every request still reports a name-resolution error, the machine cannot reach +its configured DNS service; correct that system or network configuration and +retry. When a proxy is configured, RadioCLI leaves name resolution to that route +instead of bypassing it. + +## Library Is Read-Only Or Cannot Be Saved + +A readable library remains usable for browsing and playback. If a history write +fails, the TUI shows **Library not saved** and continues tuning, navigation, +stop, and quit. Failed writes do not become saved listening history, and +explicit favorite, setting, or alarm saves report their failure. + +Use the library path from `radiocli check` to inspect the file and its parent +directory. Atomic saves need write access to both. Check filesystem permissions, +Windows ACLs, or a read-only mount as appropriate. Windows ACLs are not configured +by POSIX `chmod` modes. + +You can set `RADIOCLI_HOME` to a private writable directory. This selects a +separate data location; it does not move the existing library automatically. +Preserve your existing data before changing locations, and restart RadioCLI +after correcting the path or permissions. + +## Optional Native Features Need Attention + +- **macOS:** terminal reopening through Apple Terminal or iTerm2 may need + Automation consent from the launching app. Experimental AirPlay also needs + Bonjour, FFmpeg, a usable sender, and an explicitly selected receiver. Local + playback remains a separate output option. +- **Windows:** scheduled alarms use Task Scheduler with the interactive user + token. A requested `WakeToRun` setting still depends on hardware and power + policy. Use native Windows RadioCLI for Windows agent and terminal setup; + WSL has a separate Linux environment. The PowerShell console, system-volume, + and sleep-inhibitor adapters require permitted .NET/native interop; constrained + PowerShell policy can prevent those optional operations. +- **Linux:** background alarms require an accessible systemd user manager. + A machine without that session can still use normal playback when its player + and audio device are available. A found `systemctl` executable alone does not + establish working user scheduling. + +Use **Verify alarm setup** in the Alarms screen to check registration, terminal +delivery, sleep protection, system volume, and bounded sample playback on the +current machine. Player volume and system output volume are separate: when a +system mixer cannot be controlled, set the output device volume manually. +macOS and Linux do not request privileged exact-wake events. Alarm Guard holds +off idle sleep only while its inhibitor is alive. + +Follow the [Alarms guide](alarms.mdx) and +[Reliability](reliability.mdx) for repair steps and wake limitations. Optional +feature failures are reported individually; the platform name alone does not +guarantee their availability. diff --git a/apps/docs/lib/seo.ts b/apps/docs/lib/seo.ts index 90292b3..abfae45 100644 --- a/apps/docs/lib/seo.ts +++ b/apps/docs/lib/seo.ts @@ -94,7 +94,7 @@ export function getHomeJsonLd() { codeRepository: repositoryUrl, downloadUrl: 'https://www.npmjs.com/package/@ciphore/radiocli', installUrl: absoluteUrl('/docs/getting-started/install'), - softwareVersion: '0.2.3', + softwareVersion: '0.2.4', license: licenseUrl, isAccessibleForFree: true, author: { diff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json index 013cdc2..70aa622 100644 --- a/apps/docs/package-lock.json +++ b/apps/docs/package-lock.json @@ -1,19 +1,19 @@ { "name": "radiocli-docs", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "radiocli-docs", - "version": "0.2.3", + "version": "0.2.4", "hasInstallScript": true, "dependencies": { "fumadocs-core": "16.9.3", "fumadocs-mdx": "15.0.10", "fumadocs-ui": "16.9.3", "lucide-react": "^1.17.0", - "next": "16.3.0", + "next": "16.3.5", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -537,9 +537,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -555,13 +555,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -577,20 +577,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -600,9 +600,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -616,9 +616,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -632,9 +632,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -651,9 +651,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -670,9 +670,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -689,9 +689,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -708,9 +708,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -727,9 +727,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -746,9 +746,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -765,9 +765,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -784,9 +784,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -805,13 +805,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -830,13 +830,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -855,13 +855,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -880,13 +880,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -905,13 +905,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -930,13 +930,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -955,13 +955,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -980,17 +980,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1000,16 +1000,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1019,9 +1019,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -1038,9 +1038,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -1057,9 +1057,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1163,15 +1163,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", - "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", + "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", - "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", + "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", "cpu": [ "arm64" ], @@ -1185,9 +1185,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", - "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", + "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", "cpu": [ "x64" ], @@ -1201,9 +1201,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", - "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", + "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", "cpu": [ "arm64" ], @@ -1220,9 +1220,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", - "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", + "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", "cpu": [ "arm64" ], @@ -1239,9 +1239,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", - "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", + "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", "cpu": [ "x64" ], @@ -1258,9 +1258,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", - "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", + "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", "cpu": [ "x64" ], @@ -1277,9 +1277,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", - "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", + "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", "cpu": [ "arm64" ], @@ -1293,9 +1293,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", - "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", + "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", "cpu": [ "x64" ], @@ -2235,9 +2235,9 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -2729,9 +2729,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3724,9 +3724,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -5139,13 +5139,13 @@ } }, "node_modules/next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", - "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", + "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0", - "@swc/helpers": "0.5.15", + "@next/env": "16.3.5", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", @@ -5158,15 +5158,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.0", - "@next/swc-darwin-x64": "16.3.0", - "@next/swc-linux-arm64-gnu": "16.3.0", - "@next/swc-linux-arm64-musl": "16.3.0", - "@next/swc-linux-x64-gnu": "16.3.0", - "@next/swc-linux-x64-musl": "16.3.0", - "@next/swc-win32-arm64-msvc": "16.3.0", - "@next/swc-win32-x64-msvc": "16.3.0", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.3.5", + "@next/swc-darwin-x64": "16.3.5", + "@next/swc-linux-arm64-gnu": "16.3.5", + "@next/swc-linux-arm64-musl": "16.3.5", + "@next/swc-linux-x64-gnu": "16.3.5", + "@next/swc-linux-x64-musl": "16.3.5", + "@next/swc-win32-arm64-msvc": "16.3.5", + "@next/swc-win32-x64-msvc": "16.3.5", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -5660,9 +5660,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -5677,31 +5677,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { "@types/node": { diff --git a/apps/docs/package.json b/apps/docs/package.json index 94a4778..53ccb16 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,6 +1,6 @@ { "name": "radiocli-docs", - "version": "0.2.3", + "version": "0.2.4", "private": true, "description": "Public website and documentation for RadioCLI.", "type": "module", @@ -16,7 +16,7 @@ "fumadocs-mdx": "15.0.10", "fumadocs-ui": "16.9.3", "lucide-react": "^1.17.0", - "next": "16.3.0", + "next": "16.3.5", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/package-lock.json b/package-lock.json index 3740627..636d8db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ciphore/radiocli", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ciphore/radiocli", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/server": "^2.0.0", diff --git a/package.json b/package.json index 7ab9227..7ad95c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ciphore/radiocli", - "version": "0.2.3", + "version": "0.2.4", "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.", "type": "module", "license": "MIT", diff --git a/scripts/fresh-check.mjs b/scripts/fresh-check.mjs index 5287656..a37ac75 100755 --- a/scripts/fresh-check.mjs +++ b/scripts/fresh-check.mjs @@ -1,79 +1,34 @@ #!/usr/bin/env node -import {existsSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdtempSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join, resolve} from 'node:path'; -import {spawnSync} from 'node:child_process'; -import process from 'node:process'; import {fileURLToPath} from 'node:url'; +import {npmCommand, runCommand, runPackedSmoke, smokeEnvironment} from './packed-smoke.mjs'; const root = resolve(fileURLToPath(new URL('..', import.meta.url))); -const tempProject = mkdtempSync(join(tmpdir(), 'radiocli-fresh-')); -const tempStore = mkdtempSync(join(tmpdir(), 'radiocli-store-')); -let packedTarball; +const args = process.argv.slice(2); +if (args.some(arg => arg !== '--require-mpv')) throw new Error('Usage: npm run fresh:check [-- --require-mpv]'); +const temporary = mkdtempSync(join(tmpdir(), 'radiocli-pack-')); try { - if (existsSync(join(root, 'tsconfig.build.json'))) { - run('npm', ['run', 'build', '--silent'], {cwd: root}); + const npm = npmCommand(); + const env = smokeEnvironment(join(temporary, 'state')); + // npm pack runs the normal prepack build, and writes only into a temp folder. + const packed = runCommand(npm.command, [...npm.args, 'pack', '--json', '--dry-run=false', '--pack-destination', temporary], {cwd: root, env, timeout: 180_000}); + const info = JSON.parse(packed.stdout); + if (info.length !== 1 || !info[0]?.filename) throw new Error('npm pack did not return exactly one package.'); + const tarball = join(temporary, info[0].filename); + if (!existsSync(tarball)) throw new Error(`npm pack did not create ${tarball}`); + const evidenceRoot = process.env.RADIOCLI_SMOKE_EVIDENCE_DIR; + for (const omitOptional of [false, true]) { + await runPackedSmoke(tarball, { + omitOptional, + requireMpv: args.includes('--require-mpv'), + executionMethod: process.env.RADIOCLI_SMOKE_EXECUTION_METHOD ?? 'local process', + evidencePath: evidenceRoot ? join(evidenceRoot, `${omitOptional ? 'omit-optional' : 'normal'}.json`) : undefined + }); } - - const pack = run('npm', ['pack', '--json', '--dry-run=false'], {cwd: root, env: freshEnv()}); - const packageInfo = JSON.parse(pack.stdout)[0]; - packedTarball = resolve(root, packageInfo.filename); - - if (!existsSync(packedTarball)) { - throw new Error(`npm pack did not create ${packedTarball}`); - } - - writeFileSync(join(tempProject, 'package.json'), '{"private":true,"type":"module"}\n', 'utf8'); - run('npm', ['install', '--no-audit', '--fund=false', packedTarball], {cwd: tempProject, env: freshEnv(), timeout: 180_000}); - - const bin = process.platform === 'win32' - ? join(tempProject, 'node_modules', '.bin', 'radiocli.cmd') - : join(tempProject, 'node_modules', '.bin', 'radiocli'); - - run(bin, ['help'], {cwd: tempProject, env: freshEnv(), timeout: 30_000}); - const check = run(bin, ['check'], {cwd: tempProject, env: freshEnv(), timeout: 45_000}); - - process.stdout.write(check.stdout); - console.log('fresh_check=ok'); + console.log('fresh_check=ok installs=normal,omit-optional'); } finally { - if (packedTarball) { - rmSync(packedTarball, {force: true}); - } - - rmSync(tempProject, {force: true, recursive: true}); - rmSync(tempStore, {force: true, recursive: true}); -} - -function freshEnv() { - const env = { - ...process.env, - RADIOCLI_HOME: tempStore, - RADIOCLI_DISABLE_ANIMATION: '1' - }; - delete env.npm_config_dry_run; - delete env.NPM_CONFIG_DRY_RUN; - return env; -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd ?? root, - env: options.env ?? process.env, - encoding: 'utf8', - shell: process.platform === 'win32', - timeout: options.timeout ?? 120_000 - }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - process.stderr.write(result.stdout ?? ''); - process.stderr.write(result.stderr ?? ''); - throw new Error(`${command} ${args.join(' ')} exited with ${result.status}`); - } - - return result; + rmSync(temporary, {force: true, recursive: true}); } diff --git a/scripts/install-smoke-mpv.mjs b/scripts/install-smoke-mpv.mjs new file mode 100644 index 0000000..02faf2a --- /dev/null +++ b/scripts/install-smoke-mpv.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {runCommand} from './packed-smoke.mjs'; + +// CI-only release fixtures, verified against the release API on 2026-09-07. +// https://github.com/mpv-player/mpv/releases/tag/v0.41.0 +// Keep hashes fixed: a replaced release asset must fail rather than change CI silently. +const assets = { + 'darwin-x64': {name: 'mpv-v0.41.0-macos-15-intel.zip', sha256: '41003617ab4f7784394b5ddea7ce51b3e0838e8cfc8166ad1a378b2eda3b583c'}, + 'darwin-arm64': {name: 'mpv-v0.41.0-macos-26-arm.zip', sha256: '09820c0d84f6687446b84eb9df81fcf6a26ebe869cee58ea1857d7948cfb7c71'}, + 'win32-x64': {name: 'mpv-v0.41.0-x86_64-pc-windows-msvc.zip', sha256: '4e197f729f5071c6772f35fffd96e0f36e3e8a044bd9479b136bb09b7c6a80ff'}, + 'win32-arm64': {name: 'mpv-v0.41.0-aarch64-pc-windows-msvc.zip', sha256: 'a822abeffd0ac88951f4084f3425f949842aa17d616f880637ebe9041e482e97'} +}; + +export function selectMpvAsset(platform, architecture) { + const asset = assets[`${platform}-${architecture}`]; + if (!asset) throw new Error(`No pinned native mpv smoke asset for ${platform}/${architecture}.`); + return {...asset, url: `https://github.com/mpv-player/mpv/releases/download/v0.41.0/${asset.name}`}; +} + +export function validateArchivePaths(paths) { + for (const path of paths) { + const normalized = path.replaceAll('\\', '/'); + assert.ok(!/[\0\r\n]/.test(normalized) && !normalized.startsWith('/') && !/^[a-z]:/i.test(normalized) && !normalized.split('/').includes('..'), `Unsafe archive path: ${JSON.stringify(path)}`); + } +} + +function zipEntryNames(bytes) { + const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + assert.ok(end >= 0 && end + 22 <= bytes.length, 'Invalid ZIP directory.'); + const count = bytes.readUInt16LE(end + 10); + let offset = bytes.readUInt32LE(end + 16); + const names = []; + for (let index = 0; index < count; index++) { + assert.ok(offset + 46 <= bytes.length && bytes.readUInt32LE(offset) === 0x02014b50, 'Invalid ZIP entry.'); + const nameLength = bytes.readUInt16LE(offset + 28); + const next = offset + 46 + nameLength + bytes.readUInt16LE(offset + 30) + bytes.readUInt16LE(offset + 32); + assert.ok(next <= bytes.length, 'Truncated ZIP entry.'); + names.push(bytes.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')); + offset = next; + } + assert.ok(names.length > 0, 'The pinned ZIP must contain files.'); + return names; +} + +/** Inspect the executable before running it; emulated x64 is not ARM64 evidence. */ +export function verifyNativeBinary(bytes, platform, architecture) { + if (platform === 'win32') { + assert.ok(bytes.length >= 64 && bytes.toString('ascii', 0, 2) === 'MZ', 'Invalid Windows PE header.'); + const offset = bytes.readUInt32LE(0x3c); + assert.ok(offset + 6 <= bytes.length && bytes.readUInt32LE(offset) === 0x00004550, 'Invalid Windows PE header.'); + const cpu = ({[0x8664]: 'x64', [0xaa64]: 'arm64'})[bytes.readUInt16LE(offset + 4)]; + assert.equal(cpu, architecture, 'mpv executable architecture does not match Node and the runner.'); + return {architectures: [cpu]}; + } + assert.ok(platform === 'darwin' && bytes.length >= 32, 'Unknown executable format.'); + const cpuName = cpu => ({[0x01000007]: 'x64', [0x0100000c]: 'arm64'})[cpu]; + let architectures; + if (bytes.readUInt32BE(0) === 0xcafebabe) { + const slices = []; + for (let index = 0; index < bytes.readUInt32BE(4); index++) { + const offset = 8 + index * 20; + assert.ok(offset + 20 <= bytes.length, 'Truncated universal Mach-O header.'); + slices.push({architecture: cpuName(bytes.readUInt32BE(offset)), offset: bytes.readUInt32BE(offset + 8), size: bytes.readUInt32BE(offset + 12)}); + } + architectures = slices.map(slice => slice.architecture).filter(Boolean); + const selected = slices.find(slice => slice.architecture === architecture); + assert.ok(selected && selected.offset + selected.size <= bytes.length, 'mpv executable architecture does not match the runner.'); + bytes = bytes.subarray(selected.offset, selected.offset + selected.size); + } + assert.ok(bytes.length >= 32 && bytes.readUInt32LE(0) === 0xfeedfacf, 'Invalid 64-bit Mach-O header.'); + const cpu = cpuName(bytes.readUInt32LE(4)); + assert.equal(cpu, architecture, 'mpv executable architecture does not match Node and the runner.'); + let minimumMacos; + let offset = 32; + for (let index = 0; index < bytes.readUInt32LE(16); index++) { + assert.ok(offset + 8 <= bytes.length, 'Truncated Mach-O command.'); + const command = bytes.readUInt32LE(offset); + const size = bytes.readUInt32LE(offset + 4); + assert.ok(size >= 8 && offset + size <= bytes.length, 'Invalid Mach-O command.'); + const versionOffset = command === 0x32 && size >= 24 ? 12 : command === 0x24 && size >= 16 ? 8 : null; + if (versionOffset !== null) { + const version = bytes.readUInt32LE(offset + versionOffset); + minimumMacos = `${version >>> 16}.${(version >>> 8) & 0xff}.${version & 0xff}`; + } + offset += size; + } + return {architectures: architectures ?? [cpu], ...(minimumMacos ? {minimumMacos} : {})}; +} + +function versionAtLeast(actual, minimum) { + const left = actual.split('.').map(Number); + const right = minimum.split('.').map(Number); + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference) return difference > 0; + } + return true; +} + +async function install() { + const asset = selectMpvAsset(process.platform, process.arch); + const root = mkdtempSync(join(process.env.RUNNER_TEMP ?? tmpdir(), 'radiocli-ci-mpv-')); + const extracted = join(root, 'unpacked'); + mkdirSync(extracted); + let completed = false; + try { + let bytes; + if (process.env.RADIOCLI_SMOKE_MPV_ARCHIVE) bytes = readFileSync(process.env.RADIOCLI_SMOKE_MPV_ARCHIVE); + else { + const response = await fetch(asset.url, {signal: AbortSignal.timeout(120_000)}); + assert.ok(response.ok, `mpv asset download failed: HTTP ${response.status}.`); + bytes = Buffer.from(await response.arrayBuffer()); + } + assert.equal(createHash('sha256').update(bytes).digest('hex'), asset.sha256, 'mpv release archive SHA256 mismatch.'); + validateArchivePaths(zipEntryNames(bytes)); + const archive = join(root, asset.name); + writeFileSync(archive, bytes); + let executable; + let macos; + if (process.platform === 'darwin') { + runCommand('/usr/bin/ditto', ['-x', '-k', archive, extracted]); + const packed = join(extracted, 'mpv.tar.gz'); + validateArchivePaths(runCommand('/usr/bin/tar', ['-tzf', packed]).stdout.split('\n').filter(Boolean)); + runCommand('/usr/bin/tar', ['-xzf', packed, '-C', extracted]); + executable = join(extracted, 'mpv.app', 'Contents', 'MacOS', 'mpv'); + macos = runCommand('/usr/bin/sw_vers', ['-productVersion']).stdout.trim(); + } else { + // Fixed PowerShell code. Neither archive path is interpolated into code. + runCommand('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'Expand-Archive -LiteralPath $env:RADIOCLI_MPV_ARCHIVE -DestinationPath $env:RADIOCLI_MPV_EXTRACT -Force'], { + env: {...process.env, RADIOCLI_MPV_ARCHIVE: archive, RADIOCLI_MPV_EXTRACT: extracted} + }); + executable = join(extracted, 'mpv.exe'); + } + const binary = verifyNativeBinary(readFileSync(executable), process.platform, process.arch); + if (binary.minimumMacos) assert.ok(versionAtLeast(macos, binary.minimumMacos), `mpv requires macOS ${binary.minimumMacos}; this runner has ${macos}.`); + const version = runCommand(executable, ['--version']).stdout.split('\n')[0]; + const evidence = {asset: asset.url, sha256: asset.sha256, platform: process.platform, architecture: process.arch, ...binary, ...(macos ? {macos} : {}), version, executable}; + if (process.env.GITHUB_PATH) appendFileSync(process.env.GITHUB_PATH, `${dirname(executable)}\n`); + if (process.env.GITHUB_ENV) appendFileSync(process.env.GITHUB_ENV, `RADIOCLI_MPV_PATH=${executable}\n`); + if (process.env.RADIOCLI_SMOKE_EVIDENCE_DIR) { + mkdirSync(process.env.RADIOCLI_SMOKE_EVIDENCE_DIR, {recursive: true}); + writeFileSync(join(process.env.RADIOCLI_SMOKE_EVIDENCE_DIR, 'mpv-asset.json'), `${JSON.stringify(evidence, null, 2)}\n`); + } + console.log(`mpv_asset=${JSON.stringify(evidence)}`); + completed = true; + } finally { + // Successful installs live only in runner temp for subsequent smoke steps. + if (!completed) rmSync(root, {recursive: true, force: true}); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await install().catch(error => { console.error(error.stack ?? error.message); process.exitCode = 1; }); +} diff --git a/scripts/install-smoke-mpv.test-node.mjs b/scripts/install-smoke-mpv.test-node.mjs new file mode 100644 index 0000000..c2aa0dd --- /dev/null +++ b/scripts/install-smoke-mpv.test-node.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +async function helpers() { + const module = await import('./install-smoke-mpv.mjs').catch(() => ({})); + assert.equal(typeof module.selectMpvAsset, 'function', 'CI must select a pinned native mpv asset before downloading'); + return module; +} + +test('Windows ARM selects an aarch64 build and rejects unsupported architectures', async () => { + const {selectMpvAsset} = await helpers(); + assert.match(selectMpvAsset('win32', 'arm64').name, /aarch64/); + assert.match(selectMpvAsset('win32', 'x64').name, /x86_64/); + assert.throws(() => selectMpvAsset('win32', 'ia32'), /No pinned native mpv/); + assert.throws(() => selectMpvAsset('unknown', 'arm64'), /No pinned native mpv/); +}); + +test('a Windows x64 executable cannot satisfy ARM64 playback evidence', async () => { + const {verifyNativeBinary} = await helpers(); + const binary = Buffer.alloc(128); + binary.write('MZ'); + binary.writeUInt32LE(64, 0x3c); + binary.write('PE\0\0', 64); + binary.writeUInt16LE(0x8664, 68); + assert.throws(() => verifyNativeBinary(binary, 'win32', 'arm64'), /architecture/); + binary.writeUInt16LE(0xaa64, 68); + assert.deepEqual(verifyNativeBinary(binary, 'win32', 'arm64').architectures, ['arm64']); +}); + +test('Mach-O inspection verifies CPU and records its declared minimum macOS', async () => { + const {verifyNativeBinary} = await helpers(); + const binary = Buffer.alloc(56); + binary.writeUInt32LE(0xfeedfacf, 0); + binary.writeUInt32LE(0x01000007, 4); + binary.writeUInt32LE(1, 16); + binary.writeUInt32LE(24, 20); + binary.writeUInt32LE(0x32, 32); + binary.writeUInt32LE(24, 36); + binary.writeUInt32LE(1, 40); + binary.writeUInt32LE(0x000d0500, 44); + assert.deepEqual(verifyNativeBinary(binary, 'darwin', 'x64'), {architectures: ['x64'], minimumMacos: '13.5.0'}); + assert.throws(() => verifyNativeBinary(binary, 'darwin', 'arm64'), /architecture/); +}); + +test('archive paths cannot leave the isolated extraction directory', async () => { + const {validateArchivePaths} = await helpers(); + validateArchivePaths(['mpv.exe', 'mpv.app/Contents/MacOS/mpv']); + for (const path of ['../mpv.exe', '/tmp/mpv', 'C:\\mpv.exe', 'a/../../mpv', 'a\\..\\mpv']) { + assert.throws(() => validateArchivePaths([path]), /Unsafe archive path/); + } +}); + +test('unknown binary formats fail before executing downloaded code', async () => { + const {verifyNativeBinary} = await helpers(); + assert.throws(() => verifyNativeBinary(Buffer.from('#!/bin/sh'), 'win32', 'arm64'), /PE|format|header/); + assert.throws(() => verifyNativeBinary(Buffer.alloc(64), 'darwin', 'x64'), /Mach-O|format|header/); +}); diff --git a/scripts/packed-smoke.mjs b/scripts/packed-smoke.mjs new file mode 100644 index 0000000..ad4fbc2 --- /dev/null +++ b/scripts/packed-smoke.mjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import {spawn, spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {arch, endianness, release, tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath, pathToFileURL} from 'node:url'; +import {setTimeout as delay} from 'node:timers/promises'; + +const script = fileURLToPath(import.meta.url); + +/** npm.cmd is a shell program; execute npm's JS entry with the selected Node. */ +export function npmCommand({execPath = process.execPath, platform = process.platform, env = process.env} = {}) { + const candidates = [env.npm_execpath, + join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + resolve(dirname(execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')]; + const path = Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] ?? ''; + for (const directory of path.split(platform === 'win32' ? ';' : ':').filter(Boolean)) { + candidates.push(join(directory, 'npm'), join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js')); + } + for (const candidate of candidates) { + if (!candidate || !existsSync(candidate)) continue; + const entry = realpathSync(candidate); + if (entry.endsWith('npm-cli.js')) return {command: execPath, args: [entry]}; + } + throw new Error('Cannot locate npm-cli.js. Install npm alongside Node.js or run this script through npm.'); +} + +export function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, env: options.env ?? process.env, encoding: 'utf8', shell: false, + windowsHide: true, timeout: options.timeout ?? 120_000, maxBuffer: 16 * 1024 * 1024 + }); + if (result.error || result.status !== (options.expectedStatus ?? 0)) { + throw new Error(`${command} failed: ${result.error?.message ?? `exited with ${result.status}`}\n${result.stdout ?? ''}${result.stderr ?? ''}`); + } + return result; +} + +export function smokeEnvironment(home) { + const env = {...process.env, RADIOCLI_HOME: home, RADIOCLI_OFFLINE: '1', RADIOCLI_DISABLE_ANIMATION: '1', RADIOCLI_MPV_AUDIO_OUTPUT: 'null', NO_COLOR: '1'}; + for (const key of Object.keys(env)) { + if (['node_path', 'node_options', 'npm_config_dry_run', 'npm_config_omit', 'npm_config_include', 'npm_config_ignore_scripts'].includes(key.toLowerCase())) delete env[key]; + } + return env; +} + +/** Imports only the installed tarball; never the checkout's dist or node_modules. */ +export async function runPackedSmoke(tarball, {omitOptional = false, requireMpv = false, executionMethod = 'local process', evidencePath} = {}) { + tarball = resolve(tarball); + assert.ok(existsSync(tarball), `Packed tarball is missing: ${tarball}`); + assert.ok(Number(process.versions.node.split('.')[0]) >= 22, 'Packed runtime checks require Node.js 22 or newer.'); + if (process.env.RADIOCLI_EXPECTED_ARCH) assert.equal(process.arch, process.env.RADIOCLI_EXPECTED_ARCH, 'The packed runtime must match the requested native runner architecture.'); + const temporary = mkdtempSync(join(tmpdir(), 'radiocli packed & # % 日本 ')); + const project = join(temporary, 'project'); + const home = join(temporary, 'state'); + mkdirSync(project); + mkdirSync(home); + const env = smokeEnvironment(home); + const networkLog = join(temporary, 'unexpected-network.log'); + const guardPath = join(temporary, 'network-guard.mjs'); + // --import uses ESM URL resolution: drive letters and URL delimiters must + // remain file-path data on Windows and on POSIX paths containing # or %. + const guard = pathToFileURL(guardPath).href; + writeFileSync(guardPath, `import {appendFileSync} from 'node:fs';\nglobalThis.fetch = () => { appendFileSync(process.env.RADIOCLI_SMOKE_NETWORK_LOG, 'fetch attempted\\n'); return Promise.reject(new Error('Packed smoke forbids live network requests.')); };\n`); + env.RADIOCLI_SMOKE_NETWORK_LOG = networkLog; + try { + writeFileSync(join(project, 'package.json'), '{"private":true,"type":"module"}\n'); + const npm = npmCommand(); + console.log(`packed_install=${omitOptional ? 'omit-optional' : 'normal'} method=${executionMethod}`); + runCommand(npm.command, [...npm.args, 'install', '--no-audit', '--fund=false', '--omit=dev', '--ignore-scripts=false', + ...(omitOptional ? ['--omit=optional'] : ['--include=optional']), tarball], {cwd: project, env, timeout: 300_000}); + const packageRoot = join(project, 'node_modules', '@ciphore', 'radiocli'); + const metadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); + const cli = join(packageRoot, metadata.bin.radiocli); + assert.ok(existsSync(cli), 'The installed package is missing its declared CLI entry.'); + if (omitOptional) assert.equal(existsSync(join(project, 'node_modules', 'node-airtunes2')), false, 'The portable install unexpectedly installed the optional native sender.'); + const runCli = (args, options = {}) => runCommand(process.execPath, ['--import', guard, cli, ...args], {cwd: project, env, timeout: 45_000, ...options}); + assert.match(runCli(['help']).stdout, /Usage:/); + assert.equal(runCli(['version']).stdout.trim(), metadata.version); + const doctor = JSON.parse(runCli(['doctor', '--json']).stdout); + assert.equal(doctor.radioCliVersion, metadata.version); + assert.equal(doctor.platform, process.platform); + assert.equal(doctor.architecture, process.arch); + assert.ok(doctor.capabilities && doctor.host, 'doctor must report host facts and capabilities.'); + + // A missing offline cache must fail promptly; stale persisted data must work. + // The preload makes accidental live-directory access an explicit failure. + assert.match(runCli(['countries'], {expectedStatus: 1}).stderr, /offline|cached/i); + seedProviderCache(home); + assert.match(runCli(['countries']).stdout, /ZZ\t2\tSmoke Islands/); + assert.match(runCli(['search', 'packed-smoke']).stdout, /Packed Smoke Radio/); + assert.match(runCli(['search', 'uncached-fixture'], {expectedStatus: 1}).stderr, /offline|cached/i); + const stationName = 'Émission 日本 & $(literal)'; + const stationUrl = 'https://example.invalid/packed-smoke?x=1&y=2'; + assert.match(runCli(['add-url', stationUrl, stationName]).stdout, /added=/); + const playlist = join(project, 'input & 日本.m3u'); + const exported = join(project, 'output & 日本.m3u'); + writeFileSync(playlist, '#EXTM3U\n#EXTINF:-1,Imported Fixture\nhttps://example.invalid/imported\n'); + assert.match(runCli(['import', playlist]).stdout, /imported=1/); + runCli(['export', exported]); + const contents = readFileSync(exported, 'utf8'); + assert.ok(contents.includes(stationName) && contents.includes(stationUrl), 'The exported playlist lost the custom URL or Unicode name.'); + assert.match(contents, /Imported Fixture\nhttps:\/\/example\.invalid\/imported/); + const libraryPath = join(home, 'radiocli.json'); + const library = JSON.parse(readFileSync(libraryPath, 'utf8')); + assert.equal(library.imported.length, 2, 'Both CLI processes must persist to the isolated store.'); + library.settings.agentControl.enabled = true; + library.settings.agentControl.openUiOnPlay = false; + library.settings.automaticUpdateChecks = false; + writeFileSync(libraryPath, `${JSON.stringify(library)}\n`); + const mcpTools = await smokeMcp({cli, cwd: project, env, nodeArgs: ['--import', guard]}); + + let binWrapper = 'pending'; + if (process.platform !== 'win32') { + const wrapper = join(project, 'node_modules', '.bin', 'radiocli'); + assert.equal(runCommand(wrapper, ['version'], {cwd: project, env, timeout: 45_000}).stdout.trim(), metadata.version); + binWrapper = 'posix npm link'; + } else if (process.env.CI) { + // Fixed PowerShell code, with the generated path passed only through env. + const wrapper = join(project, 'node_modules', '.bin', 'radiocli.ps1'); + assert.ok(existsSync(wrapper), 'npm did not create its PowerShell bin shim.'); + const result = runCommand('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', '& $env:RADIOCLI_SMOKE_BIN version; exit $LASTEXITCODE'], { + cwd: project, env: {...env, RADIOCLI_SMOKE_BIN: wrapper}, timeout: 45_000 + }); + assert.equal(result.stdout.trim(), metadata.version); + binWrapper = 'PowerShell npm shim'; + } + let playback = 'not exercised: mpv unavailable'; + if (doctor.backends.includes('mpv')) { + const result = runCommand(process.execPath, ['--import', guard, script, '--exercise-playback', packageRoot, home], {cwd: project, env, timeout: 60_000}); + assert.match(result.stdout, /packed_playback=ok/); + process.stdout.write(result.stdout); + playback = 'mpv: local WAV, IPC readiness/volume/pause/resume/stop, ao=null; no audio hardware assertion'; + } else if (requireMpv) throw new Error('This job requires real mpv playback, but the installed CLI did not detect mpv.'); + assert.equal(existsSync(networkLog), false, 'A packed runtime fixture attempted a live network request.'); + const evidence = { + executionMethod, platform: process.platform, architecture: arch(), endianness: endianness(), kernel: release(), + node: process.version, npm: runCommand(npm.command, [...npm.args, '--version'], {cwd: project, env}).stdout.trim(), + package: `${metadata.name}@${metadata.version}`, install: omitOptional ? 'omit-optional' : 'normal', + tarballSha256: createHash('sha256').update(readFileSync(tarball)).digest('hex'), + host: doctor.host, binWrapper, offlineCache: 'passed', mcpTools, playback, + checks: ['help', 'version', 'doctor-json', 'offline-cache-hit-and-miss', 'add-url', 'import', 'export', 'MCP-stdio'], result: 'passed' + }; + if (evidencePath) { + mkdirSync(dirname(resolve(evidencePath)), {recursive: true}); + writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); + } + console.log(`packed_smoke=${JSON.stringify(evidence)}`); + return evidence; + } finally { + rmSync(temporary, {recursive: true, force: true}); + } +} + +function seedProviderCache(home) { + const entries = {}; + const add = (path, params, value) => { + const query = new URLSearchParams(Object.entries(params).sort(([a], [b]) => a.localeCompare(b))); + entries[`${path}?${query}`] = {createdAt: Date.now() - 2 * 24 * 60 * 60 * 1000, value}; + }; + add('/json/countries', {hidebroken: 'true'}, [{name: 'Smoke Islands', iso_3166_1: 'ZZ', stationcount: 2}]); + for (const field of ['name', 'tag', 'country', 'language']) { + add('/json/stations/search', {hidebroken: 'true', limit: '20', offset: '0', order: 'clickcount', reverse: 'true', [field]: 'packed-smoke'}, [ + {stationuuid: 'packed-smoke-station', name: 'Packed Smoke Radio', url_resolved: 'https://example.invalid/cached', tags: 'packed-smoke'} + ]); + } + writeFileSync(join(home, 'radiocli-cache.json'), `${JSON.stringify({version: 1, entries})}\n`); +} + +export async function smokeMcp({cli, cwd, env, nodeArgs = []}) { + const child = spawn(process.execPath, [...nodeArgs, cli, 'mcp', 'serve'], {cwd, env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe']}); + const closed = new Promise(resolveClosed => child.once('close', resolveClosed)); + try { + return await new Promise((resolveResult, reject) => { + let output = ''; + let stderr = ''; + let settled = false; + let toolCount = 0; + const finish = (error, value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + error ? reject(error) : resolveResult(value); + }; + const timeout = setTimeout(() => finish(new Error(`MCP smoke timed out. ${stderr}`)), 30_000); + const send = message => child.stdin.write(`${JSON.stringify({jsonrpc: '2.0', ...message})}\n`); + child.stderr.on('data', chunk => { stderr += String(chunk); }); + child.stdin.on('error', error => finish(error)); + child.once('error', error => finish(error)); + child.once('close', code => finish(new Error(`MCP server exited with ${code}. ${stderr}`))); + child.stdout.on('data', chunk => { + output += String(chunk); + let newline; + while ((newline = output.indexOf('\n')) >= 0 && !settled) { + const line = output.slice(0, newline).trim(); + output = output.slice(newline + 1); + if (!line) continue; + try { + const response = JSON.parse(line); + assert.equal(response.error, undefined, 'MCP returned a protocol error.'); + if (response.id === 1) { + assert.equal(response.result?.serverInfo?.name, 'radiocli'); + send({method: 'notifications/initialized'}); + send({id: 2, method: 'tools/list', params: {}}); + } else if (response.id === 2) { + const tools = response.result?.tools ?? []; + for (const name of ['radio_status', 'radio_search', 'radio_play', 'radio_stop', 'radio_alarm_list']) assert.ok(tools.some(tool => tool.name === name), `MCP tool missing: ${name}`); + toolCount = tools.length; + send({id: 3, method: 'tools/call', params: {name: 'radio_status', arguments: {}}}); + } else if (response.id === 3) { + assert.notEqual(response.result?.isError, true, 'MCP radio_status failed.'); + const status = JSON.parse(response.result?.content?.find(item => item.type === 'text')?.text ?? 'null'); + assert.equal(status?.connected, false, 'An isolated MCP smoke must not find a user session.'); + finish(null, toolCount); + } + } catch (error) { + finish(new Error(`${error instanceof SyntaxError ? 'MCP server wrote non-JSON stdout' : 'MCP smoke failed'}: ${error.message}`)); + } + } + }); + send({id: 1, method: 'initialize', params: {protocolVersion: '2025-11-25', capabilities: {}, clientInfo: {name: 'radiocli-packed-smoke', version: '1.0.0'}}}); + }); + } finally { + child.stdin.destroy(); + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + const force = setTimeout(() => child.kill('SIGKILL'), 1000); + force.unref(); + await closed; + clearTimeout(force); + } + } +} + +async function exercisePlayback(packageRoot, home) { + const {PlayerController} = await import(pathToFileURL(join(packageRoot, 'dist', 'player', 'player-controller.js')).href); + const {JsonLibraryStore} = await import(pathToFileURL(join(packageRoot, 'dist', 'storage', 'store.js')).href); + const {resolveCommand} = await import(pathToFileURL(join(packageRoot, 'dist', 'platform', 'executables.js')).href); + const mpv = resolveCommand('mpv'); + assert.ok(mpv, 'mpv executable is required.'); + console.log(runCommand(mpv, ['--version']).stdout.split('\n')[0]); + const store = new JsonLibraryStore(); + store.updateSettings({preferredBackend: 'mpv', volume: 0, automaticUpdateChecks: false}); + const player = new PlayerController(() => store.snapshot().settings); + const wav = join(home, 'local silence.wav'); + writeFileSync(wav, silentWav()); + try { + await player.play({id: 'packed-playback', provider: 'playlist', name: 'Packed playback', tags: ['smoke'], streamUrl: wav}, wav); + await delay(750); + assert.equal(player.getState().ready, true, 'mpv must acknowledge playback readiness over IPC.'); + assert.equal(player.getState().state, 'playing'); + assert.equal((await player.setVolume(31)).ok, true, 'mpv must acknowledge the volume command.'); + assert.equal(player.getState().volume, 31); + assert.equal((await player.pause()).ok, true); + assert.equal(player.getState().state, 'paused'); + assert.equal((await player.resume()).ok, true); + assert.equal(player.getState().state, 'playing'); + } finally { + await player.stop(); + } + assert.equal(player.diagnostics().active, false, 'The playback child must stop.'); + assert.equal(player.getState().state, 'stopped'); + console.log('packed_playback=ok source=local-wav audio-output=null controls=IPC-volume-pause-resume-stop'); +} + +function silentWav() { + const dataBytes = 10 * 44_100 * 2 * 2; + const buffer = Buffer.alloc(44 + dataBytes); + buffer.write('RIFF'); + buffer.writeUInt32LE(36 + dataBytes, 4); + buffer.write('WAVEfmt ', 8); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(2, 22); + buffer.writeUInt32LE(44_100, 24); + buffer.writeUInt32LE(44_100 * 4, 28); + buffer.writeUInt16LE(4, 32); + buffer.writeUInt16LE(16, 34); + buffer.write('data', 36); + buffer.writeUInt32LE(dataBytes, 40); + return buffer; +} + +async function main(args) { + if (args[0] === '--exercise-playback') return exercisePlayback(args[1], args[2]); + const options = {}; + let tarball; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--omit-optional') options.omitOptional = true; + else if (arg === '--require-mpv') options.requireMpv = true; + else if (arg === '--execution-method') options.executionMethod = args[++index]; + else if (arg === '--evidence') options.evidencePath = args[++index]; + else if (!arg.startsWith('-') && !tarball) tarball = arg; + else throw new Error(`Unexpected packed-smoke argument: ${arg}`); + } + if (!tarball) throw new Error('Usage: node scripts/packed-smoke.mjs [--omit-optional] [--require-mpv] [--execution-method ] [--evidence ]'); + await runPackedSmoke(tarball, options); +} + +if (process.argv[1] && resolve(process.argv[1]) === script) { + await main(process.argv.slice(2)).catch(error => { console.error(error.stack ?? error.message); process.exitCode = 1; }); +} diff --git a/scripts/packed-smoke.test-node.mjs b/scripts/packed-smoke.test-node.mjs new file mode 100644 index 0000000..3b04cf3 --- /dev/null +++ b/scripts/packed-smoke.test-node.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +// Run explicitly with node --test; this name avoids Vitest's test-file glob. +import {mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {test} from 'node:test'; + +async function helpers() { + const module = await import('./packed-smoke.mjs').catch(() => ({})); + assert.equal(typeof module.npmCommand, 'function', 'the packed smoke must provide a shell-free npm launcher'); + return module; +} + +function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), 'radiocli smoke & 日本 ')); + t.after(() => rmSync(directory, {recursive: true, force: true})); + return directory; +} + +test('npm runs its JavaScript entry directly and preserves shell-sensitive arguments', async t => { + const {npmCommand, runCommand} = await helpers(); + const directory = fixture(t); + const cli = join(directory, 'npm-cli.js'); + writeFileSync(cli, 'console.log(JSON.stringify(process.argv.slice(2)));\n'); + const args = ['install', join(directory, 'a & b; $(never-run).tgz'), '100% literal !']; + const invocation = npmCommand({env: {npm_execpath: cli}}); + assert.equal(invocation.command, process.execPath); + assert.equal(invocation.args[0], realpathSync(cli)); + const result = runCommand(invocation.command, [...invocation.args, ...args]); + assert.deepEqual(JSON.parse(result.stdout), args); +}); + +test('npm discovery resolves the Windows Node distribution layout without executing a cmd shim', async t => { + const {npmCommand} = await helpers(); + const directory = fixture(t); + const node = join(directory, 'node.exe'); + const cli = join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'); + mkdirSync(dirname(cli), {recursive: true}); + writeFileSync(node, ''); + writeFileSync(cli, ''); + assert.deepEqual(npmCommand({execPath: node, platform: 'win32', env: {}}), {command: node, args: [realpathSync(cli)]}); +}); + +test('npm discovery fails clearly when only an unusable shim is available', async t => { + const {npmCommand} = await helpers(); + const directory = fixture(t); + writeFileSync(join(directory, 'npm.cmd'), '@echo off\r\n'); + assert.throws(() => npmCommand({execPath: join(directory, 'node.exe'), platform: 'win32', env: {PATH: directory}}), /npm-cli\.js/); +}); + +test('command failures include stderr and retain a nonzero failure', async () => { + const {runCommand} = await helpers(); + assert.throws(() => runCommand(process.execPath, ['-e', 'console.error("fixture failure"); process.exit(7)']), /fixture failure/); +}); + +test('a stalled child cannot turn a smoke test into an unbounded job', async () => { + const {runCommand} = await helpers(); + assert.throws(() => runCommand(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {timeout: 100}), /timed out|ETIMEDOUT/); +}); + +test('MCP smoke rejects non-JSON stdout from the actual child process', async t => { + const {smokeMcp} = await helpers(); + const directory = fixture(t); + const cli = join(directory, 'broken-mcp.cjs'); + writeFileSync(cli, 'console.log("unexpected banner"); setInterval(() => {}, 1000);\n'); + await assert.rejects(smokeMcp({cli, cwd: directory, env: process.env}), /non-JSON stdout/); +}); + +test('MCP smoke reports an early server exit instead of waiting for its deadline', async t => { + const {smokeMcp} = await helpers(); + const directory = fixture(t); + const cli = join(directory, 'closed-mcp.cjs'); + writeFileSync(cli, 'console.error("fixture server exited"); process.exit(3);\n'); + await assert.rejects(smokeMcp({cli, cwd: directory, env: process.env}), /fixture server exited|exited with 3/); +}); diff --git a/src/agent/headless-host.test.ts b/src/agent/headless-host.test.ts new file mode 100644 index 0000000..20c1210 --- /dev/null +++ b/src/agent/headless-host.test.ts @@ -0,0 +1,234 @@ +import {chmodSync, existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {PlayerController} from '../player/player-controller.js'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import type {PlaybackState, Station} from '../types.js'; +import {runHeadlessAgentHost} from './headless-host.js'; +import {connectRadioSession, type RadioSessionClient, type RadioSessionCommand} from './session.js'; + +const first: Station = {id: 'first', provider: 'playlist', name: 'First Radio', tags: [], streamUrl: 'https://radio.example/first'}; +const second: Station = {...first, id: 'second', name: 'Second Radio', streamUrl: 'https://radio.example/second'}; +const historyMethods = ['addRecent', 'startListeningSession', 'finishActiveListeningSession', 'checkpointActiveListeningSession'] as const; +const signalNames = ['SIGTERM', 'SIGINT'] as const; +let root: string; +let playback: PlaybackState; +let originalListeners: Map>; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'radiocli headless 音楽-')); + vi.stubEnv('RADIOCLI_HOME', root); + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']}); + originalListeners = new Map(signalNames.map(signal => [signal, new Set(process.listeners(signal))])); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.spyOn(process, 'kill').mockReturnValue(true); + playback = {backend: 'mpv', state: 'idle', volume: 70, muted: false, ready: false}; + vi.spyOn(PlayerController.prototype, 'refreshDetectedBackends').mockReturnValue(['mpv']); + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...playback})); + vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (station, url) => { + playback = {...playback, state: 'playing', stationName: station.name, streamUrl: url, ready: true}; + }); + vi.spyOn(PlayerController.prototype, 'stop').mockImplementation(async () => { + playback = {backend: 'none', state: 'stopped', volume: playback.volume, muted: false, ready: false}; + }); + vi.spyOn(PlayerController.prototype, 'pause').mockImplementation(async () => { + playback = {...playback, state: 'paused'}; + return {ok: true}; + }); + vi.spyOn(PlayerController.prototype, 'resume').mockImplementation(async () => { + playback = {...playback, state: 'playing'}; + return {ok: true}; + }); + vi.spyOn(PlayerController.prototype, 'setVolume').mockImplementation(async volume => { + playback = {...playback, volume}; + return {ok: true}; + }); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async station => ({url: station.streamUrl!, station})); + vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(true); +}); + +afterEach(async () => { + // Restore storage access before cleanup so a failing regression cannot leak a + // real loopback listener. Player and process mocks stay in place until closed. + for (const method of [...historyMethods, 'updateSettings', 'toggleFavorite'] as const) { + if (vi.isMockFunction(JsonLibraryStore.prototype[method])) vi.mocked(JsonLibraryStore.prototype[method]).mockRestore(); + } + const libraryPath = join(root, 'radiocli.json'); + if (existsSync(libraryPath)) chmodSync(libraryPath, 0o600); + const shutdown = hostSignalHandler('SIGTERM'); + if (shutdown) { + shutdown('SIGTERM'); + await vi.waitFor(() => expect(existsSync(join(root, 'runtime', 'agent-session.json'))).toBe(false)); + } + for (const signal of signalNames) { + for (const listener of process.listeners(signal)) { + if (!originalListeners.get(signal)?.has(listener)) process.removeListener(signal, listener); + } + } + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + rmSync(root, {recursive: true, force: true}); +}); + +function hostSignalHandler(signal: NodeJS.Signals): NodeJS.SignalsListener | undefined { + return process.listeners(signal).find(listener => !originalListeners.get(signal)?.has(listener)); +} + +async function startHost(): Promise<{client: RadioSessionClient; libraryPath: string; sessionPath: string}> { + new JsonLibraryStore().updateSettings({shareDirectoryVotes: false}); + let startupError: unknown; + void runHeadlessAgentHost().catch(error => {startupError = error;}); + await vi.waitFor(() => { + if (startupError) throw startupError; + expect(hostSignalHandler('SIGTERM')).toBeDefined(); + }); + const sessionPath = join(root, 'runtime', 'agent-session.json'); + const client = await connectRadioSession(sessionPath); + expect(client).not.toBeNull(); + return {client: client!, libraryPath: join(root, 'radiocli.json'), sessionPath}; +} + +function storageFailure(code = 'ENOSPC'): Error { + return Object.assign(new Error(`${code}: write failed at ${join(root, 'private library.json')}\u001b[31m`), {code}); +} + +function failHistory(method: typeof historyMethods[number], code = 'ENOSPC'): void { + vi.spyOn(JsonLibraryStore.prototype, method).mockImplementation(() => {throw storageFailure(code);}); +} + +describe('headless host optional persistence', () => { + it('stops through the authenticated session after its real library becomes read-only', async context => { + if (process.platform === 'win32') context.skip('POSIX file permissions are not enforced by Windows ACLs.'); + if (process.getuid?.() === 0) context.skip('Root bypasses POSIX file write permissions.'); + const {client, libraryPath, sessionPath} = await startHost(); + await client.call({type: 'play', station: first}); + const saved = readFileSync(libraryPath, 'utf8'); + chmodSync(libraryPath, 0o400); + + const stopped = await client.call({type: 'stop'}); + + expect(stopped).toMatchObject({ok: true, message: 'Stopped RadioCLI.', status: { + station: null, queue: [], playback: {state: 'stopped'}, persistenceWarning: expect.stringContaining('RADIOCLI_HOME') + }}); + expect(stopped.status.persistenceWarning).toContain('EACCES'); + expect(stopped.status.persistenceWarning).not.toContain(root); + expect(readFileSync(libraryPath, 'utf8')).toBe(saved); + expect(await client.status()).toMatchObject({playback: {state: 'stopped'}}); + vi.advanceTimersByTime(50); + expect(process.kill).toHaveBeenCalledWith(process.pid, 'SIGTERM'); + hostSignalHandler('SIGTERM')!('SIGTERM'); + await vi.waitFor(() => expect(existsSync(sessionPath)).toBe(false)); + expect(existsSync(`${sessionPath}.owner`)).toBe(false); + }); + + it.each(['addRecent', 'startListeningSession'] as const)('keeps a successful tune when %s fails', async method => { + const {client} = await startHost(); + failHistory(method); + + const played = await client.call({type: 'play', station: first}); + + expect(played).toMatchObject({ok: true, message: 'Playing First Radio.', status: { + station: first, playback: {state: 'playing', ready: true}, persistenceWarning: expect.stringContaining('ENOSPC') + }}); + expect(played.status.persistenceWarning).toContain('RADIOCLI_HOME'); + expect(played.status.persistenceWarning).not.toContain(root); + expect(played.status.persistenceWarning).not.toContain('\u001b'); + }); + + it('retunes the queue when finishing the previous history entry fails', async () => { + const {client} = await startHost(); + await client.call({type: 'play', station: first, queue: [first, second]}); + failHistory('finishActiveListeningSession'); + + const next = await client.call({type: 'next'}); + + expect(next).toMatchObject({ok: true, status: {station: second, playback: {state: 'playing'}, persistenceWarning: expect.any(String)}}); + }); + + it('yields playback to an alarm when finishing history fails', async () => { + const {client} = await startHost(); + await client.call({type: 'play', station: first}); + failHistory('finishActiveListeningSession'); + + const yielded = await client.call({type: 'alarm-preempt'}); + + expect(yielded).toMatchObject({ok: true, status: {station: null, queue: [], playback: {state: 'stopped'}, persistenceWarning: expect.any(String)}}); + expect(await client.status()).toMatchObject({station: null, playback: {state: 'stopped'}}); + expect(process.kill).not.toHaveBeenCalledWith(process.pid, 'SIGTERM'); + }); + + it.each(['pause', 'resume'] as const)('keeps acknowledged %s controls successful after a history failure', async type => { + const {client} = await startHost(); + await client.call({type: 'play', station: first}); + if (type === 'resume') await client.call({type: 'pause'}); + failHistory(type === 'pause' ? 'finishActiveListeningSession' : 'startListeningSession'); + + const controlled = await client.call({type}); + + expect(controlled).toMatchObject({ok: true, status: { + playback: {state: type === 'pause' ? 'paused' : 'playing'}, persistenceWarning: expect.any(String) + }}); + }); + + it('contains heartbeat write failures and keeps the authenticated owner responsive', async () => { + const {client} = await startHost(); + await client.call({type: 'play', station: first}); + failHistory('checkpointActiveListeningSession'); + + expect(() => vi.advanceTimersByTime(30_000)).not.toThrow(); + + expect(await client.status()).toMatchObject({station: first, playback: {state: 'playing'}, persistenceWarning: expect.stringContaining('ENOSPC')}); + }); + + it.each(signalNames)('stops playback and removes session ownership on %s despite a history failure', async signal => { + const {client, sessionPath} = await startHost(); + await client.call({type: 'play', station: first}); + failHistory('finishActiveListeningSession'); + + hostSignalHandler(signal)!(signal); + + await vi.waitFor(() => expect(existsSync(sessionPath)).toBe(false)); + expect(existsSync(`${sessionPath}.owner`)).toBe(false); + expect(playback.state).toBe('stopped'); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('retains the warning through optional saves and clears it after an explicit save succeeds', async () => { + const {client} = await startHost(); + failHistory('addRecent'); + await client.call({type: 'play', station: first}); + vi.advanceTimersByTime(30_000); + expect((await client.status()).persistenceWarning).toContain('RADIOCLI_HOME'); + + const saved = await client.call({type: 'update-settings', settings: {theme: 'amber'}}); + + expect(saved.ok).toBe(true); + expect(saved.status.persistenceWarning).toBeUndefined(); + expect(new JsonLibraryStore().snapshot().settings.theme).toBe('amber'); + }); + + const explicitCommands: RadioSessionCommand[] = [ + {type: 'update-settings', settings: {theme: 'amber'}}, + {type: 'set-volume', volume: 42}, + {type: 'set-favorite', favorite: true} + ]; + it.each(explicitCommands)('does not claim success for a failed explicit $type save', async command => { + const {client, libraryPath} = await startHost(); + await client.call({type: 'play', station: first}); + const saved = readFileSync(libraryPath, 'utf8'); + const method = command.type === 'set-favorite' ? 'toggleFavorite' : 'updateSettings'; + vi.spyOn(JsonLibraryStore.prototype, method).mockImplementation(() => { + throw new Error('Unable to write the RadioCLI library (EACCES). Set RADIOCLI_HOME to a private writable directory.'); + }); + + await expect(client.call(command)).rejects.toThrow('RADIOCLI_HOME'); + + expect(readFileSync(libraryPath, 'utf8')).toBe(saved); + expect(new JsonLibraryStore().snapshot()).toMatchObject({favorites: [], settings: {volume: 70, theme: 'green'}}); + expect(ProviderManager.prototype.vote).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agent/headless-host.ts b/src/agent/headless-host.ts index bfcdb9e..e1bf25b 100644 --- a/src/agent/headless-host.ts +++ b/src/agent/headless-host.ts @@ -3,7 +3,7 @@ import {ProviderManager} from '../providers/provider-manager.js'; import {JsonLibraryStore, stationKey} from '../storage/store.js'; import type {Station} from '../types.js'; import {startRadioSession, type RadioSessionCommand, type RadioSessionResult, type RadioSessionStatus} from './session.js'; -import {detectPlaybackBackends} from '../player/backend-install.js'; +import {airPlayMacOSOnlyMessage, detectPlaybackBackends, isAirPlayPlatformSupported} from '../player/backend-install.js'; export async function runHeadlessAgentHost(): Promise { const store = new JsonLibraryStore(); @@ -12,25 +12,45 @@ export async function runHeadlessAgentHost(): Promise { player.refreshDetectedBackends(); let station: Station | null = null; let queue: Station[] = []; + let persistenceWarning: string | undefined; - const status = (): RadioSessionStatus => ({owner: 'headless', playback: player.getState(), station, queue, output: { + const persistHistory = (write: () => unknown): void => { + try { + write(); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : ''; + persistenceWarning = `Listening history was not saved${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`; + } + }; + const saveLibrary = (write: () => unknown): void => { + write(); + // Optional writes can be no-ops, so only an explicit successful save clears + // a prior warning. Explicit failures still reject the control request. + persistenceWarning = undefined; + }; + + const status = (): RadioSessionStatus => ({owner: 'headless', playback: player.getState(), station, queue, persistenceWarning, output: { preferredBackend: store.snapshot().settings.preferredBackend, preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice }}); const result = (message: string, ok = true, data?: RadioSessionResult['data']): RadioSessionResult => ({ok, message, status: status(), ...(data ? {data} : {})}); const play = async (next: Station, nextQueue: Station[] = [next]): Promise => { - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); const resolved = await providers.resolve(next); await player.play(next, resolved.url); station = next; queue = nextQueue.length ? nextQueue : [next]; - store.addRecent(next); - store.startListeningSession(next); + persistHistory(() => store.addRecent(next)); + persistHistory(() => store.startListeningSession(next)); return result(`Playing ${next.name}.`); }; const handle = async (command: RadioSessionCommand): Promise => { + if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !isAirPlayPlatformSupported()) { + return result(airPlayMacOSOnlyMessage, false, command.type === 'airplay-list' ? [] : undefined); + } if (command.type === 'status') return result(station ? `${player.getState().state}: ${station.name}` : 'RadioCLI is idle.'); if (command.type === 'play') { if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state)) return result(`Kept current station ${station?.name ?? ''}.`); @@ -38,16 +58,16 @@ export async function runHeadlessAgentHost(): Promise { } if (command.type === 'pause') { const control = await player.pause(); - if (control.ok) store.finishActiveListeningSession(); + if (control.ok) persistHistory(() => store.finishActiveListeningSession()); return result(control.message ?? 'Paused.', control.ok); } if (command.type === 'resume') { const control = await player.resume(); - if (control.ok && station) store.startListeningSession(station); + if (control.ok && station) persistHistory(() => store.startListeningSession(station!)); return result(control.message ?? 'Resumed.', control.ok); } if (command.type === 'stop') { - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); station = null; queue = []; @@ -55,7 +75,7 @@ export async function runHeadlessAgentHost(): Promise { return result('Stopped RadioCLI.'); } if (command.type === 'alarm-preempt') { - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); station = null; queue = []; @@ -63,7 +83,7 @@ export async function runHeadlessAgentHost(): Promise { } if (command.type === 'set-volume') { const control = await player.setVolume(command.volume); - if (control.ok) store.updateSettings({volume: player.getState().volume}); + if (control.ok) saveLibrary(() => store.updateSettings({volume: player.getState().volume})); return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok); } if (command.type === 'set-muted') { const control = await player.setMuted(command.muted); return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok); } @@ -71,7 +91,7 @@ export async function runHeadlessAgentHost(): Promise { const target = command.station ?? station; if (!target) return result('No active station to favorite.', false); const current = store.isFavorite(target); - if (current !== command.favorite) store.toggleFavorite(target); + if (current !== command.favorite) saveLibrary(() => store.toggleFavorite(target)); if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes) void providers.vote(target); return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`); } @@ -85,8 +105,10 @@ export async function runHeadlessAgentHost(): Promise { if (!device) return result('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices); if (device.local) return switchToLocal(); if (!detectPlaybackBackends().includes('airplay')) return result('AirPlay playback is unavailable; run radiocli doctor.', false); - store.updateSettings({preferredAirPlayDevice: device.id}); - store.updateSettings({preferredBackend: 'airplay'}); + saveLibrary(() => { + store.updateSettings({preferredAirPlayDevice: device.id}); + store.updateSettings({preferredBackend: 'airplay'}); + }); if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue); return result(`Audio output set to AirPlay receiver ${device.name}.`); } @@ -96,7 +118,7 @@ export async function runHeadlessAgentHost(): Promise { return result(control.message ?? 'AirPlay code sent.', control.ok); } if (command.type === 'update-settings') { - store.updateSettings(command.settings); + saveLibrary(() => store.updateSettings(command.settings)); return result('RadioCLI settings updated.'); } const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station!)) : -1; @@ -109,18 +131,18 @@ export async function runHeadlessAgentHost(): Promise { const backends = detectPlaybackBackends(); const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null; if (!backend) return result('No local playback backend is available. Run radiocli setup to install mpv.', false); - store.updateSettings({preferredBackend: backend}); + saveLibrary(() => store.updateSettings({preferredBackend: backend})); if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue); return result(`Audio output set to this device (${backend}).`); }; const session = await startRadioSession(handle); const checkpoint = setInterval(() => { - if (player.getState().state === 'playing') store.checkpointActiveListeningSession(); + if (player.getState().state === 'playing') persistHistory(() => store.checkpointActiveListeningSession()); }, 30_000); const shutdown = async () => { clearInterval(checkpoint); - store.finishActiveListeningSession(); + persistHistory(() => store.finishActiveListeningSession()); await player.stop(); await session.close(); }; diff --git a/src/agent/launcher.test.ts b/src/agent/launcher.test.ts new file mode 100644 index 0000000..61981d2 --- /dev/null +++ b/src/agent/launcher.test.ts @@ -0,0 +1,45 @@ +import {EventEmitter} from 'node:events'; +import {spawn as nodeSpawn,type ChildProcess,type SpawnOptions} from 'node:child_process'; +import {homedir} from 'node:os'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import {launchRadioTui} from './launcher.js'; + +vi.mock('node:child_process',async importOriginal=>({...await importOriginal(),spawn:vi.fn()})); +const calls:Array<{command:string;args:readonly string[];options?:SpawnOptions}>=[]; +const spawn=vi.fn((command:string,args:readonly string[],options?:SpawnOptions)=>{calls.push({command,args,options});const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',0);});return child;}); +beforeEach(()=>{calls.length=0;spawn.mockClear();vi.mocked(nodeSpawn).mockImplementation(spawn as unknown as typeof nodeSpawn);}); +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();}); +function nodeInvocation(args: readonly string[], environment: NodeJS.ProcessEnv = {}): {command: string; args: string[]} { + const encodedScript = args[args.indexOf('-EncodedCommand') + 1]; + const script = Buffer.from(encodedScript!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + const key = /GetEnvironmentVariable\('([^']+)'/.exec(script)?.[1]; + const source = encoded ? Buffer.from(encoded, 'base64').toString('utf8') : environment[key!]; + expect(source).toBeDefined(); + const invocation = JSON.parse(source!) as {command: string; args: string[]}; + return invocation.args.includes('-EncodedCommand') ? nodeInvocation(invocation.args) : invocation; +} + +describe('agent graphical launcher',()=>{ + it.each(['win32:console','win32:windows-terminal'])('encodes hostile paths and the data home without cmd interpolation in %s',async terminal=>{ + const home='C:\\Data %PATH%! & "quoted" \' 单播';const nodePath='C:\\Node %TEMP%! & 单播\\node.exe';const cliPath='C:\\Radio %PATH%! & 单播\\cli.js'; + await launchRadioTui(nodePath,cliPath,'encoded-agent-command',{platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:terminal,RADIOCLI_HOME:home},spawn,resolve:command=>command}); + const call=calls[0]!;expect(call.command).not.toBe('cmd.exe');const encoded=call.args[call.args.indexOf('-EncodedCommand')+1];expect(call.args).toContain('-EncodedCommand'); + const script=Buffer.from(encoded!,'base64').toString('utf16le'); + const invocation=nodeInvocation(call.args,call.options?.env);expect(invocation.command).toBe(nodePath); + const payload=JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')); + expect(payload).toEqual({args:[cliPath,'agent-ui','encoded-agent-command'],environment:{RADIOCLI_HOME:home,...(process.platform==='win32'?{USERPROFILE:homedir()}:{})}});expect(script).not.toContain(home);expect(script).not.toContain(cliPath); + }); + + it('surfaces an immediate native launcher failure after spawn',async()=>{ + const launch=vi.fn(()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',1);});return child;});vi.mocked(nodeSpawn).mockImplementation(launch as typeof nodeSpawn); + await expect(launchRadioTui('/node','/cli.js','encoded',{platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal'},spawn:launch,resolve:command=>command})).rejects.toThrow(/terminal.*exit|launcher.*exit/i); + }); + + it('does not launch a graphical terminal in a headless Unix session',async()=>{await expect(launchRadioTui('/node','/cli.js','encoded',{platform:'linux',env:{RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},spawn,resolve:command=>command})).rejects.toThrow(/graphical|display|headless/i);expect(spawn).not.toHaveBeenCalled();}); + it.each(['freebsd','openbsd','netbsd'] as const)('requests the same agent session command in a supported %s desktop',async platform=>{ + await launchRadioTui('/node','/cli.js','encoded',{platform,env:{DISPLAY:':0',TERMINAL:'xterm'},spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined}); + expect(calls[0]!.command).toBe('/usr/local/bin/xterm');expect(calls[0]!.args.slice(0,3)).toEqual(['-e','/node','-e']); + expect(JSON.parse(Buffer.from(calls[0]!.args.at(-1)!,'base64url').toString('utf8'))).toEqual({args:['/cli.js','agent-ui','encoded'],environment:{}}); + }); +}); diff --git a/src/agent/launcher.ts b/src/agent/launcher.ts index 91ba156..9d02026 100644 --- a/src/agent/launcher.ts +++ b/src/agent/launcher.ts @@ -1,69 +1,12 @@ -import {spawn, type ChildProcess} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {basename, join} from 'node:path'; -import {detectAlarmTerminal} from '../alarms/terminal-launcher.js'; +import {spawn} from 'node:child_process'; +import {launchTerminalCommand, type TerminalOptions} from '../platform/terminals.js'; +import {waitForLaunch} from '../platform/launch-command.js'; -const linuxTerminals = new Set(['alacritty', 'foot', 'ghostty', 'gnome-terminal', 'kitty', 'konsole', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'wezterm', 'xfce4-terminal', 'x-terminal-emulator']); - -export async function launchRadioTui(nodePath: string, cliPath: string, encodedCommand: string): Promise { - const terminal = detectAlarmTerminal(); - const direct = environmentCommand(nodePath, cliPath, ['agent-ui', encodedCommand]); - const command = `${direct.map(shellQuote).join(' ')}`; - if (terminal === 'darwin:apple-terminal') await launched(spawnDetached('/usr/bin/osascript', appleTerminalScript(command))); - else if (terminal === 'darwin:iterm') await launched(spawnDetached('/usr/bin/osascript', iTermScript(command))); - else if (terminal === 'darwin:wezterm') await launched(spawnDetached('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct])); - else if (terminal === 'darwin:ghostty') await launched(spawnDetached('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...direct])); - else if (terminal === 'darwin:kitty') await launched(spawnDetached('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...direct])); - else if (terminal === 'win32:windows-terminal') await launched(spawnDetached('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI', ...direct])); - else if (terminal === 'win32:console') await launched(spawnDetached('cmd.exe', ['/d', '/c', 'start', 'RadioCLI', 'cmd.exe', '/k', windowsCommand(direct)])); - else if (terminal.startsWith('linux:')) { - const executable = terminal.slice('linux:'.length); - if (!linuxTerminals.has(basename(executable))) throw new Error('Saved Linux terminal is not supported.'); - const name = basename(executable); - const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' - ? ['--'] - : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e']; - await launched(spawnDetached(executable, [...prefix, ...direct])); - } else { - throw new Error('No supported graphical terminal was found. Set agentControl.openUiOnPlay to false or open radiocli manually.'); - } +/** The agent service verifies the authenticated session after this request. */ +export async function launchRadioTui(nodePath: string, cliPath: string, encodedCommand: string, options: TerminalOptions = {}): Promise { + await launchTerminalCommand({...options, nodePath, args: [cliPath, 'agent-ui', encodedCommand], title: 'RadioCLI'}); } export async function launchHeadlessHost(nodePath: string, cliPath: string): Promise { - await launched(spawnDetached(nodePath, [cliPath, 'agent-host'])); -} - -function environmentCommand(nodePath: string, cliPath: string, args: string[]): string[] { - const command = [nodePath, cliPath, ...args]; - const radioCliHome = process.env.RADIOCLI_HOME; - if (!radioCliHome) return command; - if (process.platform === 'win32') { - return ['cmd.exe', '/d', '/c', `set "RADIOCLI_HOME=${cmdEscape(radioCliHome)}" && ${windowsCommand(command)}`]; - } - return ['/usr/bin/env', `RADIOCLI_HOME=${radioCliHome}`, ...command]; -} - -function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'`; } -function windowsCommand(values: string[]): string { return values.map(value => `"${value.replaceAll('"', '""')}"`).join(' '); } -function cmdEscape(value: string): string { return value.replaceAll('%', '%%').replaceAll('"', '""').replaceAll('^', '^^').replaceAll('&', '^&').replaceAll('|', '^|').replaceAll('<', '^<').replaceAll('>', '^>'); } -function appleTerminalScript(command: string): string[] { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; } -function iTermScript(command: string): string[] { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; } -function spawnDetached(command: string, args: readonly string[]): ChildProcess { return spawn(command, [...args], {detached: true, stdio: 'ignore', windowsHide: false}); } -function launched(child: ChildProcess): Promise { return new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', () => { child.unref(); resolve(); }); }); } - -export function resolveExecutable( - input: string, - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform -): string | undefined { - if ((input.includes('/') || input.includes('\\')) && existsSync(input)) return input; - const suffixes = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; - const pathDelimiter = platform === 'win32' ? ';' : ':'; - for (const directory of (env.PATH ?? '').split(pathDelimiter)) { - for (const suffix of suffixes) { - const path = join(directory, `${input}${suffix}`); - if (existsSync(path)) return path; - } - } - return undefined; + await waitForLaunch(spawn(nodePath, [cliPath, 'agent-host'], {detached: true, stdio: 'ignore', windowsHide: false})); } diff --git a/src/agent/mcp-install.ts b/src/agent/mcp-install.ts index 6ea248c..b427a55 100644 --- a/src/agent/mcp-install.ts +++ b/src/agent/mcp-install.ts @@ -6,7 +6,7 @@ import {applyEdits, modify, parse, type FormattingOptions} from 'jsonc-parser'; import type {Writable} from 'node:stream'; import {JsonLibraryStore} from '../storage/store.js'; import {defaultAgentControlSettings} from '../types.js'; -import {resolveExecutable} from './launcher.js'; +import {resolveCommandDetails} from '../platform/executables.js'; import type {AgentRuntime} from './service.js'; export type McpInstallResult = {client: string; status: 'configured' | 'removed' | 'not-found' | 'failed' | 'inherited'; detail: string}; @@ -41,7 +41,7 @@ export async function configureMcpIntegrations( : ['mcp', 'remove', 'radiocli'], ['mcp', 'remove', 'radiocli'])); } else results.push(notFound('Codex')); - const claude = resolveExecutable('claude'); + const claude = resolveCommandDetails('claude', {env: process.env}).path; if (claude) { results.push(await configureCliClient('Claude Code', claude, enabled ? ['mcp', 'add', '--scope', 'user', 'radiocli', '--', ...command] @@ -49,7 +49,7 @@ export async function configureMcpIntegrations( } else results.push(notFound('Claude Code')); const openCodePath = openCodeConfigPath(); - if (resolveExecutable('opencode') || existsSync(openCodePath)) { + if (resolveCommandDetails('opencode', {env: process.env}).path || existsSync(openCodePath)) { try { updateOpenCodeConfig(openCodePath, enabled, command); results.push({client: 'OpenCode', status: enabled ? 'configured' : 'removed', detail: openCodePath}); @@ -61,14 +61,14 @@ export async function configureMcpIntegrations( configureJsonClient(results, 'Cursor', cursorDetected(), join(homedir(), '.cursor', 'mcp.json'), ['mcpServers', 'radiocli'], enabled, { command: command[0], args: command.slice(1) }); - configureJsonClient(results, 'Gemini CLI', Boolean(resolveExecutable('gemini')), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, { + configureJsonClient(results, 'Gemini CLI', Boolean(resolveCommandDetails('gemini', {env: process.env}).path), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, { command: command[0], args: command.slice(1) }); configureJsonClient(results, 'VS Code / Copilot Agent Host', vsCodeDetected(), join(homedir(), '.copilot', 'mcp-config.json'), ['servers', 'radiocli'], enabled, { type: 'stdio', command: command[0], args: command.slice(1) }); - if (resolveExecutable('orca')) { + if (resolveCommandDetails('orca', {env: process.env}).path) { results.push({client: 'Orca', status: 'inherited', detail: 'Orca exposes MCP integrations through its configured Codex/Claude agent runtimes.'}); } else results.push(notFound('Orca')); @@ -195,7 +195,7 @@ export function probeMcpServer(command: string[], timeoutMs = 5_000): Promise<{o } function matchingRadioCliShim(runtime: AgentRuntime, platform: NodeJS.Platform): string | undefined { - const candidate = resolveExecutable('radiocli', process.env, platform); + const candidate = resolveCommandDetails('radiocli', {env: process.env, platform}).path; if (!candidate) return undefined; if (!usableDirectLauncher(candidate, platform)) return undefined; try { @@ -216,16 +216,16 @@ async function mcpClientStates(command: string[]): Promise { +async function claudeClientState(executable: string | null | undefined, expected: string[]): Promise { if (!executable) return managedClientState(false); const result = await run(executable, ['mcp', 'get', 'radiocli']); const detail = `${result.stdout}\n${result.stderr}`.trim(); diff --git a/src/agent/service.ts b/src/agent/service.ts index e198dcd..5e06eea 100644 --- a/src/agent/service.ts +++ b/src/agent/service.ts @@ -8,7 +8,7 @@ import {connectActiveAlarms, type ActiveAlarmStatus} from '../alarms/active-sess import {connectRadioSession, ensureRadioSession, type RadioSessionCommand, type RadioSessionResult} from './session.js'; import {launchHeadlessHost, launchRadioTui} from './launcher.js'; import {PlayerController} from '../player/player-controller.js'; -import {detectPlaybackBackends} from '../player/backend-install.js'; +import {airPlayMacOSOnlyMessage, detectPlaybackBackends, isAirPlayPlatformSupported} from '../player/backend-install.js'; import {AgentAlarmService, type AgentAlarmCreate, type AgentAlarmUpdate} from './alarm-service.js'; import {appVersion} from '../version.js'; import {checkForUpdate, updateCommandForInstall} from '../update-check.js'; @@ -321,7 +321,7 @@ export class AgentRadioService { } private assertAirPlayPlatform(): void { - if (process.platform !== 'darwin') throw new Error('AirPlay control is available only on macOS.'); + if (!isAirPlayPlatformSupported()) throw new Error(airPlayMacOSOnlyMessage); } } diff --git a/src/agent/session.test.ts b/src/agent/session.test.ts index bd99347..18b1b6d 100644 --- a/src/agent/session.test.ts +++ b/src/agent/session.test.ts @@ -1,4 +1,6 @@ -import {mkdtempSync, rmSync} from 'node:fs'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {createServer} from 'node:http'; +import {spawn} from 'node:child_process'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {afterEach, describe, expect, it} from 'vitest'; @@ -54,4 +56,116 @@ describe('agent radio session', () => { await first.close(); } }); + + for (const host of ['127.0.0.1', '::1']) { + it(`authenticates directly to a discovered ${host} endpoint`, async context => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-family-')); + roots.push(root); + const path = join(root, 'session.json'); + let authenticatedRequests = 0; + const server = createServer((request, response) => { + if (request.headers.authorization === 'Bearer test-session-token') authenticatedRequests += 1; + request.resume(); + response.end(JSON.stringify({ok: true, message: 'ok', status: idle})); + }); + try { + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, host, resolve);}); + } catch (error) { + if (host === '::1' && ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip('The host does not provide IPv6 loopback.'); + } + throw error; + } + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + writeFileSync(path, JSON.stringify({version: 1, host, port: address.port, token: 'test-session-token', pid: process.pid, createdAt: new Date().toISOString()})); + const client = await connectRadioSession(path); + expect(client).not.toBeNull(); + expect(await client!.status()).toEqual(idle); + expect(authenticatedRequests).toBe(2); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + } + + it('rejects a discovery hostname without contacting the advertised port', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-host-')); + roots.push(root); + const path = join(root, 'session.json'); + let requests = 0; + const server = createServer((_request, response) => {requests += 1;response.end('{}');}); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + writeFileSync(path, JSON.stringify({version: 1, host: 'localhost', port: address.port, token: 'test-session-token', pid: process.pid, createdAt: new Date().toISOString()})); + expect(await connectRadioSession(path)).toBeNull(); + expect(requests).toBe(0); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } + }); + + it.skipIf(!process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))( + 'keeps radio and alarm control tokens away from environment proxies in a fresh Node process', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-control-proxy-')); + roots.push(root); + let proxyRequests = 0; + let proxySawAuthorization = false; + const proxy = createServer((request, response) => { + proxyRequests += 1; + proxySawAuthorization ||= Boolean(request.headers.authorization); + request.resume(); + response.writeHead(401); + response.end('{}'); + }); + await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve)); + try { + const address = proxy.address(); + if (!address || typeof address === 'string') throw new Error('No proxy address.'); + const env = {...process.env}; + for (const key of Object.keys(env)) if (/^(?:https?|all|no)_proxy$/i.test(key) || key === 'NODE_OPTIONS') delete env[key]; + env.NODE_USE_ENV_PROXY = '1'; + env.HTTP_PROXY = `http://127.0.0.1:${address.port}`; + env.NO_PROXY = ''; + const source = ` + import {join} from 'node:path'; + import {startRadioSession, connectRadioSession} from ${JSON.stringify(new URL('./session.ts', import.meta.url).href)}; + import {startActiveAlarmSession, connectActiveAlarm} from ${JSON.stringify(new URL('../alarms/active-session.ts', import.meta.url).href)}; + const root = process.argv[1]; + const idle = ${JSON.stringify(idle)}; + const radioPath = join(root, 'radio.json'); + const alarmPath = join(root, 'alarm.json'); + const radio = await startRadioSession(async () => ({ok: true, message: 'ok', status: idle}), radioPath); + const alarm = await startActiveAlarmSession({alarmId: 'test', scheduledAt: '2030-01-01T00:00:00Z', stationName: 'Local alarm', startedAt: '2030-01-01T00:00:00Z'}, {filePath: alarmPath, onDismiss() {}, onSnooze() {}, onKeepPlaying() {}}); + try { + const radioClient = await connectRadioSession(radioPath); + const alarmClient = await connectActiveAlarm(alarmPath); + console.log(JSON.stringify({radio: Boolean(radioClient), alarm: Boolean(alarmClient)})); + } finally { + await alarm.close(); + await radio.close(); + } + `; + const output = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', source, root], {env, timeout: 8000}); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => {stdout += String(chunk);}); + child.stderr.on('data', chunk => {stderr += String(chunk);}); + child.once('error', reject); + child.once('close', code => code === 0 ? resolve(stdout) : reject(new Error(`Control probe exited ${code}: ${stderr}`))); + }); + expect(proxyRequests).toBe(0); + expect(proxySawAuthorization).toBe(false); + expect(JSON.parse(output)).toEqual({radio: true, alarm: true}); + } finally { + proxy.closeAllConnections(); + await new Promise(resolve => proxy.close(() => resolve())); + } + } + ); }); diff --git a/src/agent/session.ts b/src/agent/session.ts index 280332b..ba12cfa 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -1,11 +1,13 @@ import {randomBytes} from 'node:crypto'; import {chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {createServer, request} from 'node:http'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import type {AirPlayDevice, AppSettings, PlaybackState, Station} from '../types.js'; +import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; export type RadioSessionStatus = { + persistenceWarning?: string; owner: 'tui' | 'headless'; playback: PlaybackState; station: Station | null; @@ -32,7 +34,7 @@ export type RadioSessionCommand = | {type: 'update-settings'; settings: Partial>}; export type RadioSessionResult = {ok: boolean; message: string; status: RadioSessionStatus; data?: AirPlayDevice[]}; -type Discovery = {version: 1; host: '127.0.0.1'; port: number; token: string; pid: number; createdAt: string}; +type Discovery = {version: 1; host: LoopbackHost; port: number; token: string; pid: number; createdAt: string}; export type RadioSessionClient = { call(command: RadioSessionCommand): Promise; @@ -70,15 +72,10 @@ export async function startRadioSession( }); }); try { - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Unable to create RadioCLI control endpoint.'); + const address = await listenLoopback(server); const discovery: Discovery = { version: 1, - host: '127.0.0.1', + host: address.host, port: address.port, token, pid: process.pid, @@ -104,7 +101,7 @@ export async function connectRadioSession(filePath = radioSessionPath()): Promis let discovery: Discovery; try { discovery = JSON.parse(readFileSync(filePath, 'utf8')) as Discovery; - if (discovery.version !== 1 || discovery.host !== '127.0.0.1' || !Number.isInteger(discovery.port) || !discovery.token) { + if (discovery.version !== 1 || !isLoopbackHost(discovery.host) || !Number.isInteger(discovery.port) || !discovery.token) { throw new Error('invalid'); } } catch { @@ -115,7 +112,9 @@ export async function connectRadioSession(filePath = radioSessionPath()): Promis const payload = JSON.stringify(command); return new Promise((resolve, reject) => { const req = request({ - host: '127.0.0.1', + host: discovery.host, + // Local control tokens must never be forwarded by an environment proxy. + agent: false, port: discovery.port, path: '/command', method: 'POST', @@ -184,9 +183,7 @@ async function waitForSession(timeoutMs: number): Promise { } function runtimeDirectory(): string { - if (process.env.RADIOCLI_HOME) return join(process.env.RADIOCLI_HOME, 'runtime'); - if (process.platform === 'win32') return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime'); - return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli'); + return platformPaths().runtime; } function acquireOwner(path: string): void { diff --git a/src/alarms/active-session.test.ts b/src/alarms/active-session.test.ts index dfe32f9..b2d6caf 100644 --- a/src/alarms/active-session.test.ts +++ b/src/alarms/active-session.test.ts @@ -4,10 +4,44 @@ import {join} from 'node:path'; import {createServer} from 'node:http'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {connectActiveAlarm,connectActiveAlarms, startActiveAlarmSession} from './active-session.js'; +import {createSystemVolumeController} from './system-volume.js'; +import {SystemVolumeOwnership} from './system-volume-ownership.js'; const dirs:string[]=[]; afterEach(()=>dirs.splice(0).forEach(path=>rmSync(path,{recursive:true,force:true}))); +it('waits for handoff ownership contention beyond the short discovery deadline',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-handoff-lock-'));dirs.push(root); + const directory=join(root,'volume'); + const run=async(_command:string,args:string[])=>({code:0,stdout:args.includes('set s to get volume settings')?'20,true':'',stderr:''}); + const lease=await createSystemVolumeController('darwin',run,()=>null,{directory}).acquireMinimum(70); + let unlock:()=>void=()=>{};let lockReady:()=>void=()=>{}; + const ready=new Promise(resolve=>{lockReady=resolve;}); + const holding=new SystemVolumeOwnership(directory).transaction(async()=>{lockReady();await new Promise(resolve=>{unlock=resolve;});}); + await ready; + let server:Awaited>|undefined; + let timer:NodeJS.Timeout|undefined; + let startedHandoff:()=>void=()=>{}; + const started=new Promise(resolve=>{startedHandoff=resolve;}); + let handedOff=false; + try{ + const file=join(root,'active.json'); + server=await startActiveAlarmSession({alarmId:'a',scheduledAt:'2030-01-01T00:00:00Z',stationName:'A',startedAt:'2030-01-01T00:00:00Z'},{ + filePath:file,onDismiss:vi.fn(),onSnooze:vi.fn(),onKeepPlaying:vi.fn(), + async onHandoff(){startedHandoff();await lease.release({preserve:true});handedOff=true;} + }); + const client=await connectActiveAlarm(file); + const handoff=client!.handoff().then(()=>({ok:true}),error=>({error})); + await started; + timer=setTimeout(unlock,1_200); + expect(await handoff).toEqual({ok:true}); + expect(handedOff).toBe(true); + }finally{ + clearTimeout(timer);unlock();await holding; + await server?.close();await lease.release(); + } +}); + describe('active alarm IPC',()=>{ it('discovers, authenticates, controls, and removes a local session',async()=>{ const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-'));dirs.push(root);const file=join(root,'active.json'); @@ -21,6 +55,41 @@ describe('active alarm IPC',()=>{ await server.close(); expect(await connectActiveAlarm(file)).toBeNull(); }); + + for (const host of ['127.0.0.1', '::1']) { + it(`authenticates directly to a discovered ${host} alarm endpoint`, async context => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-alarm-family-')); + dirs.push(root); + const file = join(root, 'active.json'); + const status = {alarmId: 'family', scheduledAt: '2030-01-01T00:00:00Z', stationName: 'Local radio', startedAt: '2030-01-01T00:00:00Z'}; + let authenticatedRequests = 0; + const server = createServer((request, response) => { + if (request.headers.authorization === 'Bearer test-alarm-token') authenticatedRequests += 1; + request.resume(); + response.end(JSON.stringify(status)); + }); + try { + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, host, resolve);}); + } catch (error) { + if (host === '::1' && ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip('The host does not provide IPv6 loopback.'); + } + throw error; + } + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + writeFileSync(file, JSON.stringify({version: 1, host, port: address.port, token: 'test-alarm-token', pid: process.pid, alarmId: 'family'})); + const client = await connectActiveAlarm(file); + expect(client).not.toBeNull(); + expect(await client!.status()).toEqual(status); + expect(authenticatedRequests).toBe(2); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + } }); it('keeps concurrent alarm sessions independently discoverable and owned',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-many-'));dirs.push(root);const first=await startActiveAlarmSession({alarmId:'one',scheduledAt:'2030-01-01T00:00:00Z',stationName:'One',startedAt:'2030-01-01T00:00:00Z'},{filePath:join(root,'one.json'),onDismiss:vi.fn(),onSnooze:vi.fn(),onKeepPlaying:vi.fn()});const second=await startActiveAlarmSession({alarmId:'two',scheduledAt:'2030-01-01T00:00:00Z',stationName:'Two',startedAt:'2030-01-01T00:00:00Z'},{filePath:join(root,'two.json'),onDismiss:vi.fn(),onSnooze:vi.fn(),onKeepPlaying:vi.fn()});expect(await connectActiveAlarms(root)).toHaveLength(2);await first.close();expect((await connectActiveAlarms(root))).toHaveLength(1);await second.close();}); diff --git a/src/alarms/active-session.ts b/src/alarms/active-session.ts index 3d0f7dd..16a1752 100644 --- a/src/alarms/active-session.ts +++ b/src/alarms/active-session.ts @@ -1,9 +1,10 @@ import {randomBytes} from 'node:crypto'; import {chmodSync, existsSync, mkdirSync, readFileSync,readdirSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {createServer, request} from 'node:http'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import type {Station} from '../types.js'; +import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; export type ActiveAlarmStatus = { alarmId: string; @@ -14,7 +15,7 @@ export type ActiveAlarmStatus = { state?: 'starting'|'playing'|'stopping'|'failed'; keepPlaying?: boolean; }; -type Discovery={version:1;host:'127.0.0.1';port:number;token:string;pid:number;alarmId:string;createdAt?:string}; +type Discovery={version:1;host:LoopbackHost;port:number;token:string;pid:number;alarmId:string;createdAt?:string}; export type ActiveAlarmHandlers={filePath?:string;onDismiss():void|Promise;onSnooze(minutes:number):void|Promise;onKeepPlaying():void|Promise;onHandoff?():void|Promise}; export type ActiveAlarmServer={update(status:Partial):void;close():Promise}; export type ActiveAlarmClient={status():Promise;dismiss():Promise;snooze(minutes:number):Promise;keepPlaying():Promise;handoff():Promise}; @@ -33,25 +34,26 @@ export async function startActiveAlarmSession(initial:ActiveAlarmStatus,handlers res.statusCode=404;res.end('{}'); }catch(error){res.statusCode=400;res.end(JSON.stringify({error:error instanceof Error?error.message:'request failed'}));} }); - await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>resolve());}); - const address=server.address();if(!address||typeof address==='string')throw new Error('Unable to create alarm control endpoint.'); - const discovery={version:1,host:'127.0.0.1',port:address.port,token,pid:process.pid,alarmId:initial.alarmId,createdAt:new Date().toISOString()} satisfies Discovery;const temp=`${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;try{mkdirSync(dirname(filePath),{recursive:true,mode:0o700});writeFileSync(temp,`${JSON.stringify(discovery)}\n`,{mode:0o600});renameSync(temp,filePath);if(process.platform!=='win32')chmodSync(filePath,0o600);}catch(error){rmSync(temp,{force:true});removeDiscoveryIfOwned(filePath,discovery);await new Promise(resolve=>server.close(()=>resolve()));throw error;} + const address=await listenLoopback(server); + const discovery={version:1,host:address.host,port:address.port,token,pid:process.pid,alarmId:initial.alarmId,createdAt:new Date().toISOString()} satisfies Discovery;const temp=`${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;try{mkdirSync(dirname(filePath),{recursive:true,mode:0o700});writeFileSync(temp,`${JSON.stringify(discovery)}\n`,{mode:0o600});renameSync(temp,filePath);if(process.platform!=='win32')chmodSync(filePath,0o600);}catch(error){rmSync(temp,{force:true});removeDiscoveryIfOwned(filePath,discovery);await new Promise(resolve=>server.close(()=>resolve()));throw error;} return {update(change){status={...status,...change};},async close(){await new Promise(resolve=>server.close(()=>resolve()));removeDiscoveryIfOwned(filePath,discovery);}}; } export async function connectActiveAlarm(filePath?:string):Promise{ if(!filePath){const clients=await connectActiveAlarms();return clients[0]??null;} if(!existsSync(filePath))return null;let discovery:Discovery; - try{discovery=JSON.parse(readFileSync(filePath,'utf8')) as Discovery;if(discovery.version!==1||discovery.host!=='127.0.0.1'||!Number.isInteger(discovery.port)||!discovery.token)throw new Error('invalid');} + try{discovery=JSON.parse(readFileSync(filePath,'utf8')) as Discovery;if(discovery.version!==1||!isLoopbackHost(discovery.host)||!Number.isInteger(discovery.port)||!discovery.token)throw new Error('invalid');} catch{return null;} - const call=async(method:string,path:string,body?:unknown)=>{const payload=body===undefined?'':JSON.stringify(body);return new Promise((resolve,reject)=>{const req=request({host:'127.0.0.1',port:discovery.port,path,method,headers:{authorization:`Bearer ${discovery.token}`,'content-type':'application/json','content-length':Buffer.byteLength(payload)}},res=>{let text='';res.on('data',value=>text+=String(value));res.on('end',()=>res.statusCode&&res.statusCode<300?resolve(text?JSON.parse(text):{}):reject(new Error('Alarm control request failed.')));});req.once('error',reject);req.setTimeout(1000,()=>req.destroy(new Error('Alarm control request timed out.')));req.end(payload);});}; + // A fresh direct agent keeps local control tokens out of environment proxies. + // Handoff can wait for the shared output lock (30s); discovery keeps its short deadline. + const call=async(method:string,path:string,body?:unknown)=>{const payload=body===undefined?'':JSON.stringify(body);return new Promise((resolve,reject)=>{const req=request({host:discovery.host,agent:false,port:discovery.port,path,method,headers:{authorization:`Bearer ${discovery.token}`,'content-type':'application/json','content-length':Buffer.byteLength(payload)}},res=>{let text='';res.on('data',value=>text+=String(value));res.on('end',()=>res.statusCode&&res.statusCode<300?resolve(text?JSON.parse(text):{}):reject(new Error('Alarm control request failed.')));});req.once('error',reject);req.setTimeout(path==='/handoff'?35_000:1000,()=>req.destroy(new Error('Alarm control request timed out.')));req.end(payload);});}; try{await call('GET','/status');}catch{if(!processAlive(discovery.pid)||discoveryAgeMs(filePath,discovery)>5*60_000)removeDiscoveryIfOwned(filePath,discovery);return null;} return {status:async()=>await call('GET','/status') as ActiveAlarmStatus,dismiss:async()=>{await call('POST','/dismiss');},snooze:async minutes=>{await call('POST','/snooze',{minutes});},keepPlaying:async()=>{await call('POST','/keep-playing');},handoff:async()=>{await call('POST','/handoff');}}; } export async function connectActiveAlarms(directory=defaultActiveAlarmDirectory()):Promise{if(!existsSync(directory))return[];const paths=readdirSync(directory).filter(name=>name.endsWith('.json')).map(name=>join(directory,name));const clients=await Promise.all(paths.map(path=>connectActiveAlarm(path)));return clients.filter((client):client is ActiveAlarmClient=>Boolean(client));} -function defaultActiveAlarmDirectory():string{if(process.env.RADIOCLI_HOME)return join(process.env.RADIOCLI_HOME,'runtime','active-alarms');if(process.platform==='darwin')return join(homedir(),'Library','Application Support','radiocli','runtime','active-alarms');if(process.platform==='win32')return join(process.env.LOCALAPPDATA??join(homedir(),'AppData','Local'),'RadioCLI','runtime','active-alarms');return join(process.env.XDG_RUNTIME_DIR??join(homedir(),'.local','state'),'radiocli','active-alarms');} +function defaultActiveAlarmDirectory():string{return join(platformPaths().alarmRuntime,'active-alarms');} function defaultActiveAlarmPath(alarmId='active',occurrenceAt='current'):string{return join(defaultActiveAlarmDirectory(),`${Buffer.from(`${alarmId}\0${occurrenceAt}`).toString('base64url')}.json`);} function readBody(req:import('node:http').IncomingMessage):Promise{return new Promise((resolve,reject)=>{let body='';req.on('data',value=>{body+=String(value);if(body.length>4096)req.destroy(new Error('Request too large.'));});req.on('end',()=>resolve(body));req.on('error',reject);});} function removeDiscoveryIfOwned(filePath:string,owner:Discovery){let current:Partial;try{current=JSON.parse(readFileSync(filePath,'utf8')) as Partial;}catch{return;}if(!sameDiscovery(current,owner))return;const quarantine=`${filePath}.closing-${process.pid}-${randomBytes(6).toString('hex')}`;try{renameSync(filePath,quarantine);const moved=JSON.parse(readFileSync(quarantine,'utf8')) as Partial;if(sameDiscovery(moved,owner)){rmSync(quarantine,{force:true});return;}if(!existsSync(filePath))renameSync(quarantine,filePath);}catch{if(existsSync(quarantine)&&!existsSync(filePath))try{renameSync(quarantine,filePath);}catch{}} } diff --git a/src/alarms/guard.test.ts b/src/alarms/guard.test.ts index 0751582..6937105 100644 --- a/src/alarms/guard.test.ts +++ b/src/alarms/guard.test.ts @@ -2,6 +2,7 @@ import {mkdirSync,mkdtempSync,readFileSync,rmSync,utimesSync,writeFileSync} from import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {request} from 'node:http'; +import {Server} from 'node:net'; import {describe,expect,it,vi} from 'vitest'; import type {Alarm} from '../types.js'; import {AlarmGuardService,runAlarmGuard,terminateGuardTree} from './guard.js'; @@ -45,14 +46,59 @@ describe('one-shot alarm guard',()=>{ }finally{rmSync(root,{recursive:true,force:true});} }); - it('does not signal a reused PID without a fresh ownership heartbeat',async()=>{ + it('retains a live unverified PID for repair without signaling it',async()=>{ const root=mkdtempSync(join(tmpdir(),'radiocli-guard-stale-')); try{ const directory=join(root,'guards');const store=new AlarmPowerGuardStore(join(root,'power.json')); const stop=vi.fn(async()=>true);const service=new AlarmGuardService(store,'node','cli.js',vi.fn(),()=>new Date(),directory,async()=>false,stop); const alarmId='a';const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`${alarmId}\0${occurrenceAt}`).toString('base64url')}.json`);mkdirSync(directory,{recursive:true}); writeFileSync(path,JSON.stringify({alarmId,occurrenceAt,pid:process.pid,port:43210,token:'a'.repeat(64)}),{mode:0o600}); - expect((await service.status()).active).toBe(false);expect(()=>readFileSync(path)).toThrow();expect(stop).not.toHaveBeenCalled(); + expect(await service.status()).toMatchObject({active:false,guards:[],unresolvedGuards:[{alarmId,occurrenceAt,pid:process.pid}]}); + expect(await service.stop(alarmId)).toBe(false);expect(readFileSync(path,'utf8')).toContain('"alarmId":"a"');expect(stop).not.toHaveBeenCalled(); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it('blocks replacement when the previous live guard cannot prove ownership',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-unresolved-')); + try{ + const directory=join(root,'guards');const store=new AlarmPowerGuardStore(join(root,'power.json')); + const stop=vi.fn(async()=>true);const spawn=vi.fn();const service=new AlarmGuardService(store,'node','cli.js',spawn,()=>new Date('2030-01-01T00:00:00Z'),directory,async()=>false,stop); + const alarm:Alarm={id:'a',label:'Wake',enabled:true,station:{id:'x',provider:'radio-browser',name:'X',tags:[]},schedule:{type:'once',at:'2030-01-01T00:02:00.000Z'},playback:{volume:30,fadeSeconds:0,stopAfterMinutes:30},reliability:{missedRunGraceMinutes:10,wakeIfSupported:false},createdAt:'x',updatedAt:'x'}; + const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`a\0${occurrenceAt}`).toString('base64url')}.json`);mkdirSync(directory,{recursive:true}); + writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt,pid:process.pid,port:43210,token:'a'.repeat(64)}),{mode:0o600}); + await expect(service.start(alarm)).rejects.toThrow(/ownership.*repair/i); + expect(spawn).not.toHaveBeenCalled();expect(stop).not.toHaveBeenCalled();expect(readFileSync(path,'utf8')).toContain(occurrenceAt); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it('removes a guard record only when its process is confirmed absent',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-dead-')); + const kill=vi.spyOn(process,'kill').mockImplementation(()=>{throw Object.assign(new Error('No process'),{code:'ESRCH'});}); + try{ + const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`a\0${occurrenceAt}`).toString('base64url')}.json`); + writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt,pid:42,port:43210,token:'a'.repeat(64)})); + const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory,async()=>false,vi.fn()); + expect(await service.status()).toMatchObject({active:false,guards:[],unresolvedGuards:[]});expect(()=>readFileSync(path)).toThrow();expect(kill).toHaveBeenCalledWith(42,0); + }finally{kill.mockRestore();rmSync(root,{recursive:true,force:true});} + }); + + it('retains permission-denied process state as unresolved',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-eperm-')); + const kill=vi.spyOn(process,'kill').mockImplementation(()=>{throw Object.assign(new Error('Permission denied'),{code:'EPERM'});}); + try{ + const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const occurrenceAt='2030-01-01T00:01:00.000Z';const path=join(directory,`${Buffer.from(`a\0${occurrenceAt}`).toString('base64url')}.json`); + writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt,pid:42,port:43210,token:'a'.repeat(64)})); + const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory,async()=>false,vi.fn()); + expect(await service.stop('a')).toBe(false);expect(await service.status()).toMatchObject({active:false,unresolvedGuards:[{alarmId:'a',pid:42}]});expect(readFileSync(path,'utf8')).toContain('"pid":42');expect(kill.mock.calls.every(call=>call[1]===0)).toBe(true); + }finally{kill.mockRestore();rmSync(root,{recursive:true,force:true});} + }); + + it('does not contact a non-loopback host from an ownership artifact',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-host-')); + try{ + const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const path=join(directory,'untrusted.json');writeFileSync(path,JSON.stringify({alarmId:'a',occurrenceAt:'2030-01-01T00:01:00.000Z',pid:process.pid,host:'example.com',port:43210,token:'a'.repeat(64)})); + const verify=vi.fn(async()=>true);const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory,verify); + expect(await service.status()).toMatchObject({active:false,guards:[],unresolvedGuards:[expect.any(Object)]});expect(verify).not.toHaveBeenCalled();expect(readFileSync(path,'utf8')).toContain('example.com'); }finally{rmSync(root,{recursive:true,force:true});} }); @@ -61,7 +107,113 @@ describe('one-shot alarm guard',()=>{ it('serves an authenticated loopback challenge and stop control',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-guard-control-'));try{const path=join(root,'guard.json');const token='b'.repeat(64);const store=new AlarmPowerGuardStore(join(root,'power.json'));const release=vi.fn(async()=>{});const running=runAlarmGuard('a','2030-01-01T00:01:00Z',{status:vi.fn(),acquire:vi.fn(async()=>({release}))},store,()=>new Date('2030-01-01T00:00:00Z'),()=>new Promise(()=>{}),path,token);for(let attempt=0;attempt<20&&!readFile(path);attempt+=1)await new Promise(resolve=>setTimeout(resolve,5));const artifact=JSON.parse(readFileSync(path,'utf8')) as {port:number};expect(await controlRequest(artifact.port,token,'GET','/challenge')).toMatchObject({status:200,body:expect.stringContaining('"alarmId":"a"')});expect(await controlRequest(artifact.port,'wrong','POST','/stop')).toMatchObject({status:401});expect(await controlRequest(artifact.port,token,'POST','/stop')).toMatchObject({status:200});await running;expect(release).toHaveBeenCalled();expect(()=>readFileSync(path)).toThrow();}finally{rmSync(root,{recursive:true,force:true});}}); it('does not delete a transiently malformed ownership artifact while reading status',async()=>{const root=mkdtempSync(join(tmpdir(),'radiocli-guard-parse-'));try{const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const path=join(directory,'partial.json');writeFileSync(path,'{"alarmId":');const service=new AlarmGuardService(new AlarmPowerGuardStore(join(root,'power.json')),'node','cli.js',vi.fn(),()=>new Date(),directory);expect((await service.status()).active).toBe(false);expect(readFileSync(path,'utf8')).toBe('{"alarmId":');}finally{rmSync(root,{recursive:true,force:true});}}); + + it('publishes and challenges its IPv6 loopback endpoint when IPv4 is unavailable',async context=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-guard-ipv6-'));const directory=join(root,'guards');mkdirSync(directory,{recursive:true});const path=join(directory,'guard.json');const token='c'.repeat(64);const store=new AlarmPowerGuardStore(join(root,'power.json'));const release=vi.fn(async()=>{}); + const listen=vi.spyOn(Server.prototype,'listen').mockImplementationOnce(function(this:Server){queueMicrotask(()=>this.emit('error',Object.assign(new Error('IPv4 unavailable'),{code:'EAFNOSUPPORT'})));return this;}); + let failed:unknown;const running=runAlarmGuard('a','2030-01-01T00:01:00Z',{status:vi.fn(),acquire:vi.fn(async()=>({release}))},store,()=>new Date('2030-01-01T00:00:00Z'),()=>new Promise(()=>{}),path,token);void running.catch(error=>{failed=error;}); + let artifact:{host:'::1';port:number}|undefined; + try{ + for(let attempt=0;attempt<40&&!readFile(path)&&!failed;attempt+=1)await new Promise(resolve=>setTimeout(resolve,5)); + if(failed&&['EAFNOSUPPORT','EPROTONOSUPPORT','EADDRNOTAVAIL'].includes((failed as NodeJS.ErrnoException).code??''))context.skip('The host does not provide IPv6 loopback.'); + if(failed)throw failed; + artifact=JSON.parse(readFileSync(path,'utf8')) as typeof artifact;expect(artifact?.host).toBe('::1'); + const service=new AlarmGuardService(store,'node','cli.js',vi.fn(),()=>new Date(),directory); + expect(await service.status()).toMatchObject({active:true,guards:[{alarmId:'a',pid:process.pid}],unresolvedGuards:[]}); + expect(await controlRequest(artifact!.port,token,'POST','/stop',artifact!.host)).toMatchObject({status:200});await running;expect(release).toHaveBeenCalled(); + }finally{ + if(artifact&&readFile(path))await controlRequest(artifact.port,token,'POST','/stop',artifact.host).catch(()=>undefined); + if(artifact||failed)await running.catch(()=>undefined); + listen.mockRestore();rmSync(root,{recursive:true,force:true}); + } + }); +}); + +describe('alarm-specific Guard ownership', () => { + const oldOccurrence = '2030-01-01T00:01:00.000Z'; + const nameFor = (alarmId: string) => `${Buffer.from(`${alarmId}\0${oldOccurrence}`).toString('base64url')}.json`; + const ownerFor = (alarmId: string) => JSON.stringify({alarmId, occurrenceAt: oldOccurrence, pid: process.pid, port: 43210, token: 'a'.repeat(64)}); + + async function withRecord(name: string, contents: string, check: (fixture: { + service: AlarmGuardService; + alarm: Alarm; + path: string; + directory: string; + spawn: ReturnType; + }) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), 'radiocli-guard-record-scope-')); + try { + const directory = join(root, 'guards'); + mkdirSync(directory); + const path = join(directory, name); + writeFileSync(path, contents); + const store = new AlarmPowerGuardStore(join(root, 'power.json')); + let spawnedToken: string | undefined; + const spawn = vi.fn((_command: string, args: string[]) => { + const occurrenceAt = args.at(-3)!; + const pidPath = args.at(-2)!; + const token = args.at(-1)!; + spawnedToken = token; + writeFileSync(pidPath, JSON.stringify({alarmId: 'a', occurrenceAt, pid: process.pid, port: 43210, token})); + store.markActive('a', new Date(), occurrenceAt); + return {pid: process.pid, unref: vi.fn()}; + }); + const alarm: Alarm = { + id: 'a', label: 'Wake', enabled: true, + station: {id: 'x', provider: 'radio-browser', name: 'X', tags: []}, + schedule: {type: 'once', at: '2030-01-01T00:02:00.000Z'}, + playback: {volume: 30, fadeSeconds: 0, stopAfterMinutes: 30}, + reliability: {missedRunGraceMinutes: 10, wakeIfSupported: false}, + createdAt: 'x', updatedAt: 'x' + }; + const service = new AlarmGuardService(store, 'node', 'cli.js', spawn, () => new Date('2030-01-01T00:00:00Z'), directory, async owner => owner.token === spawnedToken, vi.fn(async () => false)); + await check({service, alarm, path, directory, spawn}); + } finally { + rmSync(root, {recursive: true, force: true}); + } + } + + it.each([ + {name: 'partial.json', contents: '{"alarmId":', reason: 'an undecodable filename'}, + {name: nameFor('b'), contents: '{"alarmId":', reason: 'another alarm encoded in its filename'}, + {name: nameFor('a'), contents: ownerFor('b'), reason: 'a parsed owner ID that differs from its filename'} + ])('starts an unrelated alarm despite $reason', async ({name, contents}) => { + await withRecord(name, contents, async ({service, alarm, path, spawn}) => { + await expect(service.start(alarm)).resolves.toMatchObject({pid: process.pid, occurrenceAt: alarm.schedule.type === 'once' ? alarm.schedule.at : undefined}); + expect(spawn).toHaveBeenCalledTimes(1); + expect(readFileSync(path, 'utf8')).toBe(contents); + }); + }); + + it.each([ + {name: nameFor('a'), contents: '{"alarmId":', reason: 'its own encoded filename'}, + {name: nameFor('b'), contents: ownerFor('a'), reason: 'its own parsed owner ID'} + ])('blocks a replacement identified by $reason', async ({name, contents}) => { + await withRecord(name, contents, async ({service, alarm, path, spawn}) => { + await expect(service.start(alarm)).rejects.toThrow(/ownership.*repair/i); + expect(spawn).not.toHaveBeenCalled(); + expect(readFileSync(path, 'utf8')).toBe(contents); + }); + }); + + it('reports one alarm stopped while retaining an unknown record for global repair', async () => { + await withRecord('partial.json', '{"alarmId":', async ({service, path, directory}) => { + const knownPath = join(directory, nameFor('a')); + writeFileSync(knownPath, ownerFor('a')); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => {throw Object.assign(new Error('No process'), {code: 'ESRCH'});}); + try { + expect(await service.stop('a')).toBe(true); + expect(() => readFileSync(knownPath)).toThrow(); + expect(await service.stop()).toBe(false); + expect(await service.status()).toMatchObject({active: false, guards: [], unresolvedGuards: [{message: expect.stringContaining('retained for repair')}]}); + expect(readFileSync(path, 'utf8')).toBe('{"alarmId":'); + expect(kill.mock.calls.every(call => call[1] === 0)).toBe(true); + } finally { + kill.mockRestore(); + } + }); + }); }); function readFile(path:string){try{readFileSync(path);return true;}catch{return false;}} -function controlRequest(port:number,token:string,method:string,path:string):Promise<{status:number;body:string}>{return new Promise((resolve,reject)=>{const req=request({host:'127.0.0.1',port,path,method,headers:{authorization:`Bearer ${token}`}},res=>{let body='';res.on('data',chunk=>body+=String(chunk));res.on('end',()=>resolve({status:res.statusCode??0,body}));});req.once('error',reject);req.end();});} +function controlRequest(port:number,token:string,method:string,path:string,host='127.0.0.1'):Promise<{status:number;body:string}>{return new Promise((resolve,reject)=>{const req=request({host,port,path,method,agent:false,headers:{authorization:`Bearer ${token}`}},res=>{let body='';res.on('data',chunk=>body+=String(chunk));res.on('end',()=>resolve({status:res.statusCode??0,body}));});req.once('error',reject);req.end();});} diff --git a/src/alarms/guard.ts b/src/alarms/guard.ts index 0e0d63b..864f460 100644 --- a/src/alarms/guard.ts +++ b/src/alarms/guard.ts @@ -2,15 +2,17 @@ import {spawn as nodeSpawn} from 'node:child_process'; import {randomBytes} from 'node:crypto'; import {chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {createServer,request} from 'node:http'; -import {dirname, join} from 'node:path'; +import {basename, dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {Alarm} from '../types.js'; import {AlarmPowerGuardStore} from './power-guard-store.js'; import type {PowerInhibitor} from './inhibitor.js'; import {nextOccurrenceForAlarm} from './schedule.js'; import {defaultAlarmRuntimeDirectory} from './runner.js'; +import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; -type GuardPid = {alarmId: string; occurrenceAt: string; pid: number; port: number; token: string}; +type GuardPid = {alarmId: string; occurrenceAt: string; pid: number; host?: LoopbackHost; port: number; token: string}; export type GuardSpawn = (command: string, args: string[], env: NodeJS.ProcessEnv) => {pid?: number; unref(): void}; type GuardVerifier=(guard:GuardPid)=>Promise; type GuardStopper=(guard:GuardPid)=>Promise; @@ -41,6 +43,7 @@ export class AlarmGuardService { // A recurring alarm may have been edited/rescheduled while its old guard is // alive. Stop that exact process tree before replacing its occurrence state. await this.stopOwned(alarm.id); + if (this.hasOwnershipRecords(alarm.id)) throw new Error('Previous Alarm Guard ownership is unresolved; retain the alarm for repair before starting another guard.'); this.store.request(alarm.id, occurrenceAt); const token=randomBytes(32).toString('hex'); const child = this.spawn( @@ -75,17 +78,20 @@ export class AlarmGuardService { async stop(alarmId?: string): Promise { if(alarmId)return this.withOwnership(alarmId,()=>this.stopOwned(alarmId)); const ids=[...new Set(this.pidFiles().map(readPid).filter((item):item is GuardPid=>Boolean(item)).map(item=>item.alarmId))]; - const results=await Promise.all(ids.map(id=>this.withOwnership(id,()=>this.stopOwned(id))));return results.every(Boolean); + const results=await Promise.all(ids.map(id=>this.withOwnership(id,()=>this.stopOwned(id))));return results.every(Boolean)&&!this.hasOwnershipRecords(); } private async stopOwned(alarmId?:string):Promise{ const targets = this.pidFiles() - .map(readPid) - .filter((item): item is GuardPid => Boolean(item)) - .filter(item => !alarmId || item.alarmId === alarmId); + .map(path => ({path, owner: readPid(path)})) + .filter((item): item is {path: string; owner: GuardPid} => Boolean(item.owner)) + .filter(item => !alarmId || item.owner.alarmId === alarmId); if (!targets.length) return false; - for (const current of targets) { - const path = this.path(current.alarmId, current.occurrenceAt); - if(!(await this.verify(current))){rmSync(path,{force:true});markIfCurrent(this.store,current,'Guard ownership challenge failed; the PID was not signaled.');continue;} + for (const {path, owner: current} of targets) { + if (!(await this.verify(current))) { + if (!isAlive(current.pid)) rmSync(path, {force: true}); + markIfCurrent(this.store, current, 'Guard ownership challenge failed; the PID was not signaled.'); + continue; + } if(!(await this.requestStop(current))){markIfCurrent(this.store,current,'Guard rejected its authenticated stop request; the PID was not signaled.');continue;} for(let attempt=0;attempt<20&&isAlive(current.pid);attempt+=1)await delay(50); if(isAlive(current.pid)&&await this.verify(current))await terminateGuardTree(current.pid); @@ -100,17 +106,28 @@ export class AlarmGuardService { this.store.markReleased(current.alarmId, new Date(), current.occurrenceAt); } } - return targets.every(item => !isAlive(item.pid)); + return !this.hasOwnershipRecords(alarmId); } async status() { const guards=[] as Array<{active:true;alarmId:string;occurrenceAt:string;pid:number}>; + const unresolvedGuards=[] as Array<{alarmId?:string;occurrenceAt?:string;pid?:number;message:string}>; for(const path of this.pidFiles()){ const pid = readPid(path); - if (!pid) continue; + if (!pid) { + const alarmId=alarmIdFromPath(path); + unresolvedGuards.push({...(alarmId?{alarmId}:{}),message:'Guard ownership metadata cannot be read; its record was retained for repair.'}); + continue; + } if (!(await this.verify(pid))) { - rmSync(path, {force: true}); - markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.'); + if (isAlive(pid.pid)) { + const message='Guard ownership cannot be verified; its record was retained and the PID was not signaled.'; + unresolvedGuards.push({alarmId:pid.alarmId,occurrenceAt:pid.occurrenceAt,pid:pid.pid,message}); + markIfCurrent(this.store,pid,message); + } else { + rmSync(path, {force: true}); + markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.'); + } continue; } guards.push({active:true,alarmId:pid.alarmId,occurrenceAt:pid.occurrenceAt,pid:pid.pid}); @@ -118,7 +135,10 @@ export class AlarmGuardService { return { active: guards.length > 0, guards, - message: guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.' + unresolvedGuards, + message: unresolvedGuards.length + ? `${guards.length} alarm guards verified active; ${unresolvedGuards.length} ownership records require repair.` + : guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.' }; } @@ -130,6 +150,13 @@ export class AlarmGuardService { if (!existsSync(this.directory)) return []; return readdirSync(this.directory).filter(name => name.endsWith('.json')).map(name => join(this.directory, name)); } + private hasOwnershipRecords(alarmId?:string):boolean { + return this.pidFiles().some(path=>{ + const ownerId=readPid(path)?.alarmId??alarmIdFromPath(path); + // Unknown records require global repair, but cannot identify this alarm. + return !alarmId||ownerId===alarmId; + }); + } private async withOwnership(alarmId:string,work:()=>Promise):Promise{const lock=join(this.directory,`.lock-${Buffer.from(alarmId).toString('base64url')}`);mkdirSync(this.directory,{recursive:true,mode:0o700});const deadline=Date.now()+3000;while(true){try{mkdirSync(lock,{mode:0o700});writeFileSync(join(lock,'created'),String(Date.now()),{mode:0o600});break;}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;let stale=false;try{const created=Number(readFileSync(join(lock,'created'),'utf8'));stale=Number.isFinite(created)?Date.now()-created>10_000:Date.now()-statSync(lock).mtimeMs>10_000;}catch{try{stale=Date.now()-statSync(lock).mtimeMs>10_000;}catch{}}if(stale){rmSync(lock,{recursive:true,force:true});continue;}if(Date.now()>deadline)throw new Error('Alarm Guard ownership is busy.');await delay(20);}}try{return await work();}finally{rmSync(lock,{recursive:true,force:true});}} } @@ -200,41 +227,56 @@ function readPid(path: string): GuardPid | undefined { if (!existsSync(path)) return undefined; try { const value = JSON.parse(readFileSync(path, 'utf8')) as GuardPid; - if (!Number.isInteger(value.pid) || !Number.isInteger(value.port)||value.port<1||value.port>65535||!value.alarmId || !/^[a-f0-9]{64}$/.test(value.token)||!/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt))) throw new Error(); + if (!Number.isInteger(value.pid) || value.pid<1 || !Number.isInteger(value.port)||value.port<1||value.port>65535||typeof value.alarmId!=='string'||!value.alarmId || (value.host!==undefined&&!isLoopbackHost(value.host)) || !/^[a-f0-9]{64}$/.test(value.token)||!/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt))) throw new Error(); return value; } catch { return undefined; } } +function alarmIdFromPath(path:string):string|undefined { + const name=basename(path,'.json');const decoded=Buffer.from(name,'base64url').toString(); + if(Buffer.from(decoded).toString('base64url')!==name)return undefined; + const separator=decoded.indexOf('\0'); + return separator>0?decoded.slice(0,separator):undefined; +} function writePrivate(path: string, value: GuardPid): void { mkdirSync(dirname(path), {recursive: true, mode: 0o700}); const temp=`${path}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`; writeFileSync(temp, JSON.stringify(value), {mode: 0o600}); renameSync(temp,path); - if (process.platform !== 'win32') chmodSync(path, 0o600); + if (nativeAdapters().posixPermissions) chmodSync(path, 0o600); } function isAlive(pid: number): boolean { - try { process.kill(pid, 0); return true; } catch { return false; } + try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; } } function ownsArtifact(path: string, token: string): boolean { return readPid(path)?.token === token; } -async function startGuardControl(path:string,token:string,alarmId:string,occurrenceAt:string,onStop:()=>void){const server=createServer((req,res)=>{if(req.headers.authorization!==`Bearer ${token}`){res.statusCode=401;res.end('{}');return;}if(req.method==='POST'&&req.url==='/stop'){onStop();res.end('{}');return;}if(req.method!=='GET'||req.url!=='/challenge'){res.statusCode=404;res.end('{}');return;}res.setHeader('content-type','application/json');res.end(JSON.stringify({alarmId,occurrenceAt,pid:process.pid}));});await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});const address=server.address();if(!address||typeof address==='string'){server.close();throw new Error('Unable to create Guard ownership endpoint.');}writePrivate(path,{alarmId,occurrenceAt,pid:process.pid,port:address.port,token});return{close:()=>new Promise(resolve=>server.close(()=>resolve()))};} +async function startGuardControl(path:string,token:string,alarmId:string,occurrenceAt:string,onStop:()=>void){ + const server=createServer((req,res)=>{if(req.headers.authorization!==`Bearer ${token}`){res.statusCode=401;res.end('{}');return;}if(req.method==='POST'&&req.url==='/stop'){onStop();res.end('{}');return;}if(req.method!=='GET'||req.url!=='/challenge'){res.statusCode=404;res.end('{}');return;}res.setHeader('content-type','application/json');res.end(JSON.stringify({alarmId,occurrenceAt,pid:process.pid}));}); + const close=()=>new Promise(resolve=>server.close(()=>resolve())); + try { + const {host,port}=await listenLoopback(server); + writePrivate(path,{alarmId,occurrenceAt,pid:process.pid,host,port,token}); + return {close}; + } catch(error) { await close();throw error; } +} function challengeGuard(guard:GuardPid):Promise{return callGuard(guard,'GET','/challenge',true);} function requestGuardStop(guard:GuardPid):Promise{return callGuard(guard,'POST','/stop',false);} -function callGuard(guard:GuardPid,method:string,path:string,validateIdentity:boolean):Promise{return new Promise(resolve=>{const req=request({host:'127.0.0.1',port:guard.port,path,method,headers:{authorization:`Bearer ${guard.token}`}},res=>{let body='';res.on('data',value=>body+=String(value));res.on('end',()=>{if(!validateIdentity){resolve(res.statusCode===200);return;}try{const reply=JSON.parse(body) as {alarmId?:string;occurrenceAt?:string;pid?:number};resolve(res.statusCode===200&&reply.alarmId===guard.alarmId&&reply.occurrenceAt===guard.occurrenceAt&&reply.pid===guard.pid);}catch{resolve(false);}});});req.once('error',()=>resolve(false));req.setTimeout(500,()=>req.destroy());req.end();});} +function callGuard(guard:GuardPid,method:string,path:string,validateIdentity:boolean):Promise{return new Promise(resolve=>{const req=request({host:guard.host??'127.0.0.1',port:guard.port,path,method,agent:false,headers:{authorization:`Bearer ${guard.token}`}},res=>{let body='';res.on('data',value=>body+=String(value));res.on('end',()=>{if(!validateIdentity){resolve(res.statusCode===200);return;}try{const reply=JSON.parse(body) as {alarmId?:string;occurrenceAt?:string;pid?:number};resolve(res.statusCode===200&&reply.alarmId===guard.alarmId&&reply.occurrenceAt===guard.occurrenceAt&&reply.pid===guard.pid);}catch{resolve(false);}});});req.once('error',()=>resolve(false));req.setTimeout(500,()=>req.destroy());req.end();});} export type GuardProcessOps={platform:NodeJS.Platform;isAlive(pid:number):boolean;kill(pid:number,signal:NodeJS.Signals):void;taskkill(pid:number,force:boolean):Promise;wait(milliseconds:number):Promise}; export async function terminateGuardTree(pid: number,ops:GuardProcessOps={platform:process.platform,isAlive,kill:(target,signal)=>process.kill(target,signal),taskkill:taskkillTree,wait:delay}): Promise { + const windows=nativeAdapters(identifyPlatform({platform:ops.platform})).ipc==='named-pipe'; // Kill the entire detached tree first on Windows so a dying parent cannot // orphan its PowerShell execution-state helper. - if (ops.platform === 'win32') { + if (windows) { await ops.taskkill(pid, false); } else { try { ops.kill(pid, 'SIGTERM'); } catch { return; } } for (let index = 0; index < 20 && ops.isAlive(pid); index += 1) await ops.wait(50); if (!ops.isAlive(pid)) return; - if (ops.platform === 'win32') await ops.taskkill(pid, true); + if (windows) await ops.taskkill(pid, true); else { // The guard is a detached process-group leader; force-killing the negative // PID guarantees its inhibitor helper cannot survive an unresponsive parent. diff --git a/src/alarms/inhibitor.test.ts b/src/alarms/inhibitor.test.ts index ab06f79..70a7308 100644 --- a/src/alarms/inhibitor.test.ts +++ b/src/alarms/inhibitor.test.ts @@ -1,4 +1,6 @@ import {describe, expect, it, vi} from 'vitest'; +import {mkdtempSync,rmSync,writeFileSync} from 'node:fs'; +import {basename,join,posix} from 'node:path'; import {createPowerInhibitor} from './inhibitor.js'; describe('power inhibitor', () => { @@ -16,5 +18,21 @@ describe('power inhibitor', () => { expect(kill).toHaveBeenCalled(); }); - it('reports a helper that exits unexpectedly after startup',async()=>{let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const inhibitor=createPowerInhibitor({platform:'darwin',spawn:()=>({pid:1,kill:()=>true,exited}),commandExists:()=>true});const lease=await inhibitor.acquire('Alarm');exit();await expect(lease.unexpectedExit).resolves.toMatchObject({message:expect.stringMatching(/unexpectedly/)});await lease.release();}); + it('reports lost protection when the helper exits unexpectedly after startup',async()=>{let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const inhibitor=createPowerInhibitor({platform:'darwin',spawn:()=>({pid:1,kill:()=>true,exited}),commandExists:()=>true});const lease=await inhibitor.acquire('Alarm');expect(inhibitor.status().active).toBe(true);exit();await expect(lease.unexpectedExit).resolves.toMatchObject({message:expect.stringMatching(/unexpectedly/)});expect(inhibitor.status().active).toBe(false);await lease.release();}); + + it('explains a missing helper without claiming that sleep is prevented',async()=>{const spawn=vi.fn();const inhibitor=createPowerInhibitor({platform:'darwin',spawn,commandExists:()=>false});expect(inhibitor.status()).toMatchObject({supported:false,active:false,message:expect.stringMatching(/caffeinate.*(?:missing|unavailable|not found)/i)});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/caffeinate.*(?:missing|unavailable|not found)/i);expect(spawn).not.toHaveBeenCalled();}); + + it('does not treat a Termux process reporting Linux as a logind host',async()=>{const spawn=vi.fn();const exists=vi.fn(()=>true);const inhibitor=createPowerInhibitor({platform:'linux',env:{TERMUX_VERSION:'0.118.3'},spawn,commandExists:exists});expect(inhibitor.status()).toMatchObject({supported:false,active:false,message:expect.stringContaining('termux')});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/unsupported.*termux/i);expect(exists).not.toHaveBeenCalled();expect(spawn).not.toHaveBeenCalled();}); + + it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('reports unavailable sleep protection on %s without spawning a surrogate',async platform=>{const spawn=vi.fn();const inhibitor=createPowerInhibitor({platform,spawn,commandExists:()=>true});expect(inhibitor.status()).toMatchObject({supported:false,active:false});await expect(inhibitor.acquire('Alarm')).rejects.toThrow(/unsupported/i);expect(spawn).not.toHaveBeenCalled();}); + + it('spawns the executable resolved from the supplied PATH',async()=>{ + // Keep the injected POSIX PATH free of Windows drive-letter delimiters. + const root=mkdtempSync(join(process.cwd(),'.radiocli-inhibitor-path-')); + try{ + writeFileSync(join(root,'systemd-inhibit'),'#!/bin/sh\nexit 0\n',{mode:0o700});let exit:()=>void=()=>{};const exited=new Promise(resolve=>{exit=resolve;});const spawn=vi.fn(()=>({pid:42,kill:()=>{exit();return true;},exited})); + const pathEntry=basename(root);const inhibitor=createPowerInhibitor({platform:'linux',env:{PATH:pathEntry},spawn});const lease=await inhibitor.acquire('Alarm'); + expect(spawn).toHaveBeenCalledWith(posix.join(pathEntry,'systemd-inhibit'),expect.any(Array));await lease.release(); + }finally{rmSync(root,{recursive:true,force:true});} + }); }); diff --git a/src/alarms/inhibitor.ts b/src/alarms/inhibitor.ts index e857bf8..23578a9 100644 --- a/src/alarms/inhibitor.ts +++ b/src/alarms/inhibitor.ts @@ -1,6 +1,6 @@ import {spawn as nodeSpawn} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {delimiter, join} from 'node:path'; +import {resolveCommandDetails} from '../platform/executables.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; type InhibitorStatus = {supported: boolean; active: boolean; message: string}; export type InhibitorLease = {release(): Promise;unexpectedExit?:Promise}; @@ -11,6 +11,7 @@ export type PowerInhibitor = { }; export type InhibitorDeps = { platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; spawn?: (command:string,args:string[])=>Spawned; commandExists?: (command:string)=>boolean; }; @@ -21,34 +22,34 @@ const windowsInhibitorScript = (parentPid:number) => `$s='[DllImport("kernel32.d export function createPowerInhibitor(deps: InhibitorDeps = {}): PowerInhibitor { const platform = deps.platform ?? process.platform; - const spawn = deps.spawn ?? spawnDetached; - const exists = deps.commandExists ?? executableExists; - const spec = platform === 'darwin' + const env=deps.env??process.env; + const host=identifyPlatform({platform,env}); + const adapter=nativeAdapters(host).inhibitor; + const spawn = deps.spawn ?? ((command,args)=>spawnDetached(command,args,env)); + const spec = adapter === 'caffeinate' ? {command:'caffeinate',args:['-i','-w',String(process.pid)],message:'Prevents idle system sleep; the display may sleep.'} - : platform === 'linux' + : adapter === 'logind' ? {command:'systemd-inhibit',args:['--what=sleep','--who=RadioCLI','--why=Scheduled radio','--mode=block','sh','-c','while kill -0 "$1" 2>/dev/null; do sleep 2; done','radiocli-inhibitor',String(process.pid)],message:'Uses a logind sleep inhibitor; explicit sleep and lid policy may override it.'} - : platform === 'win32' + : adapter === 'windows' ? {command:'powershell.exe',args:['-NoLogo','-NoProfile','-NonInteractive','-Command',windowsInhibitorScript(process.pid)],message:'Prevents idle system sleep; explicit sleep and lid policy may override it.'} : null; let active = false; - const supported = Boolean(spec && exists(spec.command)); + const command=spec?(deps.commandExists?(deps.commandExists(spec.command)?spec.command:null):resolveCommandDetails(spec.command,{platform,env}).path):null; + const supported = Boolean(command); + const message=!spec?`Power inhibition is unsupported on ${host.id==='unknown'?platform:host.id}.`:!command?`${spec.command} is unavailable; sleep protection cannot be acquired.`:spec.message; return { - status:()=>({supported,active,message:spec?.message ?? `Power inhibition is unsupported on ${platform}.`}), + status:()=>({supported,active,message}), async acquire(_reason){ - if (!spec || !supported) throw new Error(spec?.message ?? `Power inhibition is unsupported on ${platform}.`); - const child=spawn(spec.command,spec.args);const exitedEarly=await Promise.race([child.exited.then(()=>true),new Promise(resolve=>setTimeout(()=>resolve(false),50))]);if(exitedEarly)throw new Error(`${spec.command} exited before sleep inhibition became active.`);active=true; let released=false; - const unexpectedExit=child.exited.then(()=>released?new Promise(()=>{}):new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`)); + if (!spec || !command) throw new Error(message); + const child=spawn(command,spec.args);const exitedEarly=await exitedWithin(child.exited,50);if(exitedEarly)throw new Error(`${spec.command} exited before sleep inhibition became active.`);active=true; let released=false; + const unexpectedExit=child.exited.then(()=>{active=false;return released?new Promise(()=>{}):new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`);}); return {unexpectedExit,async release(){if(released)return;released=true;active=false;child.kill('SIGTERM');if(await exitedWithin(child.exited,1000))return;child.kill('SIGKILL');if(!(await exitedWithin(child.exited,500)))throw new Error(`${spec.command} did not terminate; sleep protection state is unknown.`);}}; } }; } -function executableExists(command:string):boolean { - if (existsSync(command)) return true; - return Boolean(process.env.PATH?.split(delimiter).some(path=>existsSync(join(path,command)) || (process.platform==='win32'&&existsSync(join(path,`${command}.exe`))))); -} -function spawnDetached(command:string,args:string[]):Spawned { - const child=nodeSpawn(command,args,{stdio:'ignore',windowsHide:true}); +function spawnDetached(command:string,args:string[],env:NodeJS.ProcessEnv):Spawned { + const child=nodeSpawn(command,args,{env,stdio:'ignore',windowsHide:true}); return {pid:child.pid,kill:signal=>child.kill(signal),exited:new Promise(resolve=>{child.once('exit',resolve);child.once('error',resolve);})}; } -async function exitedWithin(exited:Promise,milliseconds:number){return Promise.race([exited.then(()=>true),new Promise(resolve=>setTimeout(()=>resolve(false),milliseconds))]);} +async function exitedWithin(exited:Promise,milliseconds:number){let timer:NodeJS.Timeout|undefined;try{return await Promise.race([exited.then(()=>true),new Promise(resolve=>{timer=setTimeout(()=>resolve(false),milliseconds);})]);}finally{if(timer)clearTimeout(timer);}} diff --git a/src/alarms/power-guard-store.ts b/src/alarms/power-guard-store.ts index a1bd2fd..bb69023 100644 --- a/src/alarms/power-guard-store.ts +++ b/src/alarms/power-guard-store.ts @@ -8,7 +8,7 @@ import { statSync, writeFileSync } from 'node:fs'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import {z} from 'zod'; import type {AlarmPowerGuardState} from '../types.js'; @@ -149,14 +149,7 @@ export class AlarmPowerGuardStore { } function defaultAlarmPowerGuardPath(): string { - if (process.env.RADIOCLI_HOME) return join(process.env.RADIOCLI_HOME, 'runtime', 'alarm-power-guards.json'); - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radiocli', 'runtime', 'alarm-power-guards.json'); - } - if (process.platform === 'win32') { - return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime', 'alarm-power-guards.json'); - } - return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli', 'alarm-power-guards.json'); + return join(platformPaths().alarmRuntime, 'alarm-power-guards.json'); } function validDate(date: Date, label: string): Date { diff --git a/src/alarms/runner.test.ts b/src/alarms/runner.test.ts index 51e5c08..53cc257 100644 --- a/src/alarms/runner.test.ts +++ b/src/alarms/runner.test.ts @@ -37,6 +37,59 @@ describe('alarm runner',()=>{ it('keeps playing when automatic terminal controls cannot open',async()=>{const record=vi.fn();const d=deps({openControls:vi.fn(async()=>{throw new Error('no terminal');}),health:{record} as never});expect((await runAlarm('a',scheduledAt,d)).status).toBe('dismissed');await Promise.resolve();expect(record).toHaveBeenCalledWith(expect.objectContaining({component:'runner',healthy:false,message:expect.stringMatching(/controls could not open.*no terminal/i)}));}); + it.each([ + 'Requested RadioCLI alarm controls; TUI startup is unverified.', + 'Requested RadioCLI alarm controls, but the TUI did not become ready before verification timed out.' + ])('preserves an unsuccessful controls result while playback continues: %s',async message=>{ + const record=vi.fn();const update=vi.fn(); + const d=deps({createSession:vi.fn(async()=>({update,close:vi.fn(async()=>{})})),openControls:vi.fn(async()=>({opened:false,requested:true,terminal:'darwin:apple-terminal',message})),health:{record} as never}); + expect((await runAlarm('a',scheduledAt,d)).status).toBe('played'); + expect(update).toHaveBeenCalledWith(expect.objectContaining({state:'playing'}));expect(d.wait).toHaveBeenCalledWith(60_000); + expect(record).toHaveBeenCalledWith(expect.objectContaining({alarmId:'a',occurrenceAt:'2030-01-01T08:00:00.000Z',component:'runner',healthy:false,message:expect.stringContaining(message)})); + }); + + it.each([ + {opened:false,terminal:'existing-tui',message:'An existing RadioCLI TUI will show the ringing controls.'}, + {opened:true,requested:true,terminal:'darwin:apple-terminal',message:'RadioCLI alarm controls are ready in the saved terminal.'} + ])('keeps confirmed or existing controls healthy: $terminal',async result=>{ + const record=vi.fn();const d=deps({openControls:vi.fn(async()=>result),health:{record} as never}); + expect((await runAlarm('a',scheduledAt,d)).status).toBe('dismissed'); + expect(record).not.toHaveBeenCalledWith(expect.objectContaining({component:'runner',healthy:false})); + }); + + it.each(['addRecent','startListeningSession','checkpointActiveListeningSession','finishActiveListeningSession'] as const)('continues published playback after optional %s history writes fail',async operation=>{ + const base=deps();const events:string[]=[];const record=vi.fn(); + const d=deps({ + store:{...base.store,[operation]:vi.fn(()=>{throw new Error(`${operation}: EACCES`);})}, + player:{...base.player,play:vi.fn(async()=>{events.push('play');}),stop:vi.fn(async()=>{events.push('stop');})}, + createSession:vi.fn(async()=>({update:vi.fn(status=>{if(status.state==='playing')events.push('published');}),close:vi.fn(async()=>{})})), + wait:vi.fn(async()=>{events.push('wait');}),health:{record} as never + }); + expect((await runAlarm('a',scheduledAt,d)).status).toBe('played'); + expect(events).toEqual(['play','published','wait','stop']); + expect(record).toHaveBeenCalledWith(expect.objectContaining({alarmId:'a',occurrenceAt:'2030-01-01T08:00:00.000Z',component:'runner',healthy:false,message:expect.stringContaining(`${operation}: EACCES`)})); + expect(d.store.recordAlarmOutcome).toHaveBeenCalledWith('a',expect.objectContaining({status:'played'}),{clearNextOverride:true}); + if(operation==='addRecent')expect(d.store.startListeningSession).toHaveBeenCalled(); + if(operation==='startListeningSession'){expect(d.store.checkpointActiveListeningSession).not.toHaveBeenCalled();expect(d.store.finishActiveListeningSession).not.toHaveBeenCalled();} + if(operation==='checkpointActiveListeningSession')expect(d.store.finishActiveListeningSession).toHaveBeenCalled(); + }); + + it.each(['recordAlarmOutcome','toggleAlarm'] as const)('reports required %s persistence failures separately from optional history',async operation=>{ + const base=deps();const record=vi.fn();const d=deps({store:{...base.store,[operation]:vi.fn(()=>{throw new Error(`${operation}: EACCES`);})},health:{record} as never}); + await runAlarm('a',scheduledAt,d); + expect(record).toHaveBeenCalledWith(expect.objectContaining({component:'runner',healthy:false,active:false,message:expect.stringMatching(new RegExp(`alarm.*sav.*${operation}: EACCES`,'i'))})); + if(operation==='recordAlarmOutcome')expect(d.store.toggleAlarm).not.toHaveBeenCalled(); + }); + + it('retains an unverified controls warning when optional history finalization also fails',async()=>{ + const base=deps();const record=vi.fn();const message='Requested controls; TUI startup is unverified.'; + const d=deps({store:{...base.store,finishActiveListeningSession:vi.fn(()=>{throw new Error('history EACCES');})},openControls:vi.fn(async()=>({opened:false,requested:true,terminal:'darwin:apple-terminal',message})),health:{record} as never}); + await runAlarm('a',scheduledAt,d); + const lastRunnerEntry=record.mock.calls.map(([entry])=>entry).filter(entry=>entry.component==='runner').at(-1); + expect(lastRunnerEntry).toMatchObject({healthy:false,message:expect.stringContaining(message)}); + expect(lastRunnerEntry.message).toContain('history EACCES'); + }); + it('retries primary once then uses only the explicit fallback',async()=>{ const play=vi.fn().mockRejectedValueOnce(new Error('bad')).mockRejectedValueOnce(new Error('bad')).mockResolvedValue(undefined); const d=deps({player:{play,stop:vi.fn(async()=>{}),setVolume:vi.fn(async()=>({ok:true})),getState:vi.fn(()=>({backend:'mpv'}))}}); @@ -76,6 +129,25 @@ describe('alarm runner',()=>{ it('does not disable a valid one-time alarm when launched early',async()=>{const d=deps({now:()=>new Date('2030-01-01T07:59:00Z')});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('failed');expect(d.store.toggleAlarm).not.toHaveBeenCalled();}); + it('does not complete a future native occurrence after an early invocation',async()=>{const completeOccurrence=vi.fn(async()=>{});const d=deps({now:()=>new Date('2030-01-01T07:59:00Z'),scheduler:{sync:vi.fn(async()=>null),completeOccurrence}});await runAlarm('a',scheduledAt,d);expect(completeOccurrence).not.toHaveBeenCalled();}); + + it.each([['early','2030-01-01T08:10:00Z'],['stale','2030-01-01T08:00:00Z']] as const)('preserves a future snooze override after an ignored %s invocation',async(_kind,invocation)=>{const snoozed={...alarm,nextOverride:{at:'2030-01-01T08:10:00Z',reason:'snooze' as const,createdAt:'2030-01-01T08:00:00Z'}};const d=deps({now:()=>new Date('2030-01-01T08:05:00Z'),store:{...deps().store,getAlarm:vi.fn(()=>snoozed)}});await runAlarm('a',invocation,d);expect(d.store.recordAlarmOutcome).toHaveBeenCalledWith('a',expect.any(Object),{clearNextOverride:false});expect(d.player.play).not.toHaveBeenCalled();}); + + it('allows the real occurrence to run after an early native dispatch', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-early-occurrence-')); + try { + let current = new Date('2030-01-01T07:59:00Z'); + const d = deps({now: () => current, acquireLock: (id, at) => acquireOccurrenceLock(id, at, root)}); + expect((await runAlarm('a', scheduledAt, d)).status).toBe('failed'); + expect(d.player.play).not.toHaveBeenCalled(); + current = new Date('2030-01-01T08:00:00Z'); + expect((await runAlarm('a', scheduledAt, d)).duplicate).not.toBe(true); + expect(d.player.play).toHaveBeenCalledOnce(); + expect((await runAlarm('a', scheduledAt, d)).duplicate).toBe(true); + expect(d.player.play).toHaveBeenCalledOnce(); + } finally { rmSync(root, {recursive: true, force: true}); } + }); + it('records a valid but overdue occurrence as missed and disables one-time alarms',async()=>{const d=deps({now:()=>new Date('2030-01-01T08:20:00Z')});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('missed');expect(d.store.toggleAlarm).toHaveBeenCalledWith('a',false);}); it('records normal stop-after completion as played',async()=>{const d=deps({createSession:vi.fn(async()=>({update:vi.fn(),close:vi.fn(async()=>{})}))});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('played');expect(d.store.toggleAlarm).toHaveBeenCalledWith('a',false);}); @@ -94,7 +166,31 @@ describe('alarm runner',()=>{ it('reconciles the next daily occurrence before keep-playing can run indefinitely',async()=>{const recurring:Alarm={...alarm,schedule:{type:'recurring',time:'08:00',weekdays:[1,2,3,4,5,6,7],timezone:'UTC'}};let signal:()=>void=()=>{};const sync=vi.fn(async()=>null);const d=deps({store:{...deps().store,getAlarm:vi.fn(()=>recurring)},scheduler:{sync},createSession:vi.fn(async(_status,handlers)=>{handlers.onKeepPlaying();return{update:vi.fn(),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>new Promise(()=>{})),subscribeSignals:handler=>{signal=handler;return vi.fn();}});const running=runAlarm('a',scheduledAt,d);for(let attempt=0;attempt<10&&sync.mock.calls.length===0;attempt+=1)await Promise.resolve();expect(sync).toHaveBeenCalledWith(recurring);signal();await running;}); - it('hands playback to the interactive player without restoring the alarm output level',async()=>{const release=vi.fn(async()=>{});const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(status,handlers)=>{expect(status.station).toEqual(station);return{update:vi.fn(change=>{if(change.state==='playing')queueMicrotask(()=>handlers.onHandoff?.());}),close:vi.fn(async()=>{})};})});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('played');expect(result.message).toContain('Handed off');expect(release).not.toHaveBeenCalled();expect(d.player.stop).toHaveBeenCalled();}); + it('hands playback to the interactive player and releases shared ownership while preserving output',async()=>{const release=vi.fn(async()=>{});const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(status,handlers)=>{expect(status.station).toEqual(station);return{update:vi.fn(change=>{if(change.state==='playing')queueMicrotask(()=>handlers.onHandoff?.());}),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>new Promise(()=>{}))});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('played');expect(result.message).toContain('Handed off');expect(release).toHaveBeenCalledExactlyOnceWith({preserve:true});expect(d.player.stop).toHaveBeenCalled();}); + + it('does not accept a handoff if preserving shared output ownership fails',async()=>{ + let handlers:Parameters[1]|undefined; + let playing:()=>void=()=>{};const started=new Promise(resolve=>{playing=resolve;}); + const release=vi.fn().mockRejectedValueOnce(new Error('ownership disk full')).mockResolvedValue(undefined); + const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(_status,value)=>{handlers=value;return{update:vi.fn(change=>{if(change.state==='playing')playing();}),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>new Promise(()=>{}))}); + const running=runAlarm('a',scheduledAt,d);await started; + await expect(Promise.resolve().then(()=>handlers!.onHandoff!())).rejects.toThrow('ownership disk full'); + await handlers!.onDismiss();expect((await running).status).toBe('dismissed'); + expect(release).toHaveBeenNthCalledWith(1,{preserve:true});expect(release).toHaveBeenNthCalledWith(2); + }); + + it('waits for an in-progress handoff before timeout cleanup can restore shared output',async()=>{ + let handlers:Parameters[1]|undefined; + let playing:()=>void=()=>{};const started=new Promise(resolve=>{playing=resolve;}); + let preserved:()=>void=()=>{};const preserving=new Promise(resolve=>{preserved=resolve;}); + let timedOut:()=>void=()=>{};const timeout=new Promise(resolve=>{timedOut=resolve;}); + const release=vi.fn(async(options?:{preserve?:boolean})=>{if(options?.preserve)await preserving;}); + const d=deps({systemVolume:{acquireMinimum:vi.fn(async()=>({message:'raised',release}))},createSession:vi.fn(async(_status,value)=>{handlers=value;return{update:vi.fn(change=>{if(change.state==='playing')playing();}),close:vi.fn(async()=>{})};}),wait:vi.fn(()=>timeout)}); + const running=runAlarm('a',scheduledAt,d);await started;const handoff=handlers!.onHandoff!();timedOut(); + try{for(let turn=0;turn<20;turn+=1)await Promise.resolve();expect(release).toHaveBeenCalledExactlyOnceWith({preserve:true});} + finally{preserved();await handoff;await running;} + expect(release).toHaveBeenCalledExactlyOnceWith({preserve:true}); + }); it('claims the alarm before resolving and preempts interactive playback before raising output or tuning',async()=>{const events:string[]=[];const base=deps();const d=deps({providers:{resolve:vi.fn(async()=>{events.push('resolve');return{url:'https://main'};})},preemptInteractivePlayback:vi.fn(async()=>{events.push('preempt');}),systemVolume:{acquireMinimum:vi.fn(async()=>{events.push('system-volume');return{message:'raised',release:vi.fn(async()=>{})};})},player:{...base.player,play:vi.fn(async()=>{events.push('play');})},createSession:vi.fn(async(status,handlers)=>{events.push(`claim-${status.state}`);return{update:vi.fn(change=>{events.push(`update-${change.state}`);queueMicrotask(()=>handlers.onDismiss());}),close:vi.fn(async()=>{})};})});await runAlarm('a',scheduledAt,d);expect(events).toEqual(['claim-starting','resolve','preempt','system-volume','play','update-playing']);}); diff --git a/src/alarms/runner.ts b/src/alarms/runner.ts index c268510..247f405 100644 --- a/src/alarms/runner.ts +++ b/src/alarms/runner.ts @@ -1,5 +1,5 @@ import {existsSync,mkdirSync,readFileSync,readdirSync,rmSync,statSync,writeFileSync} from 'node:fs'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; import type {Alarm,AlarmRunRecord,PlaybackState,ResolvedStream,Station} from '../types.js'; import type {PlaybackControlResult} from '../player/player-controller.js'; @@ -9,6 +9,7 @@ import {assessScheduledOccurrence,NATIVE_DISPATCH_TOLERANCE_MS,nextOccurrenceFor import {startActiveAlarmSession,type ActiveAlarmHandlers,type ActiveAlarmServer,type ActiveAlarmStatus} from './active-session.js'; import type {AlarmRuntimeHealthStore} from './runtime-health.js'; import type {SystemVolumeController,SystemVolumeLease} from './system-volume.js'; +import type {AlarmTerminalLaunchResult} from './terminal-launcher.js'; type RunnerStore={ getAlarm(id:string):Alarm|undefined; @@ -25,9 +26,9 @@ type RunnerProvider={resolve(station:Station):Promise}; type RunnerScheduler=Pick & Partial>; export type AlarmRunnerDeps={ now():Date; store:RunnerStore; providers:RunnerProvider; player:RunnerPlayer; scheduler:RunnerScheduler; inhibitor:PowerInhibitor; - acquireLock(alarmId:string,scheduledAt:string):(()=>void)|null; + acquireLock(alarmId:string,scheduledAt:string):((completed?:boolean)=>void)|null; createSession(status:ActiveAlarmStatus,handlers:ActiveAlarmHandlers):Promise; - openControls?(status:ActiveAlarmStatus):Promise; + openControls?(status:ActiveAlarmStatus):Promise; preemptInteractivePlayback?():Promise; wait(milliseconds:number):Promise; subscribeSignals?(handler:()=>void):()=>void; @@ -40,10 +41,14 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(!/(?:Z|[+-]\d{2}:\d{2})$/.test(scheduledAtText))throw new Error('Scheduled alarm occurrence must be an absolute date.'); const scheduledAt=new Date(scheduledAtText);if(!Number.isFinite(scheduledAt.getTime()))throw new Error('Scheduled alarm occurrence must be an absolute date.'); const releaseLock=deps.acquireLock(alarmId,scheduledAt.toISOString());if(!releaseLock){const current=deps.store.getAlarm(alarmId);if(current)try{await deps.scheduler.sync(current);}catch{}return{duplicate:true,message:'This alarm occurrence is already running or completed.'};} - let alarm=deps.store.getAlarm(alarmId);let outcome:AlarmRunRecord|undefined;let session:ActiveAlarmServer|undefined;let lease:Awaited>|undefined;let systemVolumeLease:SystemVolumeLease|undefined;let listening=false;let firedAt:Date|undefined; + let alarm=deps.store.getAlarm(alarmId);let outcome:AlarmRunRecord|undefined;let session:ActiveAlarmServer|undefined;let lease:Awaited>|undefined;let systemVolumeLease:SystemVolumeLease|undefined;let listening=false;let historyStarted=false;let firedAt:Date|undefined; + const runnerWarnings=new Set(); + const recordRunnerWarning=(message:string)=>{runnerWarnings.add(message);try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:listening,message:[...runnerWarnings].join(' ')});}catch{}}; let preserveNextOverride=false; let preserveSystemVolume=false; + let handoffOutput:Promise|undefined; let validTerminalOccurrence=false; + let completeLock=true; let signalReceived=false;let resolveEarlySignal:()=>void=()=>{};let onPlaybackSignal:()=>void=()=>{};const earlySignal=new Promise(resolve=>{resolveEarlySignal=resolve;});const unsubscribeSignals=deps.subscribeSignals?.(()=>{signalReceived=true;resolveEarlySignal();onPlaybackSignal();void deps.player.stop().catch(()=>undefined);}); const finish=(status:AlarmRunRecord['status'],message?:string):AlarmRunRecord=>({status,scheduledAt:scheduledAt.toISOString(),...(firedAt?{firedAt:firedAt.toISOString()}:{}),finishedAt:deps.now().toISOString(),...(message?{message}:{})}); let action:'dismissed'|'snoozed'|'handoff'|'timeout'|'signal'|undefined;let keepPlaying=false;let resolveAction:(value:'dismissed'|'snoozed'|'handoff'|'signal')=>void=()=>{}; @@ -52,7 +57,7 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(!alarm||!alarm.enabled)return{message:'Alarm is missing or disabled.'}; const expected=nextExpectedOccurrence(alarm,scheduledAt);if(!expected||Math.abs(expected.getTime()-scheduledAt.getTime())>1000){outcome=finish('missed','Ignored a stale native task whose occurrence no longer matches this alarm.');return{status:'missed',message:outcome.message};} const assessment=assessScheduledOccurrence(scheduledAt,deps.now(),alarm.reliability.missedRunGraceMinutes); - if(assessment==='pending'){outcome=finish('failed','Scheduler launched the alarm before its occurrence.');return{status:outcome.status,message:outcome.message};} + if(assessment==='pending'){completeLock=false;outcome=finish('failed','Scheduler launched the alarm before its occurrence.');return{status:outcome.status,message:outcome.message};} validTerminalOccurrence=true; if(assessment==='missed'){outcome=finish('missed','Alarm was outside its missed-run grace window.');return{status:outcome.status,message:outcome.message};} const claimingStatus:ActiveAlarmStatus={alarmId,scheduledAt:scheduledAt.toISOString(),stationName:alarm.station.name,station:alarm.station,startedAt:deps.now().toISOString(),state:'starting'}; @@ -60,7 +65,7 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR onDismiss:()=>{action='dismissed';resolveAction('dismissed');resolveEarlySignal();}, onSnooze:minutes=>{deps.store.snoozeAlarm(alarmId,new Date(deps.now().getTime()+minutes*60_000));preserveNextOverride=true;action='snoozed';resolveAction('snoozed');resolveEarlySignal();}, onKeepPlaying:()=>{keepPlaying=true;session?.update({keepPlaying:true});}, - onHandoff:()=>{if(!listening)throw new Error('Alarm playback is still starting.');preserveSystemVolume=true;action='handoff';resolveAction('handoff');} + onHandoff:async()=>{if(!listening)throw new Error('Alarm playback is still starting.');handoffOutput=(systemVolumeLease?.release({preserve:true})??Promise.resolve()).then(()=>{preserveSystemVolume=true;});await handoffOutput;action='handoff';resolveAction('handoff');} });void creatingSession.then(created=>{if(signalReceived)void created.close().catch(()=>undefined);}).catch(()=>{});const created=await Promise.race([creatingSession.then(value=>({value})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in created){outcome=finish('dismissed','Alarm interrupted while local controls were starting.');return{status:'dismissed',message:outcome.message};}session=created.value; const claimed=deps.store.getAlarm(alarmId);if(claimed?.enabled&&claimed.schedule.type==='recurring'){if(deps.scheduler.syncClaimed)await deps.scheduler.syncClaimed(claimed,scheduledAt);else await deps.scheduler.sync(claimed);} let resolvedStation=alarm.station;let lastError:unknown; @@ -73,10 +78,12 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR if(lastError)throw lastError; firedAt=deps.now(); try{const acquiring=deps.inhibitor.acquire('RadioCLI alarm playback');void acquiring.then(acquired=>{if(signalReceived)void acquired.release().catch(()=>undefined);}).catch(()=>{});const acquired=await Promise.race([acquiring.then(value=>({value})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in acquired){outcome=finish('dismissed','Alarm interrupted while sleep protection was starting.');return{status:'dismissed',message:outcome.message};}lease=acquired.value;deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:true,active:true,message:'Sleep inhibition active while playing.'});void lease.unexpectedExit?.then(error=>{try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:false,message:`Playback continues after sleep protection exited: ${error.message}`});}catch{}}).catch(()=>{});}catch(error){deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:false,message:`Playback continues without sleep protection: ${errorMessage(error)}`});} - const startedAt=deps.now();deps.store.addRecent(resolvedStation);deps.store.startListeningSession(resolvedStation,startedAt);listening=true; + const startedAt=deps.now();listening=true; + try{deps.store.addRecent(resolvedStation);}catch(error){recordRunnerWarning(`Recent listening history could not be saved: ${errorMessage(error)}`);} + try{deps.store.startListeningSession(resolvedStation,startedAt);historyStarted=true;}catch(error){recordRunnerWarning(`Listening history could not be started: ${errorMessage(error)}`);} const activeStatus:ActiveAlarmStatus={alarmId,scheduledAt:scheduledAt.toISOString(),stationName:resolvedStation.name,station:resolvedStation,startedAt:startedAt.toISOString(),state:'playing'}; session.update(activeStatus); - void deps.openControls?.(activeStatus).catch(error=>{try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:true,message:`Alarm is playing, but RadioCLI controls could not open automatically: ${errorMessage(error)}`});}catch{}}); + void deps.openControls?.(activeStatus).then(result=>{if(result&&!result.opened&&result.terminal!=='existing-tui')recordRunnerWarning(`RadioCLI controls are unavailable or unverified: ${result.message}`);}).catch(error=>{recordRunnerWarning(`RadioCLI controls could not open automatically: ${errorMessage(error)}`);}); onPlaybackSignal=()=>{action='signal';resolveAction('signal');};if(signalReceived)onPlaybackSignal(); { const supportsFade=deps.player.getState().backend==='mpv'; @@ -88,27 +95,32 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR return{status:outcome.status,message:outcome.message}; }catch(error){outcome=finish('failed',errorMessage(error));return{status:'failed',message:outcome.message};} finally{ + listening=false; unsubscribeSignals?.(); try{await deps.player.stop();}catch{} + await handoffOutput?.catch(()=>undefined); if(!preserveSystemVolume)try{await systemVolumeLease?.release();}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Previous system output volume could not be restored: ${errorMessage(error)}`});}catch{}} - if(listening){try{deps.store.checkpointActiveListeningSession(deps.now());deps.store.finishActiveListeningSession(deps.now());}catch{}} + if(historyStarted){ + try{deps.store.checkpointActiveListeningSession(deps.now());}catch(error){recordRunnerWarning(`Listening history could not be checkpointed: ${errorMessage(error)}`);} + try{deps.store.finishActiveListeningSession(deps.now());}catch(error){recordRunnerWarning(`Listening history could not be finished: ${errorMessage(error)}`);} + } try{await lease?.release();if(lease)deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:true,active:false,message:'Playback sleep inhibition released.'});}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'power',healthy:false,active:true,message:`Unable to verify inhibitor release: ${errorMessage(error)}`});}catch{}} try{await session?.close();}catch{} - if(alarm&&outcome){try{deps.store.recordAlarmOutcome(alarmId,outcome,{clearNextOverride:!preserveNextOverride});const latest=deps.store.getAlarm(alarmId);if(alarm.schedule.type==='once'&&latest?.schedule.type==='once'&&latest.schedule.at===alarm.schedule.at&&validTerminalOccurrence&&!preserveNextOverride)deps.store.toggleAlarm(alarmId,false);}catch{}} + if(alarm&&outcome){try{deps.store.recordAlarmOutcome(alarmId,outcome,{clearNextOverride:validTerminalOccurrence&&!preserveNextOverride});const latest=deps.store.getAlarm(alarmId);if(alarm.schedule.type==='once'&&latest?.schedule.type==='once'&&latest.schedule.at===alarm.schedule.at&&validTerminalOccurrence&&!preserveNextOverride)deps.store.toggleAlarm(alarmId,false);}catch(error){recordRunnerWarning(`Alarm outcome or completion state could not be saved: ${errorMessage(error)}`);}} alarm=deps.store.getAlarm(alarmId);if(alarm)try{if(deps.scheduler.syncClaimed)await deps.scheduler.syncClaimed(alarm,scheduledAt);else await deps.scheduler.sync(alarm);}catch{} - try{releaseLock();}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Occurrence lock cleanup failed: ${errorMessage(error)}`});}catch{}} - try{await deps.scheduler.completeOccurrence?.(alarmId,scheduledAt);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'scheduler',healthy:false,message:`Completed launch job cleanup failed: ${errorMessage(error)}`});}catch{}} + try{releaseLock(completeLock);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Occurrence lock cleanup failed: ${errorMessage(error)}`});}catch{}} + if(completeLock)try{await deps.scheduler.completeOccurrence?.(alarmId,scheduledAt);}catch(error){try{deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'scheduler',healthy:false,message:`Completed launch job cleanup failed: ${errorMessage(error)}`});}catch{}} } } -export function acquireOccurrenceLock(alarmId:string,scheduledAt:string,root=defaultAlarmRuntimeDirectory()):(()=>void)|null{ +export function acquireOccurrenceLock(alarmId:string,scheduledAt:string,root=defaultAlarmRuntimeDirectory()):((completed?:boolean)=>void)|null{ const safe=`${alarmId.replace(/[^a-zA-Z0-9_-]/g,'_').slice(0,80)}-${Buffer.from(scheduledAt).toString('base64url')}`;const path=join(root,'locks',safe);mkdirSync(dirname(path),{recursive:true,mode:0o700});pruneCompletedOccurrenceLocks(root); try{mkdirSync(path,{mode:0o700});}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;try{if(existsSync(join(path,'completed')))return null;const pid=Number(readFileSync(join(path,'running'),'utf8'));if(Number.isInteger(pid)&&processAlive(pid))return null;rmSync(path,{recursive:true,force:true});mkdirSync(path,{mode:0o700});}catch{return null;}} writeFileSync(join(path,'running'),String(process.pid),{mode:0o600}); - return()=>{rmSync(join(path,'running'),{force:true});writeFileSync(join(path,'completed'),new Date().toISOString(),{mode:0o600});}; + return(completed=true)=>{if(!completed){rmSync(path,{recursive:true,force:true});return;}rmSync(join(path,'running'),{force:true});writeFileSync(join(path,'completed'),new Date().toISOString(),{mode:0o600});}; } export function pruneCompletedOccurrenceLocks(root=defaultAlarmRuntimeDirectory(),olderThanMs=30*24*60*60_000,now=Date.now()){const directory=join(root,'locks');if(!existsSync(directory))return 0;let removed=0;for(const name of readdirSync(directory)){const completed=join(directory,name,'completed');try{if(existsSync(completed)&&now-statSync(completed).mtimeMs>olderThanMs){rmSync(join(directory,name),{recursive:true,force:true});removed+=1;}}catch{}}return removed;} -export function defaultAlarmRuntimeDirectory():string{if(process.env.RADIOCLI_HOME)return join(process.env.RADIOCLI_HOME,'runtime');if(process.platform==='win32')return join(process.env.LOCALAPPDATA??join(homedir(),'AppData','Local'),'RadioCLI','runtime');return join(process.env.XDG_RUNTIME_DIR??join(homedir(),'.local','state'),'radiocli');} +export function defaultAlarmRuntimeDirectory():string{return platformPaths().runtime;} export const defaultRunnerUtilities={acquireLock:acquireOccurrenceLock,createSession:startActiveAlarmSession,wait:(milliseconds:number)=>new Promise(resolve=>setTimeout(resolve,milliseconds)),subscribeSignals:(handler:()=>void)=>{process.once('SIGTERM',handler);process.once('SIGHUP',handler);process.once('SIGINT',handler);return()=>{process.off('SIGTERM',handler);process.off('SIGHUP',handler);process.off('SIGINT',handler);};}}; function errorMessage(error:unknown){return error instanceof Error?error.message:String(error);} function processAlive(pid:number){try{process.kill(pid,0);return true;}catch{return false;}} diff --git a/src/alarms/schedule.test.ts b/src/alarms/schedule.test.ts index 7bb14f7..8e703b6 100644 --- a/src/alarms/schedule.test.ts +++ b/src/alarms/schedule.test.ts @@ -1,8 +1,9 @@ -import {describe, expect, it} from 'vitest'; +import {describe, expect, it, vi} from 'vitest'; import { assessScheduledOccurrence, canonicalizeAlarmTime, canonicalizeIsoWeekdays, + canonicalizeTimeZone, isValidTimeZone, nextAlarmOccurrence, nextOccurrenceForAlarm @@ -120,4 +121,20 @@ describe('alarm scheduling', () => { expect(() => canonicalizeIsoWeekdays([])).toThrow(/weekday/i); expect(() => canonicalizeIsoWeekdays([0, 8])).toThrow(/weekday/i); }); + + it('canonicalizes each alarm timezone with one native formatter construction', () => { + const expected = new Intl.DateTimeFormat('en-US', {timeZone: 'US/Eastern'}).resolvedOptions().timeZone; + const NativeDateTimeFormat = Intl.DateTimeFormat; + const formatter = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function (...args: Parameters) { + return new NativeDateTimeFormat(...args); + } as typeof Intl.DateTimeFormat); + try { + expect(canonicalizeTimeZone(' US/Eastern ')).toBe(expected); + expect(formatter).toHaveBeenCalledTimes(1); + expect(() => canonicalizeTimeZone('Mars/Olympus_Mons')).toThrow('Invalid IANA timezone: Mars/Olympus_Mons'); + expect(() => canonicalizeTimeZone(' ')).toThrow(/Invalid IANA timezone/); + } finally { + formatter.mockRestore(); + } + }); }); diff --git a/src/alarms/schedule.ts b/src/alarms/schedule.ts index 430e302..7b8f04c 100644 --- a/src/alarms/schedule.ts +++ b/src/alarms/schedule.ts @@ -16,10 +16,15 @@ export function isValidTimeZone(timezone: string): boolean { export function canonicalizeTimeZone(timezone: string): string { const value = timezone.trim(); - if (!isValidTimeZone(value)) { + try { + if (!value) throw new Error('Empty timezone.'); + // The constructor validates the zone and resolves its canonical name. + // Constructing twice made large-library validation expensive on Node 22 + // Intel hosts; keep one native validation for every supplied value. + return new Intl.DateTimeFormat('en-US', {timeZone: value}).resolvedOptions().timeZone; + } catch { throw new Error(`Invalid IANA timezone: ${timezone}`); } - return new Intl.DateTimeFormat('en-US', {timeZone: value}).resolvedOptions().timeZone; } export function canonicalizeAlarmTime(time: string): string { diff --git a/src/alarms/scheduler.test.ts b/src/alarms/scheduler.test.ts index 01f90d4..fce30c2 100644 --- a/src/alarms/scheduler.test.ts +++ b/src/alarms/scheduler.test.ts @@ -1,9 +1,12 @@ import {describe, expect, it, vi} from 'vitest'; import {existsSync,mkdirSync,mkdtempSync,readdirSync,rmSync,writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; -import {dirname,join} from 'node:path'; +import {homedir,tmpdir} from 'node:os'; +import {basename,dirname,join,posix,win32} from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; import type {Alarm} from '../types.js'; import {createSchedulerAdapter, SchedulerService,shouldRunLaunchdOccurrence,type SchedulerAdapter} from './scheduler.js'; +import {pathEnvironmentKeys, platformPaths} from '../platform/paths.js'; const alarm: Alarm = { id: 'wake<&"', label: 'Morning <&>', enabled: true, @@ -14,6 +17,46 @@ const alarm: Alarm = { createdAt: '2029-01-01T00:00:00.000Z', updatedAt: '2029-01-01T00:00:00.000Z' }; +function windowsTaskInvocation(body: string): {command: string; args: string[]} { + expect(body).toContain('%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); + const argumentsText = /([^<]+)<\/Arguments>/.exec(body)?.[1]; + expect(argumentsText).toBeDefined(); + const args = argumentsText!.split(' '); + const script = Buffer.from(args[args.indexOf('-EncodedCommand') + 1]!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(encoded).toBeDefined(); + return JSON.parse(Buffer.from(encoded!, 'base64').toString('utf8')); +} + +const decodeXml = (value: string) => value.replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll(''', "'").replaceAll('&', '&'); +function schedulerInvocation(body: string, platform: 'darwin' | 'linux' | 'win32'): {command: string; args: string[]} { + if (platform === 'win32') return windowsTaskInvocation(body); + const values: string[] = platform === 'darwin' + ? [.../ProgramArguments<\/key>([\s\S]*?)<\/array>/.exec(body)![1]!.matchAll(/([\s\S]*?)<\/string>/g)].map(match => decodeXml(match[1]!)) + : [.../^ExecStart=:(.+)$/m.exec(body)![1]!.matchAll(/"(?:[^"\\]|\\.)*"/g)].map(match => (JSON.parse(match[0]) as string).replaceAll('%%', '%')); + return {command: values[0]!, args: values.slice(1)}; +} +function schedulerPayload(body: string, platform: 'darwin' | 'linux' | 'win32'): {args: string[]; environment: Record} { + return JSON.parse(Buffer.from(schedulerInvocation(body, platform).args.at(-1)!, 'base64url').toString('utf8')); +} + +function schedulerEnvironment(body: string, platform: 'darwin' | 'linux' | 'win32'): Record { + if (platform === 'win32') return {}; + if (platform === 'darwin') { + const environment = /EnvironmentVariables<\/key>([\s\S]*?)<\/dict>/.exec(body)![1]!; + return Object.fromEntries([...environment.matchAll(/([^<]+)<\/key>([^<]*)<\/string>/g)].map(match => [decodeXml(match[1]!), decodeXml(match[2]!)])); + } + return Object.fromEntries([...body.matchAll(/^Environment=(.+)$/gm)].map(match => { + const entry = (JSON.parse(match[1]!) as string).replaceAll('%%', '%'); + return [entry.slice(0, entry.indexOf('=')), entry.slice(entry.indexOf('=') + 1)]; + })); +} + +const windowsTaskPath = (id: string) => `\\RadioCLI\\io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`; +function windowsLookupResult(overrides: Record = {}) { + return {code: 0, stdout: JSON.stringify({taskPath: windowsTaskPath(alarm.id), stage: 'lookup', status: 'error', hresult: -2147024894, message: 'El sistema no puede encontrar el archivo especificado.', ...overrides}), stderr: ''}; +} + describe('native alarm schedulers', () => { it('generates safely escaped launchd artifacts and an argv-only command', async () => { const write = vi.fn(); const run = vi.fn(async () => ({code: 0, stdout: '', stderr: ''})); @@ -25,14 +68,14 @@ describe('native alarm schedulers', () => { expect(xml).toContain('/Node A/node'); expect(xml).not.toContain('Year'); expect(xml).not.toContain('Second'); - expect(xml).toContain('internal-launchd'); + expect(schedulerPayload(xml, 'darwin').args).toEqual(['/App A/dist/cli.js', 'alarm', 'internal-launchd', alarm.id, '2030-02-03T14:05:00.000Z']); expect(xml).toContain('RADIOCLI_HOME/Data & A'); expect(xml).toContain('RADIOCLI_ALARM_TERMINALdarwin:apple-terminal'); expect((run.mock.calls as unknown as Array<[string,string[]]>).every(call => Array.isArray(call[1]))).toBe(true); }); it('makes an early annual launchd calendar match a true no-op',()=>{expect(shouldRunLaunchdOccurrence('2035-02-03T14:05:00Z',new Date('2030-02-03T14:05:00Z'))).toBe(false);expect(shouldRunLaunchdOccurrence('2035-02-03T14:05:00Z',new Date('2035-02-03T14:05:00Z'))).toBe(true);}); - it('installs the next launchd occurrence without booting out the current runner',async()=>{const writes:Array<[string,string]>=[];const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='print'?1:0,stdout:'',stderr:''}));const adapter=createSchedulerAdapter({platform:'darwin',home:'/Users/a',nodePath:'/node',cliPath:'/cli.js',writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run,env:{}});const recurring:Alarm={...alarm,schedule:{type:'recurring',time:'14:05',weekdays:[1,2,3,4,5,6,7],timezone:'UTC'}};const service=new SchedulerService(adapter,()=>new Date('2030-02-03T14:06:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[])} as never);await expect(service.syncClaimed(recurring,new Date('2030-02-03T14:05:00Z'))).resolves.toEqual(new Date('2030-02-04T14:05:00Z'));expect(run.mock.calls.map(call=>call[1]?.[0])).not.toContain('bootout');expect(run).toHaveBeenCalledWith('launchctl',expect.arrayContaining(['bootstrap']));expect(writes[0]?.[0]).toMatch(/io\.radiocli\.alarm\.[a-f0-9]+\.[a-f0-9]+\.plist$/);expect(writes[0]?.[1]).toContain('2030-02-04T14:05:00.000Z');}); + it('installs the next launchd occurrence without booting out the current runner',async()=>{const writes:Array<[string,string]>=[];const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='print'?1:0,stdout:'',stderr:''}));const adapter=createSchedulerAdapter({platform:'darwin',home:'/Users/a',nodePath:'/node',cliPath:'/cli.js',writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run,env:{}});const recurring:Alarm={...alarm,schedule:{type:'recurring',time:'14:05',weekdays:[1,2,3,4,5,6,7],timezone:'UTC'}};const service=new SchedulerService(adapter,()=>new Date('2030-02-03T14:06:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[])} as never);await expect(service.syncClaimed(recurring,new Date('2030-02-03T14:05:00Z'))).resolves.toEqual(new Date('2030-02-04T14:05:00Z'));expect(run.mock.calls.map(call=>call[1]?.[0])).not.toContain('bootout');expect(run).toHaveBeenCalledWith('launchctl',expect.arrayContaining(['bootstrap']));expect(writes[0]?.[0]).toMatch(/io\.radiocli\.alarm\.[a-f0-9]+\.[a-f0-9]+\.plist$/);expect(schedulerPayload(writes[0]![1],'darwin').args).toContain('2030-02-04T14:05:00.000Z');}); it.each([['nonzero',false],['rejected',true]] as const)('retains a stable launchd artifact when bootout is %s and the job is still loaded',async(_label,rejected)=>{const remove=vi.fn();const run=vi.fn(async(_command:string,args:string[])=>{if(args[0]==='bootout'){if(rejected)throw new Error('transport');return{code:5,stdout:'',stderr:'busy'};}return{code:0,stdout:'loaded',stderr:''};});const adapter=createSchedulerAdapter({platform:'darwin',home:'/Users/a',nodePath:'/node',cliPath:'/cli.js',writeFile:vi.fn(),removeFile:remove,run,env:{}});await expect(adapter.completeOccurrence!(alarm.id,new Date(alarm.schedule.type==='once'?alarm.schedule.at:''))).rejects.toThrow(/still loaded/i);expect(remove).not.toHaveBeenCalled();}); @@ -52,7 +95,7 @@ describe('native alarm schedulers', () => { await adapter.install(alarm, new Date('2030-02-03T14:05:00.000Z')); expect(writes.map(item => item[1]).join('\n')).toContain('Persistent=true'); expect(writes.map(item => item[1]).join('\n')).toContain('AccuracySec=1s'); - expect(writes.map(item => item[1]).join('\n')).toContain('"/opt/radio cli/dist/cli.js"'); + expect(schedulerPayload(writes.find(([path]) => path.endsWith('.service'))![1], 'linux').args).toEqual(['/opt/radio cli/dist/cli.js', 'alarm', 'internal-run', alarm.id, '2030-02-03T14:05:00.000Z']); expect(writes.map(item => item[1]).join('\n')).toContain('RADIOCLI_ALARM_TERMINAL=linux:unsupported'); }); @@ -62,6 +105,22 @@ describe('native alarm schedulers', () => { expect(adapter.capabilities().message).toMatch(/systemd/i); }); + it('retains scheduler health and repair artifacts when systemctl is unavailable',async()=>{ + const run=vi.fn();const removeFile=vi.fn();const health={record:vi.fn(),list:()=>[],get:()=>[],remove:vi.fn()}; + const adapter=createSchedulerAdapter({platform:'linux',home:'/missing-radiocli-home',env:{},commandExists:()=>false,run,removeFile,writeFile:vi.fn()}); + const service=new SchedulerService(adapter,()=>new Date(),health as never); + await expect(service.remove(alarm.id)).rejects.toThrow(/systemctl.*unavailable|verify.*remov|verify.*absence/i); + expect(health.remove).not.toHaveBeenCalled();expect(removeFile).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled(); + expect(health.record).toHaveBeenCalledWith(expect.objectContaining({alarmId:alarm.id,component:'scheduler',healthy:false,message:expect.stringContaining('retained')})); + }); + + it('allows removal of a definition on a runtime with no native scheduler adapter',async()=>{ + const run=vi.fn();const adapter=createSchedulerAdapter({platform:'freebsd',env:{},run}); + const health={record:vi.fn(),list:()=>[],get:()=>[],remove:vi.fn()}; + await expect(new SchedulerService(adapter,()=>new Date(),health as never).remove(alarm.id)).resolves.toBeUndefined(); + expect(health.remove).toHaveBeenCalledWith(alarm.id);expect(run).not.toHaveBeenCalled(); + }); + it('creates a current-user Windows task with wake and local interactive audio semantics', async () => { const write=vi.fn();const run=vi.fn(async()=>({code:0,stdout:'',stderr:''})); const adapter=createSchedulerAdapter({platform:'win32',home:'C:\\Users\\A B',nodePath:'C:\\Node A\\node.exe',cliPath:'C:\\Radio A\\dist\\cli.js',writeFile:write,removeFile:vi.fn(),run,env:{LOCALAPPDATA:'C:\\Data A',RADIOCLI_HOME:'C:\\Profile & A'}}); @@ -74,18 +133,90 @@ describe('native alarm schedulers', () => { expect(artifact).toContain('false'); expect(artifact).toContain('PT0S'); expect(artifact).toContain('encoding="UTF-8"'); - expect(artifact).toContain('RADIOCLI_HOME=C:\\Profile ^& A" &&'); - expect(artifact).toContain('RADIOCLI_ALARM_TERMINAL=win32:console'); + const invocation=windowsTaskInvocation(artifact); + expect(invocation.command).toBe('C:\\Node A\\node.exe'); + expect(JSON.parse(Buffer.from(invocation.args.at(-1)!,'base64url').toString('utf8')).environment).toEqual({LOCALAPPDATA:'C:\\Data A',RADIOCLI_HOME:'C:\\Profile & A',RADIOCLI_ALARM_TERMINAL:'win32:console',...(process.platform==='win32'?{USERPROFILE:homedir()}:{})}); expect(artifact).not.toContain('&amp;'); expect(adapter.capabilities().exactWake).toBe(false); expect((run.mock.calls as unknown as Array<[string,string[]]>)[0]?.[1]).toContain('/XML'); }); + it('preserves literal Windows job arguments and approved environment values',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-windows-job-')); + try{ + const cliPath=join(root,"Radio $ %PATH%! & ' 单播.cjs");const writes:Array<[string,string]>=[]; + const configured={RADIOCLI_HOME:"C:\\Data %PATH%! & ' 单播",RADIOCLI_MPV_PATH:"C:\\Players %PATH%! & ' 单播\\mpv.exe",RADIOCLI_FFPLAY_PATH:'C:\\Players A\\ffplay.exe',RADIOCLI_VLC_PATH:'C:\\Players A\\vlc.exe',RADIOCLI_FFMPEG_PATH:'C:\\Players A\\ffmpeg.exe',RADIOCLI_OFFLINE:'0',RADIOCLI_LOW_BANDWIDTH:'1'}; + writeFileSync(cliPath,`process.stdout.write(JSON.stringify({args:process.argv.slice(2),environment:Object.fromEntries(${JSON.stringify(Object.keys(configured))}.map(key=>[key,process.env[key]]))}))`); + const adapter=createSchedulerAdapter({platform:'win32',home:root,nodePath:process.execPath,cliPath,env:{...configured,HTTPS_PROXY:'https://private-password@proxy.invalid'},writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); + const {command,args}=windowsTaskInvocation(writes[0]![1]);expect(command).toBe(process.execPath);expect(args).toHaveLength(3);expect(args[0]).toBe('-e'); + const payload=JSON.parse(Buffer.from(args.at(-1)!,'base64url').toString('utf8'));expect(payload.environment).toEqual({...configured,RADIOCLI_ALARM_TERMINAL:'win32:console',...(process.platform==='win32'?{USERPROFILE:homedir()}:{})});expect(JSON.stringify(payload)).not.toContain('private-password'); + const output=execFileSync(command,args,{encoding:'utf8',windowsHide:true}); + expect(JSON.parse(output)).toEqual({args:['alarm','internal-run',alarm.id,'2030-02-03T14:05:00.000Z'],environment:configured}); + }finally{rmSync(root,{recursive:true,force:true});} + }); + it('pins systemd timers in UTC and escapes specifiers',async()=>{ const writes:Array<[string,string]>=[];const adapter=createSchedulerAdapter({platform:'linux',home:'/home/a',nodePath:'/node%p',cliPath:'/app%h/cli.js',writeFile:(p,c)=>writes.push([p,c]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''})),commandExists:()=>true,env:{RADIOCLI_HOME:'/data%u'}}); await adapter.install(alarm,new Date('2030-02-03T14:05:00Z'));const units=writes.map(item=>item[1]).join('\n');expect(units).toContain('2030-02-03 14:05:00 UTC');expect(units).toContain('/node%%p');expect(units).toContain('RADIOCLI_HOME=/data%%u'); }); + it('disables systemd command expansion while preserving literal environment values',async()=>{ + const writes:Array<[string,string]>=[]; + const adapter=createSchedulerAdapter({platform:'linux',home:'/home/a',nodePath:'/Node $ ${CUSTOM} % 单播/node',cliPath:'/app-${CUSTOM}/% spaced 单播/cli.js',env:{RADIOCLI_HOME:'/data $ ${CUSTOM} % 单播'},commandExists:()=>true,writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); + const service=writes.find(([path])=>path.endsWith('.service'))![1]; + expect(service).toContain('ExecStart=:"/Node $ ${CUSTOM} %% 单播/node" "-e"'); + expect(schedulerPayload(service, 'linux').args[0]).toBe('/app-${CUSTOM}/% spaced 单播/cli.js'); + expect(service).toContain('Environment="RADIOCLI_HOME=/data $ ${CUSTOM} %% 单播"'); + expect(service).not.toContain('$$'); + }); + + it.each(['darwin','linux'] as const)('preserves explicit player locations and network policy without proxy secrets in %s jobs',async platform=>{ + const writes:Array<[string,string]>=[]; + const configured={RADIOCLI_MPV_PATH:'/Players A/mpv',RADIOCLI_FFPLAY_PATH:'/Players A/ffplay',RADIOCLI_VLC_PATH:'/Players A/vlc',RADIOCLI_FFMPEG_PATH:'/Players A/ffmpeg',RADIOCLI_OFFLINE:'0',RADIOCLI_LOW_BANDWIDTH:'1'}; + const adapter=createSchedulerAdapter({platform,home:'/home/a',nodePath:'/node',cliPath:'/cli.js',env:{...configured,HTTPS_PROXY:'https://proxy-user:private-password@proxy.invalid',ARBITRARY_SECRET:'private-secret'},commandExists:()=>true,writeFile:(path,body)=>writes.push([path,body]),removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z')); + const artifact=writes.map(([,body])=>body).join('\n'); + for(const [key,value] of Object.entries(configured))expect(artifact).toContain(platform==='darwin'?`${key}${value}`:`Environment="${key}=${value}"`); + expect(artifact).not.toMatch(/proxy-user|private-password|private-secret|HTTPS_PROXY|ARBITRARY_SECRET/); + }); + + it.each(['darwin', 'linux', 'win32'] as const)('runs the generated %s scheduler command with its saved identity in clean and contaminated environments', async platform => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-scheduler-identity-')); + try { + const cliPath = join(root, "inspect $ % & ' 単播.cjs"); + writeFileSync(cliPath, `process.stdout.write(JSON.stringify({args:process.argv.slice(2),env:Object.fromEntries(${JSON.stringify(pathEnvironmentKeys)}.filter(key=>process.env[key]!==undefined).map(key=>[key,process.env[key]]))}))`); + const path = platform === 'win32' ? win32 : posix; + // A Windows filesystem path is not an absolute POSIX path. Use a virtual + // POSIX identity when exercising Darwin/Linux artifacts on Windows. + const dataRoot = platform === 'win32' ? 'C:\\Users\\scheduler-fixture' : process.platform === 'win32' ? '/scheduler-fixture' : root; + const configured = {HOME: dataRoot, USERPROFILE: path.join(dataRoot, 'profile'), APPDATA: '', LOCALAPPDATA: path.join(dataRoot, 'local'), RADIO_ATLAS_HOME: path.join(dataRoot, "alias $ % & ' 単播"), XDG_DATA_HOME: path.join(dataRoot, 'data'), XDG_CACHE_HOME: path.join(dataRoot, 'cache'), XDG_RUNTIME_DIR: path.join(dataRoot, 'runtime')}; + const writes: Array<[string, string]> = []; + const adapter = createSchedulerAdapter({platform, home: root, nodePath: process.execPath, cliPath, env: {...configured, HTTPS_PROXY: 'private-proxy-secret'}, commandExists: () => true, writeFile: (path, body) => writes.push([path, body]), removeFile: vi.fn(), run: vi.fn(async () => ({code: 0, stdout: '', stderr: ''}))}); + await adapter.install(alarm, new Date('2030-02-03T14:05:00Z')); + const artifact = writes.find(([path]) => platform !== 'linux' || path.endsWith('.service'))![1]; + const invocation = schedulerInvocation(artifact, platform); + const clean = {...process.env}; + for (const key of pathEnvironmentKeys) delete clean[key]; + const contaminated = {...clean, ...Object.fromEntries(pathEnvironmentKeys.map(key => [key, `${root}/stale-${key}`]))}; + for (const env of [clean, contaminated]) { + const observed = JSON.parse(execFileSync(invocation.command, invocation.args, {env: {...env, ...schedulerEnvironment(artifact, platform)}, encoding: 'utf8', windowsHide: true})); + expect(observed.args).toEqual(['alarm', platform === 'darwin' ? 'internal-launchd' : 'internal-run', alarm.id, '2030-02-03T14:05:00.000Z']); + // An empty APPDATA root denotes the invoking directory. The snapshot + // retains that location even if the native manager has another cwd. + expect(observed.env).toEqual({...configured, APPDATA: path.resolve(process.cwd())}); + expect(platformPaths({platform, home: root, env: observed.env})).toEqual(platformPaths({platform, home: root, env: configured})); + } + expect(artifact).not.toContain('private-proxy-secret'); + } finally {rmSync(root, {recursive: true, force: true});} + }); + + it.each(['darwin','linux','win32'] as const)('rejects control characters in an approved %s job environment before writing artifacts',async platform=>{ + const writeFile=vi.fn();const adapter=createSchedulerAdapter({platform,home:'/home/a',nodePath:'/node',cliPath:'/cli.js',env:{RADIOCLI_MPV_PATH:'/tools/mpv\nInjected=value'},commandExists:()=>true,writeFile,removeFile:vi.fn(),run:vi.fn(async()=>({code:0,stdout:'',stderr:''}))}); + await expect(adapter.install(alarm,new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/RADIOCLI_MPV_PATH.*control/i);expect(writeFile).not.toHaveBeenCalled(); + }); + it('removes disabled alarms and reschedules enabled alarms', async () => { const adapter = {capabilities: vi.fn(() => ({supported: true, exactWake: false, catchUpAfterWake: true, message: 'ok'})), install: vi.fn(), remove: vi.fn(), status: vi.fn()}; const service = new SchedulerService(adapter, () => new Date('2029-01-01T00:00:00Z'), {record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[])} as never); @@ -113,4 +244,219 @@ describe('native alarm schedulers', () => { it('fails disable/removal when an active Guard cannot be verified stopped',async()=>{const adapter:SchedulerAdapter={capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'ok'}),install:vi.fn(),remove:vi.fn(),status:vi.fn()};const record=vi.fn();const guard={start:vi.fn(),stop:vi.fn(async()=>false),status:vi.fn(async()=>({guards:[{alarmId:alarm.id}]}))};const service=new SchedulerService(adapter,()=>new Date('2029-01-01T00:00:00Z'),{record,list:vi.fn(()=>[]),get:vi.fn(()=>[]),remove:vi.fn()} as never,guard);await expect(service.sync({...alarm,enabled:false})).rejects.toThrow(/teardown|verified/i);expect(record).toHaveBeenCalledWith(expect.objectContaining({component:'power',healthy:false}));await expect(service.remove(alarm.id)).rejects.toThrow(/retained/i);expect(adapter.remove).toHaveBeenCalledTimes(1);}); it('accepts false Guard stop only when status verifies no matching Guard exists',async()=>{const adapter:SchedulerAdapter={capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'ok'}),install:vi.fn(),remove:vi.fn(),status:vi.fn()};const guard={start:vi.fn(),stop:vi.fn(async()=>false),status:vi.fn(async()=>({guards:[]}))};const service=new SchedulerService(adapter,()=>new Date('2029-01-01T00:00:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[]),remove:vi.fn()} as never,guard);await expect(service.sync({...alarm,enabled:false})).resolves.toBeNull();await expect(service.remove(alarm.id)).resolves.toBeUndefined();}); it('accepts a successful authenticated Guard teardown',async()=>{const adapter:SchedulerAdapter={capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'ok'}),install:vi.fn(),remove:vi.fn(),status:vi.fn()};const guard={start:vi.fn(),stop:vi.fn(async()=>true),status:vi.fn()};const service=new SchedulerService(adapter,()=>new Date('2029-01-01T00:00:00Z'),{record:vi.fn(),list:vi.fn(()=>[]),get:vi.fn(()=>[]),remove:vi.fn()} as never,guard);await expect(service.sync({...alarm,enabled:false})).resolves.toBeNull();expect(guard.status).not.toHaveBeenCalled();}); + + it.each(['darwin', 'linux', 'win32'] as const)('retains %s repair artifacts when native removal fails and absence is uncertain', async platform => { + const removeFile = vi.fn(); + const run = vi.fn(async () => ({code: 1, stdout: '', stderr: 'Permission denied'})); + const adapter = createSchedulerAdapter({platform, home: '/missing-radiocli-test-home', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(/remov|absen|disable|bootout|delete/i); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'linux', 'win32'] as const)('retains %s repair artifacts when the targeted native job is still registered', async platform => { + const removeFile = vi.fn(); + const run = vi.fn(async (_command: string, args: string[]) => { + if (args[0] === 'print' || args[0] === '/Query') return {code: 0, stdout: 'registered', stderr: ''}; + if (args.includes('show')) return {code: 0, stdout: 'LoadState=loaded\nActiveState=active\n', stderr: ''}; + return {code: 1, stdout: '', stderr: 'busy'}; + }); + const adapter = createSchedulerAdapter({platform, home: '/missing-radiocli-test-home', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'linux', 'win32'] as const)('accepts failed %s removal only after a targeted absence response', async platform => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string, args: string[]) => { + if (args[0] === 'print') return {code: 113, stdout: '', stderr: `Could not find service "${args[1]!.split('/').at(-1)}" in domain for user gui: 501`}; + if (args.includes('show')) return {code: 1, stdout: 'LoadState=not-found\nActiveState=inactive\n', stderr: ''}; + if (command === 'powershell.exe') return windowsLookupResult(); + if (args.includes('daemon-reload')) return {code: 0, stdout: '', stderr: ''}; + return {code: 1, stdout: '', stderr: 'already gone'}; + }); + const adapter = createSchedulerAdapter({platform, home: '/missing-radiocli-test-home', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).resolves.toBeUndefined(); + expect(removeFile).toHaveBeenCalled(); + }); + + it('reports an unreachable systemd user manager without writing native jobs', async () => { + const writeFile = vi.fn(); + const run = vi.fn(async (_command: string, _args: string[]) => ({code: 1, stdout: '', stderr: 'Failed to connect to user bus'})); + const adapter = createSchedulerAdapter({platform: 'linux', env: {}, commandExists: () => true, writeFile, removeFile: vi.fn(), run}); + const service = new SchedulerService(adapter, () => new Date(), {record: vi.fn(), list: () => [], get: () => []} as never); + expect(adapter.capabilities().message).toMatch(/detect|verif|check/i); + const report = await service.runtimeStatus(); + expect(report.capabilities).toMatchObject({supported: false, catchUpAfterWake: false}); + expect(report.capabilities.message).toMatch(/user.*manager|user bus/i); + await expect(adapter.install(alarm, new Date(alarm.schedule.type === 'once' ? alarm.schedule.at : ''))).rejects.toThrow(/user.*manager|user bus/i); + expect(writeFile).not.toHaveBeenCalled(); + expect(run.mock.calls.every(call => call[1].includes('show'))).toBe(true); + }); + + it('bounds a stalled systemd user-manager probe', async () => { + vi.useFakeTimers(); + try { + const adapter = createSchedulerAdapter({platform: 'linux', env: {}, commandExists: () => true, writeFile: vi.fn(), removeFile: vi.fn(), run: () => new Promise(() => {})}); + const service = new SchedulerService(adapter, () => new Date(), {record: vi.fn(), list: () => [], get: () => []} as never); + const pending = service.runtimeStatus(); + await vi.advanceTimersByTimeAsync(3_000); + expect((await pending).capabilities).toMatchObject({supported: false}); + } finally { vi.useRealTimers(); } + }); + + it('uses the injected executable environment for scheduler discovery', () => { + // A relative PATH entry also represents a POSIX search path when this + // injected Linux case runs on Windows, whose drive prefix contains ':'. + const root = mkdtempSync(join(process.cwd(), '.radiocli-scheduler-path-')); + vi.stubEnv('PATH', '/missing-radiocli-executables'); + try { + writeFileSync(join(root, 'systemctl'), '#!/bin/sh\nexit 0\n', {mode: 0o755}); + const adapter = createSchedulerAdapter({platform: 'linux', env: {PATH: basename(root)}, home: root, writeFile: vi.fn(), removeFile: vi.fn(), run: vi.fn()}); + expect(adapter.capabilities().supported).toBe(true); + } finally { vi.unstubAllEnvs(); rmSync(root, {recursive: true, force: true}); } + }); + + it('does not select Linux scheduling for a Termux environment', async () => { + const writeFile = vi.fn(); const run = vi.fn(); + const adapter = createSchedulerAdapter({platform: 'linux', env: {TERMUX_VERSION: 'test'}, commandExists: () => true, writeFile, removeFile: vi.fn(), run}); + expect(adapter.capabilities()).toMatchObject({supported: false, catchUpAfterWake: false}); + await expect(adapter.install(alarm, new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/termux/i); + expect(writeFile).not.toHaveBeenCalled(); expect(run).not.toHaveBeenCalled(); + }); + + it('retains an alarm when Guard ownership remains unresolved', async () => { + const adapter: SchedulerAdapter = {capabilities: () => ({supported: true, exactWake: false, catchUpAfterWake: true, message: 'ok'}), install: vi.fn(), remove: vi.fn(), status: vi.fn()}; + const guard = {start: vi.fn(), stop: vi.fn(async () => false), status: vi.fn(async () => ({guards: [], unresolvedGuards: [{alarmId: alarm.id}]}))}; + const service = new SchedulerService(adapter, () => new Date(), {record: vi.fn(), list: () => [], get: () => [], remove: vi.fn()} as never, guard); + await expect(service.remove(alarm.id)).rejects.toThrow(/verified|retained/i); + expect(adapter.remove).not.toHaveBeenCalled(); + }); + + it('does not accept an absence response for another launchd service sharing the target prefix',async()=>{const removeFile=vi.fn();const run=vi.fn(async(_command:string,args:string[])=>({code:113,stdout:'',stderr:args[0]==='print'?`Could not find service "${args[1]!.split('/').at(-1)}.other" in domain for user gui: 501`:'busy'}));const adapter=createSchedulerAdapter({platform:'darwin',home:'/missing-radiocli-test-home',env:{},writeFile:vi.fn(),removeFile,run});await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*absence/i);expect(removeFile).not.toHaveBeenCalled();}); + + it('keeps a registered but inactive systemd timer unhealthy without mutating its registration',async()=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-systemd-inactive-'));try{ + const cliPath=join(root,'cli.js');writeFileSync(cliPath,'');const run=vi.fn(async(_command:string,args:string[])=>({code:args.includes('is-active')?3:0,stdout:args.includes('is-active')?'inactive':'',stderr:''})); + const writeFile=vi.fn((path:string,body:string)=>{mkdirSync(dirname(path),{recursive:true});writeFileSync(path,body);}); + const adapter=createSchedulerAdapter({platform:'linux',home:root,nodePath:process.execPath,cliPath,env:{},commandExists:()=>true,writeFile,run}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00Z'));writeFile.mockClear();run.mockClear(); + expect(await adapter.status(alarm.id)).toMatchObject({installed:true,healthy:false,message:expect.stringMatching(/not active/i)});expect(writeFile).not.toHaveBeenCalled();expect(run.mock.calls.map(call=>call[1][1])).toEqual(['show','is-enabled','is-active']); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it.each(['darwin','linux','win32'] as const)('retains %s repair artifacts when native commands reject instead of returning status',async platform=>{const removeFile=vi.fn();const adapter=createSchedulerAdapter({platform,env:{},home:'/missing-radiocli-test-home',writeFile:vi.fn(),removeFile,commandExists:()=>true,run:async()=>{throw new Error('native transport failed');}});await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*removal/i);expect(removeFile).not.toHaveBeenCalled();}); + + it.each([ + {hresult: -2147024894, message: 'El sistema no puede encontrar el archivo especificado.'}, + {hresult: -2147024894, message: 'Das System kann die angegebene Datei nicht finden.'}, + {hresult: -2147024893, message: '指定されたパスが見つかりません。'} + ])('removes Windows repair metadata after localized absence HRESULT $hresult: $message', async result => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult(result) : {code: 1, stdout: '', stderr: result.message}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).resolves.toBeUndefined(); + expect(removeFile).toHaveBeenCalledOnce(); + expect(run).toHaveBeenCalledTimes(2); + }); + + it.each([ + {label: 'access denied', hresult: -2147024891, message: 'Zugriff verweigert.'}, + {label: 'unknown failure', hresult: -2147467259, message: 'Erreur non spécifiée.'}, + {label: 'missing COM service', hresult: -2147024894, message: 'Servicio no encontrado.', stage: 'connect'}, + {label: 'inaccessible root folder', hresult: -2147024893, message: 'Chemin introuvable.', stage: 'root'} + ])('retains Windows repair metadata and diagnostics for $label', async ({label: _label, ...result}) => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult(result) : {code: 1, stdout: '', stderr: 'delete failed'}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + const failure = await adapter.remove(alarm.id).catch(error => error as Error); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(result.message); + expect((failure as Error).message).toContain('delete failed'); + expect((failure as Error).message).toContain(`0x${(result.hresult >>> 0).toString(16)}`); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it.each([ + {label: 'empty response', result: {code: 0, stdout: '', stderr: ''}}, + {label: 'plain localized text', result: {code: 1, stdout: '', stderr: 'El sistema no puede encontrar el archivo especificado.'}}, + {label: 'malformed JSON', result: {code: 0, stdout: '{broken', stderr: ''}}, + {label: 'null response', result: {code: 0, stdout: 'null', stderr: ''}}, + {label: 'array response', result: {code: 0, stdout: '[]', stderr: ''}}, + {label: 'string HRESULT', result: windowsLookupResult({hresult: '-2147024894'})}, + {label: 'unsupported status', result: windowsLookupResult({status: 'absent'})}, + {label: 'missing diagnostic', result: windowsLookupResult({message: null})}, + {label: 'unrelated task', result: windowsLookupResult({taskPath: `${windowsTaskPath(alarm.id)}.other`})}, + {label: 'failed process with valid JSON', result: {...windowsLookupResult(), code: 1}}, + {label: 'extra output', result: {...windowsLookupResult(), stdout: `notice\n${windowsLookupResult().stdout}`}} + ])('does not infer Windows task absence from $label', async ({result}) => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? result : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(/verify.*absence|verify.*removal/i); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it('retains Windows repair metadata when successful deletion is followed by a still-present task', async () => { + const removeFile = vi.fn(); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult({status: 'present', hresult: 0, message: ''}) : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + await expect(adapter.remove(alarm.id)).rejects.toThrow(/still registered/i); + expect(removeFile).not.toHaveBeenCalled(); + }); + + it('waits for native Windows absence verification before removing the artifact', async () => { + const removeFile = vi.fn(); + let finishLookup!: (result: ReturnType) => void; + const lookup = new Promise>(resolve => {finishLookup = resolve;}); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? lookup : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + const pending = adapter.remove(alarm.id); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2)); + expect(removeFile).not.toHaveBeenCalled(); + finishLookup(windowsLookupResult()); + await expect(pending).resolves.toBeUndefined(); + expect(removeFile).toHaveBeenCalledOnce(); + }); + + it('limits Windows deletion and lookup to the exact named task and retains unrelated artifacts', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-windows-remove-')); + try { + const id = "../other; $env:HOME & ' 単播"; + const target = windowsTaskPath(id); + const run = vi.fn(async (command: string) => command === 'powershell.exe' ? windowsLookupResult({taskPath: target}) : {code: 0, stdout: '', stderr: ''}); + const adapter = createSchedulerAdapter({platform: 'win32', home: root, env: {LOCALAPPDATA: root}, run}); + await adapter.install({...alarm, id}, new Date('2030-02-03T14:05:00Z')); + await adapter.install(alarm, new Date('2030-02-03T14:05:00Z')); + run.mockClear(); + await adapter.remove(id); + const calls = run.mock.calls as unknown as Array<[string, string[]]>; + expect(calls[0]).toEqual(['schtasks.exe', ['/Delete', '/TN', target, '/F']]); + expect(calls[1]![0]).toBe('powershell.exe'); + const args = calls[1]![1]; + expect(args.slice(0, -1)).toEqual(['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand']); + const script = Buffer.from(args.at(-1)!, 'base64').toString('utf16le'); + expect(script).not.toContain(id); + expect(script).not.toMatch(/DeleteFolder|DeleteTask|GetTasks|Unregister-ScheduledTask/i); + const encodedPath = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(Buffer.from(encodedPath!, 'base64').toString('utf8')).toBe(target); + expect(readdirSync(join(root, 'RadioCLI', 'scheduler'))).toEqual([`${windowsTaskPath(alarm.id).split('\\').at(-1)}.xml`]); + } finally {rmSync(root, {recursive: true, force: true});} + }); + + it('bounds a stalled Windows deletion and absence probe without removing repair artifacts', async () => { + vi.useFakeTimers(); + try { + const removeFile = vi.fn(); + const run = vi.fn(() => new Promise>(() => {})); + const adapter = createSchedulerAdapter({platform: 'win32', env: {}, writeFile: vi.fn(), removeFile, run}); + const pending = adapter.remove(alarm.id); + const assertion = expect(pending).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(20_000); + await assertion; + expect(run).toHaveBeenCalledTimes(2); + expect(removeFile).not.toHaveBeenCalled(); + } finally {vi.useRealTimers();} + }); + + it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('does not create or claim native alarm registration on %s',async platform=>{const writeFile=vi.fn();const run=vi.fn();const commandExists=vi.fn(()=>true);const adapter=createSchedulerAdapter({platform,env:{},writeFile,run,commandExists});expect(adapter.capabilities()).toMatchObject({supported:false,exactWake:false,catchUpAfterWake:false});await expect(adapter.install(alarm,new Date('2030-02-03T14:05:00Z'))).rejects.toThrow(/not supported/i);expect(writeFile).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();expect(commandExists).not.toHaveBeenCalled();}); }); diff --git a/src/alarms/scheduler.ts b/src/alarms/scheduler.ts index f543cdb..8d75fd7 100644 --- a/src/alarms/scheduler.ts +++ b/src/alarms/scheduler.ts @@ -9,9 +9,14 @@ import {nextOccurrenceForAlarm} from './schedule.js'; import {AlarmRuntimeHealthStore} from './runtime-health.js'; import {AlarmGuardService} from './guard.js'; import {AlarmPowerGuardStore} from './power-guard-store.js'; -import {detectAlarmTerminal} from './terminal-launcher.js'; +import {detectGraphicalTerminal} from '../platform/terminals.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; +import {resolveCommandDetails} from '../platform/executables.js'; +import {launchEnvironment, nodeLaunchCommand} from '../platform/launch-command.js'; +import {powershellCommand} from '../platform/shell.js'; export type SchedulerCapabilities = { + name?: string; supported: boolean; exactWake: boolean; catchUpAfterWake: boolean; @@ -21,6 +26,7 @@ type CommandResult = {code: number; stdout: string; stderr: string}; type SchedulerStatus = {installed: boolean; healthy: boolean; message: string}; export type SchedulerAdapter = { capabilities(): SchedulerCapabilities; + probeCapabilities?(): Promise; install(alarm: Alarm, occurrence: Date): Promise; remove(alarmId: string): Promise; status(alarmId: string): Promise; @@ -41,7 +47,7 @@ export type SchedulerDeps = { }; export class SchedulerService { - constructor(readonly adapter: SchedulerAdapter, private readonly now: () => Date = () => new Date(),readonly health=new AlarmRuntimeHealthStore(),private readonly guard?:{start(alarm:Alarm):Promise;stop(alarmId?:string):Promise;status?():Promise<{guards:Array<{alarmId:string}>}>|{guards:Array<{alarmId:string}>}}) {} + constructor(readonly adapter: SchedulerAdapter, private readonly now: () => Date = () => new Date(),readonly health=new AlarmRuntimeHealthStore(),private readonly guard?:{start(alarm:Alarm):Promise;stop(alarmId?:string):Promise;status?():Promise|GuardStatus}) {} async sync(alarm: Alarm): Promise { const occurrence = nextOccurrenceForAlarm(alarm, this.now()); @@ -76,10 +82,11 @@ export class SchedulerService { async completeOccurrence(alarmId:string,occurrence:Date):Promise{await this.adapter.completeOccurrence?.(alarmId,occurrence);} async remove(alarmId:string):Promise{await this.ensureGuardAbsent(alarmId);try{await this.adapter.remove(alarmId);}catch(error){this.health.record({alarmId,component:'scheduler',healthy:false,message:`Native job removal failed; the alarm definition was retained: ${errorMessage(error)}`});throw error;}this.health.remove(alarmId);} async statusAll(alarms:readonly Alarm[]){return mapConcurrent(alarms,4,async alarm=>{const occurrence=nextOccurrenceForAlarm(alarm,this.now());return{alarmId:alarm.id,nextOccurrence:occurrence?.toISOString()??null,native:occurrence?await this.adapter.status(alarm.id):{installed:false,healthy:true,message:'No enabled future occurrence; no native job is expected.'},health:this.health.get(alarm.id)};});} - async runtimeStatus(alarms:readonly Alarm[]=[]){return{capabilities:this.doctor(),entries:this.health.list(),alarms:await this.statusAll(alarms)};} + async runtimeStatus(alarms:readonly Alarm[]=[]){const [capabilities,status]=await Promise.all([this.adapter.probeCapabilities?.()??this.doctor(),this.statusAll(alarms)]);return{capabilities,entries:this.health.list(),alarms:status};} private async reconcileGuard(alarm:Alarm){if(!this.guard)return;try{const requested=alarm.enabled&&alarm.reliability.keepAwakeUntilAlarm;if(requested)await this.guard.start(alarm);else await this.ensureGuardAbsent(alarm.id);this.health.record({alarmId:alarm.id,component:'power',healthy:true,active:requested,message:requested?'Alarm Guard reconciled.':'Alarm Guard verified absent.'});}catch(error){const message=`Alarm Guard reconciliation failed: ${errorMessage(error)}`;this.health.record({alarmId:alarm.id,component:'power',healthy:false,active:true,message});throw new GuardReconcileError(message);}} - private async ensureGuardAbsent(alarmId:string){if(!this.guard)return;const stopped=await this.guard.stop(alarmId);if(stopped)return;const status=await this.guard.status?.();if(status&&!status.guards.some(item=>item.alarmId===alarmId))return;const message='Alarm Guard teardown could not be verified; the alarm definition was retained for repair.';this.health.record({alarmId,component:'power',healthy:false,active:true,message});throw new GuardReconcileError(message);} + private async ensureGuardAbsent(alarmId:string){if(!this.guard)return;const stopped=await this.guard.stop(alarmId);if(stopped)return;const status=await this.guard.status?.();if(status&&!status.guards.some(item=>item.alarmId===alarmId)&&!status.unresolvedGuards?.some(item=>!item.alarmId||item.alarmId===alarmId))return;const message='Alarm Guard teardown could not be verified; the alarm definition was retained for repair.';this.health.record({alarmId,component:'power',healthy:false,active:true,message});throw new GuardReconcileError(message);} } +type GuardStatus={guards:Array<{alarmId:string}>;unresolvedGuards?:Array<{alarmId?:string}>}; class GuardReconcileError extends Error{} async function settledOperation(work:()=>Promise):Promise<{error?:string}>{try{await work();return{};}catch(error){return{error:errorMessage(error)};}} @@ -93,24 +100,26 @@ export function createSchedulerAdapter(deps: SchedulerDeps = {}): SchedulerAdapt const nodePath = deps.nodePath ?? process.execPath; const cliPath = deps.cliPath ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js'); const env = deps.env ?? process.env; + const host = identifyPlatform({platform, env}); + const policy = nativeAdapters(host); const write = deps.writeFile ?? ((path, contents) => { mkdirSync(dirname(path), {recursive: true}); writeFileSync(path, contents, {encoding: 'utf8', mode: 0o600}); }); const removeFile = deps.removeFile ?? (path => rmSync(path, {force: true})); - const run = deps.run ?? runCommand; - const commandExists = deps.commandExists ?? (command => existsSync(command) || Boolean(process.env.PATH?.split(platform === 'win32' ? ';' : ':').some(path => existsSync(join(path, command))))); - const common = {home, nodePath, cliPath, env, terminal: detectAlarmTerminal(platform, env), write, removeFile, run}; - if (platform === 'darwin') return launchdAdapter(common); - if (platform === 'win32') return windowsAdapter(common); - if (platform === 'linux' && commandExists('systemctl')) return systemdAdapter(common); - return unsupportedAdapter(platform === 'linux' - ? 'User scheduling requires systemd. This Linux session is unsupported; RadioCLI will not claim the alarm is registered.' - : `Alarm scheduling is not supported on ${platform}.`); + const resolve = (command:string) => resolveCommandDetails(command,{platform,env,home}).path; + const run = deps.run ?? ((command,args) => runCommand(resolve(command)??command,args)); + const commandExists = deps.commandExists ?? (command => resolve(command)!==null); + const common = {platform, home, nodePath, cliPath, env, terminal: detectGraphicalTerminal(platform, env), write, removeFile, run}; + if (policy.scheduler === 'launchd') return launchdAdapter(common); + if (policy.scheduler === 'task-scheduler') return windowsAdapter(common); + if (policy.scheduler === 'systemd') { + if (commandExists('systemctl')) return systemdAdapter(common); + return unavailableAdapter('systemctl is unavailable; systemd job registration and removal cannot be verified. Repair artifacts were retained.'); + } + return unsupportedAdapter(`Alarm scheduling is not supported on ${host.id==='unknown'?host.platform:host.id}.`); } -type Common = {home: string; nodePath: string; cliPath: string; env: NodeJS.ProcessEnv; terminal:string; write: (p:string,c:string)=>void; removeFile:(p:string)=>void; run:(c:string,a:string[])=>Promise}; +type Common = {platform: NodeJS.Platform; home: string; nodePath: string; cliPath: string; env: NodeJS.ProcessEnv; terminal:string; write: (p:string,c:string)=>void; removeFile:(p:string)=>void; run:(c:string,a:string[])=>Promise}; function runtimeEnvironment(common:Common):Record{ - const result:Record={RADIOCLI_ALARM_TERMINAL:common.terminal}; - for(const key of ['RADIOCLI_HOME','DISPLAY','WAYLAND_DISPLAY','DBUS_SESSION_BUS_ADDRESS','XDG_RUNTIME_DIR']){const value=common.env[key];if(value)result[key]=value;} - return result; + return launchEnvironment(common.env, {platform: common.platform, includeDesktop: true, terminal: common.terminal}); } const jobName = (id: string) => `io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`; const invocationArgs = (common: Common, id: string, occurrence: Date,internalCommand='internal-run') => [common.cliPath, 'alarm', internalCommand, id, occurrence.toISOString()]; @@ -122,50 +131,159 @@ function launchdAdapter(common: Common): SchedulerAdapter { const occurrenceLabel=(id:string,occurrence:Date)=>`${jobName(id)}.${createHash('sha256').update(occurrence.toISOString()).digest('hex').slice(0,12)}`; const occurrencePath=(id:string,occurrence:Date)=>join(directory,`${occurrenceLabel(id,occurrence)}.plist`); const pathsFor=(id:string)=>{const prefix=`${jobName(id)}.`;let extras:string[]=[];try{extras=readdirSync(directory).filter(name=>name.startsWith(prefix)&&name.endsWith('.plist')).map(name=>join(directory,name));}catch{}return[pathFor(id),...extras];}; - const installJob=async(alarm:Alarm,occurrence:Date,label:string,path:string,replace:boolean)=>{const local=localParts(occurrence);const entries=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`${xml(key)}${xml(value)}`).join('');const envArgs=`EnvironmentVariables${entries}`;const args=[common.nodePath,...invocationArgs(common,alarm.id,occurrence,'internal-launchd')].map(value=>`${xml(value)}`).join('');common.write(path,`\n\nLabel${label}ProgramArguments${args}${envArgs}StartCalendarIntervalMonth${local.month}Day${local.day}Hour${local.hour}Minute${local.minute}ProcessTypeInteractiveStandardOutPath/dev/nullStandardErrorPath/dev/null\n`);if(replace)await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);ensureSuccess(await common.run('launchctl',['bootstrap',`gui/${process.getuid?.()??''}`,path]),'launchctl bootstrap');}; + const installJob=async(alarm:Alarm,occurrence:Date,label:string,path:string,replace:boolean)=>{const local=localParts(occurrence);const entries=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`${xml(key)}${xml(value)}`).join('');const envArgs=`EnvironmentVariables${entries}`;const args=nodeLaunchCommand(common.nodePath,invocationArgs(common,alarm.id,occurrence,'internal-launchd'),runtimeEnvironment(common)).map(value=>`${xml(value)}`).join('');common.write(path,`\n\nLabel${label}ProgramArguments${args}${envArgs}StartCalendarIntervalMonth${local.month}Day${local.day}Hour${local.hour}Minute${local.minute}ProcessTypeInteractiveStandardOutPath/dev/nullStandardErrorPath/dev/null\n`);if(replace)await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);ensureSuccess(await common.run('launchctl',['bootstrap',`gui/${process.getuid?.()??''}`,path]),'launchctl bootstrap');}; + const removeJob=async(path:string)=>{ + const label=path.split('/').at(-1)!.slice(0,-6); + let failure:unknown; + try{ensureSuccess(await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]),'launchctl bootout');}catch(error){failure=error;} + if(failure){ + let status:CommandResult; + try{status=await common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${label}`]);}catch(error){throw new Error(`Unable to verify launchd job removal: ${errorMessage(error)}`);} + if(status.code===0)throw new Error(`launchd job is still loaded: ${errorMessage(failure)}`); + if(!isLaunchdServiceNotFound(status,label))throw new Error(`Unable to verify launchd job absence: ${commandMessage(status)}`); + } + common.removeFile(path); + }; return { - capabilities: () => ({supported: true, exactWake: false, catchUpAfterWake: true, message: 'launchd runs after wake when possible; exact wake is unavailable. Resync after changing the host timezone.'}), + capabilities: () => ({name:'launchd', supported: true, exactWake: false, catchUpAfterWake: true, message: 'launchd runs after wake when possible; exact wake is unavailable. Resync after changing the host timezone.'}), async install(alarm,occurrence){await installJob(alarm,occurrence,jobName(alarm.id),pathFor(alarm.id),true);}, async installFromRunner(alarm,occurrence){const label=occurrenceLabel(alarm.id,occurrence);const existing=await common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${label}`]);if(existing.code===0)return;await installJob(alarm,occurrence,label,occurrencePath(alarm.id,occurrence),false);}, async removeFromRunner(){}, - async completeOccurrence(id,occurrence){const specific=occurrencePath(id,occurrence);const path=existsSync(specific)?specific:pathFor(id);const label=path.split('/').at(-1)!.slice(0,-6);let failure:unknown;try{const result=await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]);if(result.code!==0)failure=new Error(`launchctl bootout failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);}catch(error){failure=error;}if(failure){let status:CommandResult;try{status=await common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${label}`]);}catch(error){throw new Error(`Unable to verify completed launchd job removal: ${errorMessage(error)}`);}if(status.code===0)throw new Error(`Completed launchd job is still loaded: ${errorMessage(failure)}`);if(!isLaunchdServiceNotFound(status,label))throw new Error(`Unable to verify completed launchd job absence: ${(status.stderr||status.stdout||`exit ${status.code}`).trim()}`);}common.removeFile(path);}, - async remove(id){for(const path of pathsFor(id)){await common.run('launchctl',['bootout',`gui/${process.getuid?.()??''}`,path]).catch(()=>undefined);common.removeFile(path);}}, + async completeOccurrence(id,occurrence){const specific=occurrencePath(id,occurrence);await removeJob(existsSync(specific)?specific:pathFor(id));}, + async remove(id){for(const path of pathsFor(id))await removeJob(path);}, async status(id){const paths=pathsFor(id).filter(existsSync);const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);if(!paths.length)return{installed:false,healthy:false,message:'LaunchAgent artifact missing'};const results=await Promise.all(paths.map(path=>common.run('launchctl',['print',`gui/${process.getuid?.()??''}/${path.split('/').at(-1)!.slice(0,-6)}`])));const registered=results.some(result=>result.code===0);return{installed:registered,healthy:registered&&executable,message:!registered?'launchd registration missing':!executable?'RadioCLI executable missing':'registered and executable'};} }; } function systemdAdapter(common: Common): SchedulerAdapter { const base = join(common.home,'.config','systemd','user'); const names=(id:string)=>({service:`${jobName(id)}.service`,timer:`${jobName(id)}.timer`}); + const capabilities=():SchedulerCapabilities=>({name:'systemd',supported:true,exactWake:false,catchUpAfterWake:true,message:'systemd tooling detected; user-manager readiness is checked during registration and diagnostics. Persistent user timers can catch up after login/wake; WakeSystem is not claimed without system privileges.'}); + const probeCapabilities=async():Promise=>{ + try{ + const result=await boundedProbe(common.run('systemctl',['--user','show','--property=Version','--value'])); + ensureSuccess(result,'systemd user manager probe'); + return{...capabilities(),message:'The systemd user manager is reachable. Persistent timers can catch up after login/wake; WakeSystem is not claimed without system privileges.'}; + }catch(error){return{...capabilities(),supported:false,catchUpAfterWake:false,message:`The systemd user manager is unavailable: ${errorMessage(error)}`};} + }; return { - capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'systemd user timers catch up after login/wake; WakeSystem is not claimed without system privileges.'}), - async install(alarm, occurrence) { const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=[common.nodePath,...invocationArgs(common,alarm.id,occurrence)].map(systemdQuote).join(' '); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, - async remove(id){const n=names(id); await common.run('systemctl',['--user','disable','--now',n.timer]).catch(()=>undefined); common.removeFile(join(base,n.timer));common.removeFile(join(base,n.service));await common.run('systemctl',['--user','daemon-reload']).catch(()=>undefined);}, - async status(id){const n=names(id);const result=await common.run('systemctl',['--user','is-enabled',n.timer]);const artifact=existsSync(join(base,n.timer))&&existsSync(join(base,n.service));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:result.code===0&&artifact,healthy:result.code===0&&artifact&&executable,message:result.code!==0?'user timer not enabled':!artifact?'systemd unit artifact missing':!executable?'RadioCLI executable missing':'enabled and executable'};} + capabilities, + probeCapabilities, + async install(alarm, occurrence) { const readiness=await probeCapabilities();if(!readiness.supported)throw new Error(readiness.message);const n=names(alarm.id); const env=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd=systemdExecCommand(nodeLaunchCommand(common.nodePath,invocationArgs(common,alarm.id,occurrence),runtimeEnvironment(common))); common.write(join(base,n.service),`[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base,n.timer),`[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl',['--user','enable','--now',n.timer]),'systemctl enable'); }, + async remove(id){ + const n=names(id); + await removeWithVerification(()=>common.run('systemctl',['--user','disable','--now',n.timer]),()=>common.run('systemctl',['--user','show',n.timer,'--property=LoadState','--property=ActiveState']),result=>/^LoadState=not-found\r?$/m.test(result.stdout)&&/^ActiveState=inactive\r?$/m.test(result.stdout),'systemd timer'); + common.removeFile(join(base,n.timer));common.removeFile(join(base,n.service)); + ensureSuccess(await common.run('systemctl',['--user','daemon-reload']),'systemctl daemon-reload after removal'); + }, + async status(id){const readiness=await probeCapabilities();if(!readiness.supported)return{installed:false,healthy:false,message:readiness.message};const n=names(id);const [enabled,active]=await Promise.all([common.run('systemctl',['--user','is-enabled',n.timer]),common.run('systemctl',['--user','is-active',n.timer])]);const artifact=existsSync(join(base,n.timer))&&existsSync(join(base,n.service));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:enabled.code===0&&artifact,healthy:enabled.code===0&&active.code===0&&artifact&&executable,message:enabled.code!==0?'user timer not enabled':active.code!==0?'user timer is not active':!artifact?'systemd unit artifact missing':!executable?'RadioCLI executable missing':'enabled, active, and executable'};} }; } function windowsAdapter(common: Common): SchedulerAdapter { const xmlPath=(id:string)=>join(common.env.LOCALAPPDATA??join(common.home,'AppData','Local'),'RadioCLI','scheduler',`${jobName(id)}.xml`); return { - capabilities:()=>({supported:true,exactWake:false,catchUpAfterWake:true,message:'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.'}), - async install(alarm,occurrence){const name=`\\RadioCLI\\${jobName(alarm.id)}`;const command=windowsCommandLine(common.nodePath,invocationArgs(common,alarm.id,occurrence));const environment=Object.entries(runtimeEnvironment(common)).map(([key,value])=>`set "${key}=${cmdEscape(value)}"`).join(' && ');const raw=`${environment} && ${command}`;const body=`${xml(alarm.label)}${windowsLocalBoundary(occurrence)}trueInteractiveTokenLeastPrivilegetrue${alarm.reliability.wakeIfSupported}falsefalsePT0SIgnoreNewcmd.exe/d /s /c "${xml(raw)}"`;const path=xmlPath(alarm.id);common.write(path,body);ensureSuccess(await common.run('schtasks.exe',['/Create','/TN',name,'/XML',path,'/F']),'schtasks create');}, - async remove(id){await common.run('schtasks.exe',['/Delete','/TN',`\\RadioCLI\\${jobName(id)}`,'/F']).catch(()=>undefined);common.removeFile(xmlPath(id));}, + capabilities:()=>({name:'Task Scheduler',supported:true,exactWake:false,catchUpAfterWake:true,message:'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.'}), + async install(alarm, occurrence) { + const name = `\\RadioCLI\\${jobName(alarm.id)}`; + const direct = nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence), runtimeEnvironment(common)); + // Task Scheduler expands %NAME% in Path and Arguments itself. Only its + // standard PowerShell path and fixed flags/encoded data cross that boundary. + const command = '%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + const args = powershellCommand(direct).join(' '); + const body = `${xml(alarm.label)}${windowsLocalBoundary(occurrence)}trueInteractiveTokenLeastPrivilegetrue${alarm.reliability.wakeIfSupported}falsefalsePT0SIgnoreNew${xml(command)}${xml(args)}`; + const path = xmlPath(alarm.id); + common.write(path, body); + ensureSuccess(await common.run('schtasks.exe', ['/Create', '/TN', name, '/XML', path, '/F']), 'schtasks create'); + }, + async remove(id){ + const name=`\\RadioCLI\\${jobName(id)}`; + const removal=await settledOperation(async()=>{ + const result=await boundedWindowsCommand(()=>common.run('schtasks.exe',['/Delete','/TN',name,'/F']),'deletion'); + if(result.code!==0)throw new Error(`Task Scheduler job removal failed: ${windowsCommandMessage(result)}`); + }); + let state:WindowsTaskState; + try{ + const result=await boundedWindowsCommand(()=>common.run('powershell.exe',windowsTaskLookupCommand(name)),'absence lookup'); + state=windowsTaskState(result,name); + }catch(error){throw new Error(`Unable to verify Task Scheduler job removal; repair artifacts were retained. ${removal.error??''} ${errorMessage(error)}`);} + if(state.state!=='absent')throw new Error(`Unable to verify Task Scheduler job absence; repair artifacts were retained. ${removal.error??''} ${state.message}`); + common.removeFile(xmlPath(id)); + }, async status(id){const result=await common.run('schtasks.exe',['/Query','/TN',`\\RadioCLI\\${jobName(id)}`]);const artifact=existsSync(xmlPath(id));const executable=existsSync(common.nodePath)&&existsSync(common.cliPath);return{installed:result.code===0&&artifact,healthy:result.code===0&&artifact&&executable,message:result.code!==0?'task not registered':!artifact?'task XML artifact missing':!executable?'RadioCLI executable missing':'registered and executable'};} }; } +function windowsTaskLookupCommand(taskPath:string):string[]{ + // Only GetTask failures can establish absence. A missing COM service or root + // folder must remain a repairable error, regardless of the UI language. + const encodedPath=Buffer.from(taskPath,'utf8').toString('base64'); + const script=[ + "$ErrorActionPreference='Stop'", + "$ProgressPreference='SilentlyContinue'", + '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)', + `$radiocliTaskPath=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`, + "$radiocliResult=@{taskPath=$radiocliTaskPath;stage='connect';status='error';hresult=0;message=''}", + "try{$radiocliService=New-Object -ComObject 'Schedule.Service';$null=$radiocliService.Connect();$radiocliResult.stage='root';$radiocliFolder=$radiocliService.GetFolder('\\');$radiocliResult.stage='lookup';$null=$radiocliFolder.GetTask($radiocliTaskPath);$radiocliResult.status='present'}catch{$radiocliException=$_.Exception;$radiocliResult.message=$radiocliException.ToString();for($radiocliDepth=0;$null -ne $radiocliException.InnerException -and $radiocliDepth -lt 16;$radiocliDepth++){$radiocliException=$radiocliException.InnerException};$radiocliResult.hresult=$radiocliException.HResult}", + '[Console]::WriteLine(($radiocliResult | ConvertTo-Json -Compress))', + 'exit 0' + ].join(';'); + return ['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')]; +} + +type WindowsTaskState={state:'absent'|'present'|'unknown';message:string}; +function windowsTaskState(result:CommandResult,taskPath:string):WindowsTaskState{ + const unknown=(message:string):WindowsTaskState=>({state:'unknown',message:`${message} ${windowsCommandMessage(result)}`}); + if(result.code!==0)return unknown('Task Scheduler lookup process failed.'); + let value:unknown; + try{value=JSON.parse(result.stdout);}catch{return unknown('Malformed Task Scheduler lookup response.');} + if(!value||typeof value!=='object'||Array.isArray(value))return unknown('Malformed Task Scheduler lookup response.'); + const response=value as Record; + if(response.taskPath!==taskPath||!['connect','root','lookup'].includes(String(response.stage))|| + !['present','error'].includes(String(response.status))||typeof response.hresult!=='number'|| + !Number.isInteger(response.hresult)||response.hresult< -2147483648||response.hresult>2147483647||typeof response.message!=='string'){ + return unknown('Invalid or unrelated Task Scheduler lookup response.'); + } + if(response.status==='present'&&response.stage==='lookup'&&response.hresult===0)return{state:'present',message:'Task Scheduler job is still registered.'}; + // HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND), from the + // exact GetTask lookup. Never interpret schtasks exit 1 or localized text. + if(response.status==='error'&&response.stage==='lookup'&&[-2147024894,-2147024893].includes(response.hresult))return{state:'absent',message:response.message}; + return unknown(`Task Scheduler ${response.stage} HRESULT 0x${(response.hresult>>>0).toString(16).padStart(8,'0')}: ${response.message}`); +} +function windowsCommandMessage(result:CommandResult):string{return [`exit ${result.code}`,result.stderr.trim(),result.stdout.trim()].filter(Boolean).join(' ');} +async function boundedWindowsCommand(work:()=>Promise,label:string):Promise{ + let timer:NodeJS.Timeout|undefined; + try{return await Promise.race([Promise.resolve().then(work),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(`Task Scheduler ${label} timed out.`)),10_000);})]);} + finally{if(timer)clearTimeout(timer);} +} + function unsupportedAdapter(message:string):SchedulerAdapter{return{capabilities:()=>({supported:false,exactWake:false,catchUpAfterWake:false,message}),install:async()=>{throw new Error(message);},remove:async()=>{},status:async()=>({installed:false,healthy:false,message})};} -function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',v=>stdout+=String(v));child.stderr.on('data',v=>stderr+=String(v));child.on('error',e=>resolve({code:127,stdout,stderr:e.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} +function unavailableAdapter(message: string): SchedulerAdapter { + return {...unsupportedAdapter(message), remove: async () => {throw new Error(message);}}; +} +function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true,timeout:10_000});let stdout='';let stderr='';child.stdout.on('data',v=>stdout+=String(v));child.stderr.on('data',v=>stderr+=String(v));child.on('error',e=>resolve({code:127,stdout,stderr:e.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} +async function boundedProbe(probe:Promise):Promise{let timer:NodeJS.Timeout|undefined;try{return await Promise.race([probe,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('User-manager probe timed out.')),3_000);})]);}finally{if(timer)clearTimeout(timer);}} +async function removeWithVerification(remove:()=>Promise,query:()=>Promise,isAbsent:(result:CommandResult)=>boolean,label:string):Promise{ + let failure:unknown; + try{ensureSuccess(await remove(),`${label} removal`);}catch(error){failure=error;} + if(!failure)return; + let result:CommandResult; + try{result=await query();}catch(error){throw new Error(`Unable to verify ${label} removal: ${errorMessage(error)}`);} + if(isAbsent(result))return; + throw new Error(`Unable to verify ${label} absence; repair artifacts were retained. ${errorMessage(failure)} ${commandMessage(result)}`); +} +function commandMessage(result:CommandResult){return(result.stderr||result.stdout||`exit ${result.code}`).trim();} function ensureSuccess(result:CommandResult,label:string){if(result.code!==0)throw new Error(`${label} failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);} function xml(value:string){return value.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');} function systemdQuote(value:string){if(/[\r\n\0]/.test(value))throw new Error('Scheduler arguments cannot contain control characters.');return `"${value.replaceAll('%','%%').replaceAll('\\','\\\\').replaceAll('"','\\"')}"`;} -function cmdEscape(value:string){return value.replaceAll('%','%%').replaceAll('"','""').replaceAll('^','^^').replaceAll('&','^&').replaceAll('|','^|').replaceAll('<','^<').replaceAll('>','^>');} -function windowsCommandLine(command:string,args:string[]){return [command,...args].map(windowsArg).join(' ');} -function windowsArg(value:string){return `"${value.replace(/(\\*)"/g,'$1$1\\"').replace(/(\\+)$/,'$1$1')}"`;} +function systemdExecCommand(values: string[]): string { + // ':' disables $VAR/${VAR} expansion for ExecStart. Environment= has + // different syntax: its dollar signs are already literal and must stay so. + return `:${values.map(systemdQuote).join(' ')}`; +} function localParts(date:Date){return{year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};} function systemdCalendar(date:Date){return `${date.getUTCFullYear()}-${String(date.getUTCMonth()+1).padStart(2,'0')}-${String(date.getUTCDate()).padStart(2,'0')} ${String(date.getUTCHours()).padStart(2,'0')}:${String(date.getUTCMinutes()).padStart(2,'0')}:${String(date.getUTCSeconds()).padStart(2,'0')} UTC`;} function windowsLocalBoundary(date:Date){const p=localParts(date);return `${p.year}-${String(p.month).padStart(2,'0')}-${String(p.day).padStart(2,'0')}T${String(p.hour).padStart(2,'0')}:${String(p.minute).padStart(2,'0')}:${String(p.second).padStart(2,'0')}`;} function unitText(value:string){return value.replace(/[\r\n\0]/g,' ').replaceAll('%','%%').slice(0,200);} function errorMessage(error:unknown){return error instanceof Error?error.message:String(error);} -function isLaunchdServiceNotFound(result:CommandResult,label:string){const output=`${result.stderr}\n${result.stdout}`;return result.code!==0&&output.includes(label)&&/could not find service\b/i.test(output)&&/\bin domain for\b/i.test(output);} +function isLaunchdServiceNotFound(result:CommandResult,label:string){const output=`${result.stderr}\n${result.stdout}`;const match=/could not find service "([^"]+)" in domain for\b/i.exec(output);return result.code!==0&&match?.[1]===label;} async function mapConcurrent(items:readonly T[],limit:number,work:(item:T)=>Promise):Promise{const results=new Array(items.length);let next=0;await Promise.all(Array.from({length:Math.min(limit,items.length)},async()=>{while(true){const index=next;next+=1;if(index>=items.length)return;results[index]=await work(items[index]!);}}));return results;} diff --git a/src/alarms/setup-verification.test.ts b/src/alarms/setup-verification.test.ts index 0af70e9..6f6ff2e 100644 --- a/src/alarms/setup-verification.test.ts +++ b/src/alarms/setup-verification.test.ts @@ -18,4 +18,5 @@ describe('alarm setup verification',()=>{ it('does not claim success when an audible sample cannot be stopped cleanly',async()=>{const {alarm,settings}=fixture();const d=dependencies();d.deps.player=vi.fn(()=>({refreshDetectedBackends:vi.fn(),play:vi.fn(async()=>{}),stop:vi.fn(async()=>new Promise(()=>{}))}));const report=await verifyAlarmSetup(scheduler() as never,alarm,settings,()=>{}, {...d.deps,wait:vi.fn(async()=>{})});expect(report.state).toBe('failed');expect(report.steps.find(step=>step.id==='playback')).toMatchObject({state:'failed'});},5_000); it('attempts cleanup even when native registration reports a hard failure',async()=>{const {alarm,settings}=fixture();const d=dependencies();const s=scheduler({sync:vi.fn(async()=>{throw new Error('registration denied');})});const report=await verifyAlarmSetup(s as never,alarm,settings,()=>{},d.deps);expect(report.state).toBe('failed');expect(s.remove).toHaveBeenCalledOnce();expect(report.steps.find(step=>step.id==='scheduler')).toMatchObject({state:'failed'});expect(report.steps.find(step=>step.id==='cleanup')).toMatchObject({state:'passed'});}); it('requires a configured alarm for the audible station rehearsal while still cleaning up',async()=>{const {settings}=fixture();const d=dependencies();const s=scheduler();const report=await verifyAlarmSetup(s as never,undefined,settings,()=>{},d.deps);expect(report.state).toBe('failed');expect(report.steps.find(step=>step.id==='playback')?.detail).toMatch(/create at least one alarm/i);expect(s.remove).toHaveBeenCalledOnce();}); + it('names the adapter that performed registration instead of inferring one from the host',async()=>{const {alarm,settings}=fixture();const d=dependencies();const s=scheduler({doctor:()=>({supported:true,exactWake:false,catchUpAfterWake:true,name:'Selected test scheduler',message:'ready'})});const report=await verifyAlarmSetup(s as never,alarm,settings,()=>{},d.deps);expect(report.steps.find(step=>step.id==='scheduler')).toMatchObject({state:'passed',detail:'Registered and queried a disposable Selected test scheduler job.'});}); }); diff --git a/src/alarms/setup-verification.ts b/src/alarms/setup-verification.ts index fe58def..51f1415 100644 --- a/src/alarms/setup-verification.ts +++ b/src/alarms/setup-verification.ts @@ -58,7 +58,7 @@ export async function verifyAlarmSetup(scheduler:SchedulerService,alarm:Alarm|un temporary=temporaryAlarm(alarm,now(),deps.id?.()??randomUUID()); const occurrence=await scheduler.sync(temporary);if(!occurrence)throw new Error('The disposable alarm did not receive a future occurrence.');schedulerInstalled=true; const [status]=await scheduler.statusAll([temporary]);if(!status?.native.installed||!status.native.healthy)throw new Error(status?.native.message??'The native job could not be verified.'); - set('scheduler','passed',`Registered and queried a disposable ${platformSchedulerName()} job.`); + set('scheduler','passed',`Registered and queried a disposable ${capability.name??'native scheduler'} job.`); if(alarm?.reliability.wakeIfSupported)set('wake',capability.exactWake?'passed':'warning',capability.exactWake?'This scheduler reports exact wake support.':`The OS accepted the job, but wake remains hardware and policy dependent. ${capability.message}`); else set('wake','passed',capability.catchUpAfterWake?'Wake was not requested; the native scheduler supports catch-up after the machine wakes.':'Wake was not requested for this alarm.'); }catch(error){set('scheduler','failed',messageOf(error));set('wake','warning','Wake behavior could not be assessed because native scheduler verification failed.');} @@ -98,7 +98,6 @@ export async function verifyAlarmSetup(scheduler:SchedulerService,alarm:Alarm|un function temporaryAlarm(source:Alarm|undefined,now:Date,id:string):Alarm{const at=new Date(Math.ceil((now.getTime()+10*60_000)/60_000)*60_000);return{id:`setup-verification-${id}`,label:'RadioCLI setup verification',enabled:true,station:source?.station??{id:'verification',provider:'playlist',name:'RadioCLI verification',tags:[],streamUrl:'https://127.0.0.1/'},schedule:{type:'once',at:at.toISOString()},playback:{volume:source?.playback.volume??40,fadeSeconds:0,stopAfterMinutes:1},reliability:{missedRunGraceMinutes:1,wakeIfSupported:false},createdAt:now.toISOString(),updatedAt:now.toISOString()};} async function probeControlChannel():Promise{const root=mkdtempSync(join(tmpdir(),'radiocli-alarm-verify-'));const file=join(root,'probe.json');const server=await startActiveAlarmSession({alarmId:'setup-verification',scheduledAt:new Date().toISOString(),stationName:'RadioCLI verification',startedAt:new Date().toISOString()},{filePath:file,onDismiss:()=>{},onSnooze:()=>{},onKeepPlaying:()=>{}});try{const client=await connectActiveAlarm(file);if(!client)throw new Error('The ringing control channel was not discoverable.');const status=await client.status();if(status.alarmId!=='setup-verification')throw new Error('The ringing control channel returned the wrong alarm identity.');}finally{await server.close();rmSync(root,{recursive:true,force:true});}} -function platformSchedulerName(){return process.platform==='darwin'?'launchd':process.platform==='win32'?'Task Scheduler':'systemd';} function friendlyTerminal(value:string){return value.replace(/^darwin:/,'').replace(/^win32:/,'').replace(/^linux:/,'');} function messageOf(error:unknown){return error instanceof Error?error.message:String(error);} function wait(milliseconds:number){return new Promise(resolve=>setTimeout(resolve,milliseconds));} diff --git a/src/alarms/system-volume-native.test.ts b/src/alarms/system-volume-native.test.ts new file mode 100644 index 0000000..5c1b2ad --- /dev/null +++ b/src/alarms/system-volume-native.test.ts @@ -0,0 +1,105 @@ +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import {EventEmitter} from 'node:events'; +import {PassThrough} from 'node:stream'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import {createSystemVolumeController} from './system-volume.js'; + +vi.mock('node:child_process',async importOriginal=>({...await importOriginal()})); +vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); +let directory:string; +beforeEach(()=>{directory=fs.mkdtempSync(join(tmpdir(),'radiocli-native-volume-'));}); +afterEach(()=>{vi.restoreAllMocks();fs.rmSync(directory,{recursive:true,force:true});}); + +describe('native system-output command lifetime',()=>{ + it.skipIf(process.platform === 'win32')('waits for an orphan native helper before recovering the saved baseline',async()=>{ + const output=join(directory,'fake-output.json');const gate=join(directory,'gate');const helperFile=join(directory,'helper.json'); + fs.writeFileSync(output,JSON.stringify({volume:20,muted:true})); + const helperSource=` + const fs=require('node:fs');const [output,gate,helperFile,text]=process.argv.slice(1);const args=JSON.parse(text); + if(args.includes('set s to get volume settings')){const state=JSON.parse(fs.readFileSync(output,'utf8'));process.stdout.write(state.volume+','+state.muted);} + else{ + const match=/set volume output volume (\\d+) (with|without) output muted/.exec(args.join(' ')); + const state={volume:Number(match[1]),muted:match[2]==='with'}; + if(state.volume===70){fs.writeFileSync(helperFile,JSON.stringify({pid:process.pid}));const timer=setInterval(()=>{if(fs.existsSync(gate)){fs.writeFileSync(output,JSON.stringify(state));clearInterval(timer);}},10);setTimeout(()=>process.exit(3),8000).unref();} + else fs.writeFileSync(output,JSON.stringify(state)); + } + `; + const source=` + import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module'; + const [directory,output,gate,helperFile]=process.argv.slice(1);const original=cp.spawn; + cp.spawn=(_command,args,options)=>original(process.execPath,['-e',${JSON.stringify(helperSource)},output,gate,helperFile,JSON.stringify(args)],options); + syncBuiltinESMExports(); + const {createSystemVolumeController}=await import(${JSON.stringify(new URL('./system-volume.ts',import.meta.url).href)}); + await createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70); + `; + const parent=childProcess.spawn(process.execPath,['--import','tsx','--input-type=module','-e',source,directory,output,gate,helperFile],{stdio:'ignore',windowsHide:true,timeout:10_000,killSignal:'SIGKILL'}); + const parentClosed=new Promise(resolve=>parent.once('close',()=>resolve())); + let helperPid:number|undefined;let acquired=false;let acquisition:ReturnType['acquireMinimum']>|undefined; + try{ + await until(()=>fs.existsSync(helperFile));helperPid=(JSON.parse(fs.readFileSync(helperFile,'utf8')) as {pid:number}).pid; + expect(JSON.parse(fs.readFileSync(join(directory,'native-write.json'),'utf8'))).toMatchObject({helperPid}); + parent.kill('SIGKILL');await parentClosed;expect(()=>process.kill(helperPid!,0)).not.toThrow(); + const run=async(_command:string,args:string[])=>{ + const before=JSON.parse(fs.readFileSync(output,'utf8')) as {volume:number;muted:boolean}; + if(args.includes('set s to get volume settings'))return{code:0,stdout:`${before.volume},${before.muted}`,stderr:''}; + const match=/set volume output volume (\d+) (with|without) output muted/.exec(args.join(' ')); + fs.writeFileSync(output,JSON.stringify({volume:Number(match![1]),muted:match![2]==='with'}));return{code:0,stdout:'',stderr:''}; + }; + acquisition=createSystemVolumeController('darwin',run,()=>null,{directory,lockTimeoutMs:2_000}).acquireMinimum(80).then(lease=>{acquired=true;return lease;}); + await new Promise(resolve=>setTimeout(resolve,80));expect(acquired).toBe(false); + fs.writeFileSync(gate,'go');const lease=await acquisition; + expect(JSON.parse(fs.readFileSync(output,'utf8'))).toEqual({volume:80,muted:false}); + await lease.release();expect(JSON.parse(fs.readFileSync(output,'utf8'))).toEqual({volume:20,muted:true}); + expect(fs.existsSync(join(directory,'native-write.json'))).toBe(false); + }finally{ + fs.writeFileSync(gate,'go');if(parent.exitCode===null&&parent.signalCode===null)parent.kill('SIGKILL');await parentClosed; + if(acquisition)await acquisition.then(lease=>lease.release()).catch(()=>{}); + if(helperPid)try{process.kill(helperPid,'SIGKILL');}catch{} + } + }); + + it('kills and awaits an owned helper when persisting its PID fails',async()=>{ + const writes:ReturnType[]=[];let saves=0;const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'native-write.json')&&++saves===2)throw new Error('PID disk failure');return rename(source,destination);}); + vi.spyOn(childProcess,'spawn').mockImplementation((_command,args)=>{ + const child=fakeChild();const reading=args?.includes('set s to get volume settings'); + if(reading)queueMicrotask(()=>{child.stdout.write('20,true');child.emit('close',0,null);}); + else{writes.push(child);if(writes.length>1)queueMicrotask(()=>child.emit('close',0,null));} + return child as unknown as childProcess.ChildProcess; + }); + let settled=false;const acquisition=createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70).finally(()=>{settled=true;}); + const rejected=expect(acquisition).rejects.toThrow('PID disk failure'); + await until(()=>writes.length>0);expect(writes[0]!.kill).toHaveBeenCalledWith('SIGKILL'); + expect(settled).toBe(false);expect(fs.existsSync(join(directory,'lock'))).toBe(true); + expect(JSON.parse(fs.readFileSync(join(directory,'native-write.json'),'utf8')).helperPid).toBeNull(); + writes[0]!.emit('close',null,'SIGKILL');await rejected; + expect(fs.existsSync(join(directory,'lock'))).toBe(false);expect(fs.existsSync(join(directory,'native-write.json'))).toBe(false); + }); + + it('clears the native journal when spawning throws before creating a helper',async()=>{ + vi.spyOn(childProcess,'spawn').mockImplementation((_command,args)=>{ + if(!args?.includes('set s to get volume settings'))throw new Error('helper could not start'); + const child=fakeChild();queueMicrotask(()=>{child.stdout.write('20,true');child.emit('close',0,null);});return child as unknown as childProcess.ChildProcess; + }); + await expect(createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70)).rejects.toThrow('helper could not start'); + expect(fs.existsSync(join(directory,'native-write.json'))).toBe(false); + expect(fs.existsSync(join(directory,'state.json'))).toBe(true); + }); + + it('does not start a mutating helper if its intent cannot be journaled',async()=>{ + const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'native-write.json'))throw new Error('native journal disk failure');return rename(source,destination);}); + const spawn=vi.spyOn(childProcess,'spawn').mockImplementation((_command,args)=>{ + expect(args).toContain('set s to get volume settings'); + const child=fakeChild();queueMicrotask(()=>{child.stdout.write('20,true');child.emit('close',0,null);});return child as unknown as childProcess.ChildProcess; + }); + await expect(createSystemVolumeController('darwin',undefined,()=>null,{directory}).acquireMinimum(70)).rejects.toThrow('native journal disk failure'); + expect(spawn).toHaveBeenCalledOnce();expect(fs.existsSync(join(directory,'state.json'))).toBe(true); + }); +}); + +function fakeChild(){const child=new EventEmitter() as EventEmitter&{pid:number;stdout:PassThrough;stderr:PassThrough;kill:ReturnType};child.pid=process.pid;child.stdout=new PassThrough();child.stderr=new PassThrough();child.kill=vi.fn(()=>true);return child;} +async function until(predicate:()=>boolean):Promise{const deadline=Date.now()+3_000;while(!predicate()){if(Date.now()>=deadline)throw new Error('Owned fake output helper did not reach the expected state.');await new Promise(resolve=>setTimeout(resolve,10));}} diff --git a/src/alarms/system-volume-ownership.test.ts b/src/alarms/system-volume-ownership.test.ts new file mode 100644 index 0000000..2470559 --- /dev/null +++ b/src/alarms/system-volume-ownership.test.ts @@ -0,0 +1,100 @@ +import * as fs from 'node:fs'; +import {randomUUID} from 'node:crypto'; +import {existsSync,mkdirSync,mkdtempSync,readdirSync,rmSync,utimesSync,writeFileSync} from 'node:fs'; +import {tmpdir,userInfo} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import {defaultSystemVolumeDirectory,SystemVolumeOwnership,volumeProcessAlive} from './system-volume-ownership.js'; + +vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); + +let directory:string; +beforeEach(()=>{directory=mkdtempSync(join(tmpdir(),'radiocli-volume-ownership-'));}); +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();rmSync(directory,{recursive:true,force:true});}); +const missingProcess=99999999; +function ownerMarker(pid=process.pid){return`owner-${pid}-${randomUUID()}`;} +function lockWith(marker:string){const lock=join(directory,'lock');mkdirSync(lock);writeFileSync(join(lock,marker),'');return lock;} +function deadProcessFixture(){vi.spyOn(process,'kill').mockImplementation(pid=>{if(pid===missingProcess)throw Object.assign(new Error('gone'),{code:'ESRCH'});return true;});} + +describe('shared system-volume ownership',()=>{ + it('uses the actual OS account home across library, HOME and XDG overrides',()=>{ + const expected=join(userInfo().homedir,'.radiocli','system-volume'); + vi.stubEnv('RADIOCLI_HOME',join(directory,'profile'));vi.stubEnv('RADIO_ATLAS_HOME',join(directory,'legacy')); + vi.stubEnv('HOME',directory);vi.stubEnv('XDG_RUNTIME_DIR',join(directory,'runtime'));vi.stubEnv('XDG_STATE_HOME',join(directory,'state'));vi.stubEnv('USERPROFILE',directory); + expect(defaultSystemVolumeDirectory()).toBe(expected); + }); + + it('only treats ESRCH as proof that a participant is dead',()=>{ + vi.spyOn(process,'kill').mockImplementationOnce(()=>{throw Object.assign(new Error('denied'),{code:'EPERM'});}).mockImplementationOnce(()=>{throw Object.assign(new Error('unknown'),{code:'EINVAL'});}).mockImplementationOnce(()=>{throw Object.assign(new Error('gone'),{code:'ESRCH'});}); + expect(volumeProcessAlive(1)).toBe(true);expect(volumeProcessAlive(2)).toBe(true);expect(volumeProcessAlive(3)).toBe(false); + }); + + it('does not steal an old lock from a live owner',async()=>{ + const marker=ownerMarker();const lock=lockWith(marker);utimesSync(lock,new Date(0),new Date(0)); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/busy.*live owner/); + expect(work).not.toHaveBeenCalled();expect(readdirSync(lock)).toEqual([marker]); + }); + + it('recovers a dead owner and an empty directory left during cleanup',async()=>{ + deadProcessFixture();lockWith(ownerMarker(missingProcess)); + await expect(new SystemVolumeOwnership(directory,25).transaction(async()=>1)).resolves.toBe(1); + mkdirSync(join(directory,'lock')); + await expect(new SystemVolumeOwnership(directory,25).transaction(async()=>2)).resolves.toBe(2); + expect(existsSync(join(directory,'lock'))).toBe(false); + }); + + it('does not displace a new live generation while reclaiming an observed dead owner',async()=>{ + deadProcessFixture();const stale=ownerMarker(missingProcess);const lock=lockWith(stale);const current=ownerMarker(); + const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{ + if(String(source)===join(lock,stale)){ + fs.unlinkSync(join(lock,stale));fs.rmdirSync(lock);mkdirSync(lock);writeFileSync(join(lock,current),''); + writeFileSync(join(directory,'native-write.json'),JSON.stringify({version:1,ownerPid:process.pid,helperPid:null})); + } + return rename(source,destination); + }); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/busy/); + expect(work).not.toHaveBeenCalled();expect(readdirSync(lock)).toEqual([current]); + }); + + it('keeps an unreadable lock intact instead of allowing independent output mutations',async()=>{ + const lock=lockWith('unknown-owner');const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/lock is unreadable/); + expect(work).not.toHaveBeenCalled();expect(readdirSync(lock)).toEqual(['unknown-owner']); + }); + + it('returns a completed acquisition after cleanup fails and permits the next transaction to recover',async()=>{ + vi.spyOn(fs,'unlinkSync').mockImplementationOnce(()=>{throw Object.assign(new Error('cleanup denied'),{code:'EACCES'});}); + const ownership=new SystemVolumeOwnership(directory,25); + await expect(ownership.transaction(async()=>({lease:'owned'}))).resolves.toEqual({lease:'owned'}); + await expect(ownership.transaction(async()=>2)).resolves.toBe(2); + expect(existsSync(join(directory,'lock'))).toBe(false); + }); + + it('does not reap a dead parent while its recorded native output helper is alive',async()=>{ + deadProcessFixture();const lock=lockWith(ownerMarker(missingProcess)); + const journal=join(directory,'native-write.json');writeFileSync(journal,JSON.stringify({version:1,ownerPid:missingProcess,helperPid:process.pid})); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/helper.*running/i); + expect(work).not.toHaveBeenCalled();expect(existsSync(lock)).toBe(true);expect(existsSync(journal)).toBe(true); + }); + + it('waits for an orphan output helper to exit before recovering its dead parent lock',async()=>{ + let helperAlive=true;const helperPid=88888888; + vi.spyOn(process,'kill').mockImplementation(pid=>{if(pid===missingProcess||pid===helperPid&&!helperAlive)throw Object.assign(new Error('gone'),{code:'ESRCH'});return true;}); + lockWith(ownerMarker(missingProcess));const journal=join(directory,'native-write.json');writeFileSync(journal,JSON.stringify({version:1,ownerPid:missingProcess,helperPid})); + const exited=setTimeout(()=>{helperAlive=false;},15); + try{await new SystemVolumeOwnership(directory,100).transaction(async()=>{expect(helperAlive).toBe(false);});} + finally{clearTimeout(exited);} + expect(existsSync(journal)).toBe(false); + }); + + it.each([JSON.stringify({version:1,ownerPid:missingProcess,helperPid:null}),'unreadable native journal'])('retains ambiguous native work and explains safe recovery: %s',async text=>{ + const journal=join(directory,'native-write.json');writeFileSync(journal,text); + const work=vi.fn(async()=>{}); + await expect(new SystemVolumeOwnership(directory,25).transaction(work)).rejects.toThrow(/restart.*native-write\.json/i); + expect(work).not.toHaveBeenCalled();expect(fs.readFileSync(journal,'utf8')).toBe(text); + }); +}); diff --git a/src/alarms/system-volume-ownership.ts b/src/alarms/system-volume-ownership.ts new file mode 100644 index 0000000..0dcd194 --- /dev/null +++ b/src/alarms/system-volume-ownership.ts @@ -0,0 +1,129 @@ +import {randomUUID} from 'node:crypto'; +import {mkdirSync,readFileSync,readdirSync,renameSync,rmSync,rmdirSync,unlinkSync,writeFileSync} from 'node:fs'; +import {userInfo} from 'node:os'; +import {isAbsolute,join} from 'node:path'; + +export type OutputSnapshot={volume:number;muted:boolean}; +export type SystemVolumeState={version:1;generation:string;baseline:OutputSnapshot|null;changed:boolean;participants:Array<{id:string;pid:number;phase:'pending'|'active'}>}; + +/** The output device belongs to the OS account, not a RADIOCLI_HOME/XDG profile. */ +export function defaultSystemVolumeDirectory():string{ + const home=userInfo().homedir; + if(!home||!isAbsolute(home))throw new Error('The OS account home is unavailable for shared output-volume ownership.'); + return join(home,'.radiocli','system-volume'); +} + +/** One short transaction covers both the saved baseline and native mixer writes. */ +export class SystemVolumeOwnership{ + constructor(private readonly directory:string,private readonly timeoutMs=30_000){} + + async transaction(work:()=>Promise):Promise{ + const release=await this.lock(); + try{return await work();} + finally{release();} + } + + read():SystemVolumeState|undefined{ + let text:string; + try{text=readFileSync(join(this.directory,'state.json'),'utf8');} + catch(error){if(codeOf(error)==='ENOENT')return undefined;throw error;} + try{ + const value=JSON.parse(text) as SystemVolumeState; + if(value?.version!==1||!validId(value.generation)||typeof value.changed!=='boolean'||!(value.baseline===null?!value.changed:validSnapshot(value.baseline))||!Array.isArray(value.participants)||!value.participants.every(item=>item&&validId(item.id)&&validPid(item.pid)&&(item.phase==='pending'||item.phase==='active'))||new Set(value.participants.map(item=>item.id)).size!==value.participants.length)throw new Error(); + return value; + }catch{throw new Error('Shared output-volume ownership state is unreadable; the saved output state was left intact.');} + } + + save(value:SystemVolumeState):void{ + const temporary=join(this.directory,`.state-${process.pid}-${randomUUID()}`); + try{writeFileSync(temporary,JSON.stringify(value),{mode:0o600,flag:'wx'});renameSync(temporary,join(this.directory,'state.json'));} + catch(error){try{rmSync(temporary,{force:true});}catch{}throw error;} + } + + clear():void{try{unlinkSync(join(this.directory,'state.json'));}catch(error){if(codeOf(error)!=='ENOENT')throw error;}} + + /** A null PID is intentional: a crash before PID persistence is ambiguous. */ + beginNativeWrite():void{this.saveNativeWrite(null);} + recordNativeWritePid(pid:number):void{if(!validPid(pid))throw new Error('The system-output helper did not report a valid process ID.');this.saveNativeWrite(pid);} + finishNativeWrite():void{try{unlinkSync(join(this.directory,'native-write.json'));}catch(error){if(codeOf(error)!=='ENOENT')throw error;}} + + private saveNativeWrite(helperPid:number|null):void{ + const temporary=join(this.directory,`.native-write-${process.pid}-${randomUUID()}`); + try{writeFileSync(temporary,JSON.stringify({version:1,ownerPid:process.pid,helperPid}),{mode:0o600,flag:'wx'});renameSync(temporary,join(this.directory,'native-write.json'));} + catch(error){try{rmSync(temporary,{force:true});}catch{}throw error;} + } + + private nativeWriteSettled():boolean{ + const path=join(this.directory,'native-write.json');let value:{version?:number;ownerPid?:number;helperPid?:number|null}; + try{value=JSON.parse(readFileSync(path,'utf8')) as typeof value;} + catch(error){if(codeOf(error)==='ENOENT')return true;throw this.unknownNativeWrite(path);} + if(value?.version!==1||!validPid(value.ownerPid)||!validPid(value.helperPid))throw this.unknownNativeWrite(path); + if(volumeProcessAlive(value.helperPid))return false; + // Removal is only allowed after acquiring the lock, so an old observer + // cannot remove a newer owner's journal. + this.finishNativeWrite();return true; + } + + private unknownNativeWrite(path:string):Error{return new Error(`An interrupted system-output helper could not be identified; the saved volume was retained. Restart the computer, then remove ${path} before retrying system output control.`);} + private async waitForNativeWrite(deadline:number):Promise{while(!this.nativeWriteSettled()){if(Date.now()>=deadline)throw new Error('A system-output helper is still running; the saved volume will be recovered after it exits.');await pause();}} + + private async lock():Promise<()=>void>{ + mkdirSync(this.directory,{recursive:true,mode:0o700}); + const marker=`owner-${process.pid}-${randomUUID()}`; + const candidate=join(this.directory,`.lock-${marker}`);const lock=join(this.directory,'lock'); + mkdirSync(candidate,{mode:0o700}); + let published=false; + try{ + writeFileSync(join(candidate,marker),'',{mode:0o600,flag:'wx'}); + const deadline=Date.now()+this.timeoutMs; + while(true){ + let acquired=false; + try{renameSync(candidate,lock);published=true;acquired=true;} + catch(error){if(!['EEXIST','ENOTEMPTY','EPERM','EACCES'].includes(codeOf(error)??''))throw error;} + if(acquired){try{await this.waitForNativeWrite(deadline);}catch(error){finishLock(lock,marker);throw error;}return()=>finishLock(lock,marker);} + if(Date.now()>=deadline)throw new Error('Shared output-volume ownership is busy; its live owner was left intact.'); + let owners:string[]; + try{owners=readdirSync(lock);}catch(error){if(codeOf(error)==='ENOENT'){await pause();continue;}throw error;} + if(owners.length===0){removeEmptyLock(lock);continue;} + const owner=owners[0]!;const match=/^(owner|released)-([1-9]\d*)-([a-f\d-]{36})$/.exec(owner); + if(owners.length!==1||!match||!validPid(Number(match[2]))||!validId(match[3]))throw new Error('Shared output-volume ownership lock is unreadable; it was left intact.'); + if(match[1]==='released'||!volumeProcessAlive(Number(match[2]))){ + // Claim this exact dead generation, never rename/remove a replacement + // lock directory. A reaper that dies is recovered by the same protocol. + const reaper=`owner-${process.pid}-${randomUUID()}`; + try{renameSync(join(lock,owner),join(lock,reaper));} + catch(error){if(codeOf(error)==='ENOENT')continue;throw error;} + try{await this.waitForNativeWrite(deadline);} + catch(error){try{renameSync(join(lock,reaper),join(lock,owner));}catch{finishLock(lock,reaper);}throw error;} + finishLock(lock,reaper);await pause();continue; + } + await pause(); + } + }finally{if(!published)try{rmSync(candidate,{recursive:true,force:true});}catch{}} + } +} + +export function volumeProcessAlive(pid:number):boolean{ + try{process.kill(pid,0);return true;}catch(error){return codeOf(error)!=='ESRCH';} +} +function finishLock(directory:string,marker:string):void{ + const released=`released-${process.pid}-${randomUUID()}`; + try{renameSync(join(directory,marker),join(directory,released));} + catch{try{removeOwnedLock(directory,marker);}catch{}return;} + // Do not lose a returned lease or replace a mixer failure with cleanup errors. + // A published completion can be reclaimed even while this PID remains alive. + try{removeOwnedLock(directory,released);}catch{} +} +function removeOwnedLock(directory:string,marker:string):void{ + try{unlinkSync(join(directory,marker));}catch(error){if(codeOf(error)==='ENOENT')return;throw error;} + removeEmptyLock(directory); +} +function removeEmptyLock(directory:string):void{ + // Another process may already have published a new nonempty generation. + try{rmdirSync(directory);}catch(error){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes(codeOf(error)??''))throw error;} +} +function validPid(value:unknown):value is number{return Number.isSafeInteger(value)&&Number(value)>0;} +function validId(value:unknown):value is string{return typeof value==='string'&&/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/.test(value);} +function validSnapshot(value:unknown):value is OutputSnapshot{if(!value||typeof value!=='object')return false;const state=value as OutputSnapshot;return Number.isInteger(state.volume)&&state.volume>=0&&state.volume<=100&&typeof state.muted==='boolean';} +function codeOf(error:unknown):string|undefined{return(error as NodeJS.ErrnoException)?.code;} +function pause():Promise{return new Promise(resolve=>setTimeout(resolve,20));} diff --git a/src/alarms/system-volume-process.test.ts b/src/alarms/system-volume-process.test.ts new file mode 100644 index 0000000..747d91a --- /dev/null +++ b/src/alarms/system-volume-process.test.ts @@ -0,0 +1,102 @@ +import {spawn} from 'node:child_process'; +import {mkdtempSync,readFileSync,rmSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach,beforeEach,describe,expect,it} from 'vitest'; + +let directory:string;let mixer:string; +type OutputWorker={held:Promise;call(action:string,fields:Record):Promise;stop():Promise}; +const workers:OutputWorker[]=[]; +beforeEach(()=>{directory=mkdtempSync(join(tmpdir(),'radiocli-volume-process-'));mixer=join(directory,'fake-output.json');writeFileSync(mixer,JSON.stringify({volume:20,muted:true}));}); +afterEach(async()=>{await Promise.all(workers.splice(0).map(worker=>worker.stop()));rmSync(directory,{recursive:true,force:true});}); +function output(){return JSON.parse(readFileSync(mixer,'utf8')) as {volume:number;muted:boolean};} + +describe('system output ownership between real processes',()=>{ + it.each([false,true])('restores once after overlapping processes with separate library profiles end (reverse release: %s)',async reverse=>{ + const [first,second]=await Promise.all([startWorker('first'),startWorker('second')]); + await first.call('acquire',{key:'a',target:40});await second.call('acquire',{key:'b',target:70}); + await (reverse?second:first).call('release',{key:reverse?'b':'a'}); + expect(output()).toEqual({volume:70,muted:false}); + await (reverse?first:second).call('release',{key:reverse?'a':'b'}); + expect(output()).toEqual({volume:20,muted:true}); + expect(readFileSync(join(directory,'writes.jsonl'),'utf8').trim().split('\n').map(line=>JSON.parse(line))).toEqual([{volume:40,muted:false},{volume:70,muted:false},{volume:20,muted:true}]); + }); + + it('serializes simultaneous acquisition and release across processes',async()=>{ + const [first,second]=await Promise.all([startWorker('first'),startWorker('second')]); + await Promise.all([first.call('acquire',{key:'a',target:40}),second.call('acquire',{key:'b',target:70})]); + expect(output()).toEqual({volume:70,muted:false}); + await Promise.all([first.call('release',{key:'a'}),second.call('release',{key:'b'})]); + expect(output()).toEqual({volume:20,muted:true}); + }); + + it('restores the original baseline when an earlier participating process dies',async()=>{ + const first=await startWorker('first');const second=await startWorker('second'); + await first.call('acquire',{key:'a',target:40});await second.call('acquire',{key:'b',target:70}); + await first.stop();expect(output()).toEqual({volume:70,muted:false}); + await second.call('release',{key:'b'});expect(output()).toEqual({volume:20,muted:true}); + }); + + it('recovers a dead mutation owner and its persisted baseline after an interrupted write',async()=>{ + const first=await startWorker('first'); + const interrupted=first.call('acquire',{key:'a',target:70,hold:'write'}).catch(()=>undefined); + await first.held;expect(output()).toEqual({volume:70,muted:false});await first.stop();await interrupted; + const second=await startWorker('second');await second.call('acquire',{key:'b',target:80}); + await second.call('release',{key:'b'});expect(output()).toEqual({volume:20,muted:true}); + }); + + it('preserves a handoff after the original owner exits and another alarm releases',async()=>{ + const first=await startWorker('first');const second=await startWorker('second'); + await first.call('acquire',{key:'a',target:40});await second.call('acquire',{key:'b',target:70}); + await first.call('release',{key:'a',preserve:true});await first.stop(); + await second.call('release',{key:'b'});expect(output()).toEqual({volume:70,muted:false}); + await second.call('acquire',{key:'c',target:90});await second.call('release',{key:'c'}); + expect(output()).toEqual({volume:70,muted:false}); + }); +}); + +async function startWorker(profile:string):Promise{ + const source=` + import {createSystemVolumeController} from ${JSON.stringify(new URL('./system-volume.ts',import.meta.url).href)}; + import {appendFileSync,readFileSync,writeFileSync} from 'node:fs'; + import {join} from 'node:path'; + const [directory,mixer]=process.argv.slice(1);const leases=new Map();let hold; + const run=async(_command,args)=>{ + if(args.includes('set s to get volume settings')){const state=JSON.parse(readFileSync(mixer,'utf8'));return{code:0,stdout:state.volume+','+state.muted,stderr:''};} + const match=/set volume output volume (\\d+) (with|without) output muted/.exec(args.join(' ')); + if(!match)throw new Error('Unexpected fake mixer invocation'); + const state={volume:Number(match[1]),muted:match[2]==='with'}; + writeFileSync(mixer,JSON.stringify(state));appendFileSync(join(directory,'writes.jsonl'),JSON.stringify(state)+'\\n'); + if(hold==='write'){process.send({event:'held'});await new Promise(()=>{});} + return{code:0,stdout:'',stderr:''}; + }; + const controller=createSystemVolumeController('darwin',run,()=>null,{directory}); + process.on('message',async message=>{ + try{ + if(message.action==='acquire'){hold=message.hold;leases.set(message.key,await controller.acquireMinimum(message.target));} + else if(message.action==='release'){await leases.get(message.key).release({preserve:message.preserve});} + else throw new Error('Unknown test action'); + process.send({id:message.id}); + }catch(error){process.send({id:message.id,error:String(error)});} + }); + process.send({event:'ready'}); + `; + const child=spawn(process.execPath,['--import','tsx','--input-type=module','-e',source,directory,mixer],{env:{...process.env,RADIOCLI_HOME:join(directory,profile),XDG_RUNTIME_DIR:join(directory,`${profile}-runtime`)},stdio:['ignore','pipe','pipe','ipc'],windowsHide:true,timeout:15_000,killSignal:'SIGKILL'}); + let stderr='';let readyResolve:()=>void=()=>{};let readyReject:(error:Error)=>void=()=>{};let heldResolve:()=>void=()=>{};let sequence=0; + const ready=new Promise((resolve,reject)=>{readyResolve=resolve;readyReject=reject;}); + const held=new Promise(resolve=>{heldResolve=resolve;}); + const pending=new Map(); + child.stderr!.on('data',data=>{stderr+=String(data);}); + child.on('message',(message:{event?:string;id?:number;error?:string})=>{ + if(message.event==='ready')readyResolve();else if(message.event==='held')heldResolve(); + else if(message.id!==undefined){const request=pending.get(message.id);pending.delete(message.id);if(message.error)request?.reject(new Error(message.error));else request?.resolve();} + }); + child.on('error',error=>{readyReject(error);for(const request of pending.values())request.reject(error);pending.clear();}); + const closed=new Promise(resolve=>child.once('close',()=>{const error=new Error(`Fake output worker exited: ${stderr}`);readyReject(error);for(const request of pending.values())request.reject(error);pending.clear();resolve();})); + const worker={ + held, + call(action:string,fields:Record){return new Promise((resolve,reject)=>{const id=++sequence;pending.set(id,{resolve,reject});child.send({id,action,...fields},error=>{if(error){pending.delete(id);reject(error);}});});}, + async stop(){if(child.exitCode===null&&child.signalCode===null)child.kill('SIGKILL');await closed;} + }; + workers.push(worker);await ready;return worker; +} diff --git a/src/alarms/system-volume.test.ts b/src/alarms/system-volume.test.ts index c09e369..7c83ece 100644 --- a/src/alarms/system-volume.test.ts +++ b/src/alarms/system-volume.test.ts @@ -1,7 +1,151 @@ -import {describe,expect,it,vi} from 'vitest'; -import {createSystemVolumeController} from './system-volume.js'; +import {afterEach,beforeEach,describe,expect,it,vi} from 'vitest'; +import * as fs from 'node:fs'; +import {existsSync,mkdirSync,mkdtempSync,readFileSync,rmSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {createSystemVolumeController as createController} from './system-volume.js'; + +vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); + +let directory:string; +beforeEach(()=>{directory=mkdtempSync(join(tmpdir(),'radiocli-volume-'));}); +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();rmSync(directory,{recursive:true,force:true});}); +function createSystemVolumeController(...args:Parameters){return createController(args[0],args[1],args[2],{directory});} + +function fakeOutput(volume=20,muted=true){ + let state={volume,muted};const writes:Array=[]; + const run=vi.fn(async(_command:string,args:string[])=>{ + if(args.includes('set s to get volume settings'))return{code:0,stdout:`${state.volume},${state.muted}`,stderr:''}; + const match=/set volume output volume (\d+) (with|without) output muted/.exec(args.join(' ')); + if(!match)throw new Error('Unexpected fake mixer command'); + state={volume:Number(match[1]),muted:match[2]==='with'};writes.push({...state}); + return{code:0,stdout:'',stderr:''}; + }); + return{run,writes,state:()=>({...state})}; +} describe('alarm system output volume',()=>{ + it.each([false,true])('keeps overlapping alarms audible and restores the shared baseline (reverse release: %s)',async reverse=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + const second=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + await (reverse?second:first).release(); + expect(output.state()).toEqual({volume:70,muted:false}); + await (reverse?first:second).release(); + expect(output.state()).toEqual({volume:20,muted:true}); + expect(output.writes).toEqual([{volume:40,muted:false},{volume:70,muted:false},{volume:20,muted:true}]); + }); + + it('joins an already-loud output lease so another alarm cannot restore underneath it',async()=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + const second=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + await first.release();expect(output.state()).toEqual({volume:70,muted:false}); + await second.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('serializes concurrent acquisitions and duplicate releases',async()=>{ + const output=fakeOutput(); + const [first,second]=await Promise.all([createSystemVolumeController('darwin',output.run).acquireMinimum(40),createSystemVolumeController('darwin',output.run).acquireMinimum(70)]); + await Promise.all([first.release(),first.release(),second.release(),second.release()]); + expect(output.state()).toEqual({volume:20,muted:true}); + expect(output.writes.filter(state=>state.muted)).toHaveLength(1); + }); + + it('preserves handed-off output after remaining alarms end and rebases later raises',async()=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + const second=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + await first.release({preserve:true}); + const third=await createSystemVolumeController('darwin',output.run).acquireMinimum(90); + await second.release();expect(output.state()).toEqual({volume:90,muted:false}); + await third.release();expect(output.state()).toEqual({volume:70,muted:false}); + const fourth=await createSystemVolumeController('darwin',output.run).acquireMinimum(80); + await fourth.release();expect(output.state()).toEqual({volume:70,muted:false}); + }); + + it('rolls a failed overlapping adjustment back to the active alarm output',async()=>{ + const output=fakeOutput(); + const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + const failRaise=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.join(' ').includes('output volume 70 ')?{...result,code:1,stderr:'partial adjustment'}:result;}; + await expect(createSystemVolumeController('darwin',failRaise).acquireMinimum(70)).rejects.toThrow('partial adjustment'); + expect(output.state()).toEqual({volume:40,muted:false}); + await first.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('retains the original baseline after both an adjustment and its rollback fail',async()=>{ + const output=fakeOutput(); + const failWrites=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.includes('set s to get volume settings')?result:{...result,code:1,stderr:'mixer failed'};}; + await expect(createSystemVolumeController('darwin',failWrites).acquireMinimum(70)).rejects.toThrow(/restoring.*failed/); + expect(existsSync(join(directory,'state.json'))).toBe(true); + const next=await createSystemVolumeController('darwin',output.run).acquireMinimum(80); + await next.release();expect(output.state()).toEqual({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it('recovers a failed final restoration before taking a later alarm baseline',async()=>{ + const output=fakeOutput();let failRestore=true; + const run=async(command:string,args:string[])=>{if(failRestore&&args.join(' ').includes('output volume 20 '))return{code:1,stdout:'',stderr:'restore failed'};return output.run(command,args);}; + const first=await createSystemVolumeController('darwin',run).acquireMinimum(70); + await expect(first.release()).rejects.toThrow('restore failed'); + expect(output.state()).toEqual({volume:70,muted:false}); + failRestore=false; + const second=await createSystemVolumeController('darwin',run).acquireMinimum(80); + await first.release();expect(output.state()).toEqual({volume:80,muted:false}); + await second.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('allows concurrent release retries after a failed final restoration',async()=>{ + const output=fakeOutput();let attempts=0; + const run=async(command:string,args:string[])=>{if(args.join(' ').includes('output volume 20 ')&&++attempts===1)return{code:1,stdout:'',stderr:'retry restore'};return output.run(command,args);}; + const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70); + const results=await Promise.allSettled([lease.release(),lease.release(),lease.release()]); + expect(results.map(result=>result.status).sort()).toEqual(['fulfilled','fulfilled','rejected']); + expect(output.state()).toEqual({volume:20,muted:true});expect(attempts).toBe(2); + }); + + it('does not touch the mixer when saved ownership is corrupt',async()=>{ + const text='{"version":77,"baseline":"unknown"}';writeFileSync(join(directory,'state.json'),text); + const output=fakeOutput();await expect(createSystemVolumeController('darwin',output.run).acquireMinimum(70)).rejects.toThrow(/ownership state is unreadable/); + expect(output.run).not.toHaveBeenCalled();expect(readFileSync(join(directory,'state.json'),'utf8')).toBe(text); + }); + + it('does not raise output if the original state cannot be saved',async()=>{ + const output=fakeOutput(); + const run=async(command:string,args:string[])=>{const result=await output.run(command,args);if(args.includes('set s to get volume settings'))mkdirSync(join(directory,'state.json'));return result;}; + await expect(createSystemVolumeController('darwin',run).acquireMinimum(70)).rejects.toThrow(); + expect(output.writes).toEqual([]);expect(output.state()).toEqual({volume:20,muted:true}); + }); + + it('does not leave a live phantom participant when adjustment, rollback and recovery persistence all fail',async()=>{ + const output=fakeOutput();const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(40); + let saves=0;const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'state.json')&&++saves===2)throw new Error('recovery disk failure');return rename(source,destination);}); + const failWrites=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.includes('set s to get volume settings')?result:{...result,code:1,stderr:'mixer failure'};}; + await expect(createSystemVolumeController('darwin',failWrites).acquireMinimum(70)).rejects.toThrow(/mixer failure.*restoring.*mixer failure.*recovery disk failure/); + await first.release();expect(output.state()).toEqual({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it('recovers an unreturned lease while its process lives when rollback succeeds but cleanup persistence fails',async()=>{ + const output=fakeOutput();const unlink=fs.unlinkSync;let failed=false; + vi.spyOn(fs,'unlinkSync').mockImplementation(path=>{if(String(path)===join(directory,'state.json')&&!failed){failed=true;throw new Error('recovery cleanup failure');}return unlink(path);}); + const failRaise=async(command:string,args:string[])=>{const result=await output.run(command,args);return args.join(' ').includes('output volume 70 ')?{...result,code:1,stderr:'raise failed'}:result;}; + await expect(createSystemVolumeController('darwin',failRaise).acquireMinimum(70)).rejects.toThrow(/raise failed.*recovery cleanup failure/); + const next=await createSystemVolumeController('darwin',output.run).acquireMinimum(80); + await next.release();expect(output.state()).toEqual({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it.each([40,80])('rolls back an acquisition whose active ownership commit fails (target: %s)',async target=>{ + const output=fakeOutput();const first=await createSystemVolumeController('darwin',output.run).acquireMinimum(70); + let saves=0;const rename=fs.renameSync; + vi.spyOn(fs,'renameSync').mockImplementation((source,destination)=>{if(String(destination)===join(directory,'state.json')&&++saves===2)throw new Error('ownership commit failed');return rename(source,destination);}); + await expect(createSystemVolumeController('darwin',output.run).acquireMinimum(target)).rejects.toThrow('ownership commit failed'); + expect(output.state()).toEqual({volume:70,muted:false}); + await first.release();expect(output.state()).toEqual({volume:20,muted:true}); + }); + it('unmutes and raises macOS output for an alarm, then restores it',async()=>{const calls:Array<{command:string;args:string[]}>=[];const run=vi.fn(async(command:string,args:string[])=>{calls.push({command,args});return{code:0,stdout:args.includes('set s to get volume settings')?'20,true\n':'',stderr:''};});const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70);expect(calls[1]?.args.join(' ')).toContain('set volume output volume 70 without output muted');await lease.release();expect(calls[2]?.args.join(' ')).toContain('set volume output volume 20 with output muted');}); it('does not lower a device that is already louder than the alarm setting',async()=>{const run=vi.fn(async()=>({code:0,stdout:'85,false\n',stderr:''}));const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70);expect(run).toHaveBeenCalledOnce();await lease.release();expect(run).toHaveBeenCalledOnce();}); @@ -11,4 +155,26 @@ describe('alarm system output volume',()=>{ it('uses Windows Core Audio through built-in PowerShell',async()=>{const run=vi.fn(async(_command:string,args:string[])=>({code:0,stdout:args.at(-1)?.includes('::Get')?'10,true':'',stderr:''}));const controller=createSystemVolumeController('win32',run,command=>command==='powershell.exe'?'powershell.exe':null);const lease=await controller.acquireMinimum(55);const setScript=(run.mock.calls as unknown as Array<[string,string[]]>)[1]?.[1].at(-1)??'';expect(setScript).toContain('[RadioCliVolume]::Set(55, $false)');await lease.release();}); it('fails truthfully when no platform volume backend exists',async()=>{await expect(createSystemVolumeController('linux',vi.fn(),()=>null).acquireMinimum(50)).rejects.toThrow(/unavailable/i);}); + + it('restores the previous volume and mute state when an adjustment only partly succeeds',async()=>{ + const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='set-mute'&&args[2]==='0'?1:0,stdout:args[0]==='get-volume'?'Volume: 0.15 [MUTED]':'',stderr:args[0]==='set-mute'&&args[2]==='0'?'mute denied':''})); + await expect(createSystemVolumeController('linux',run,()=>'/test/wpctl').acquireMinimum(60)).rejects.toThrow(/mute denied/); + expect(run.mock.calls.map(call=>call[1])).toEqual([['get-volume','@DEFAULT_AUDIO_SINK@'],['set-volume','@DEFAULT_AUDIO_SINK@','60%'],['set-mute','@DEFAULT_AUDIO_SINK@','0'],['set-volume','@DEFAULT_AUDIO_SINK@','15%'],['set-mute','@DEFAULT_AUDIO_SINK@','1']]); + }); + + it('reports restoration failure after a partially applied adjustment',async()=>{ + const run=vi.fn(async(_command:string,args:string[])=>({code:args[0]==='set-mute'||args[2]==='15%'?1:0,stdout:args[0]==='get-volume'?'Volume: 0.15 [MUTED]':'',stderr:args[2]==='15%'?'restore denied':'mute denied'})); + await expect(createSystemVolumeController('linux',run,()=>'/test/wpctl').acquireMinimum(60)).rejects.toThrow(/mute denied.*restor.*restore denied/i); + }); + + it('allows a failed restoration to be retried when the lease is released again',async()=>{ + let restorationAttempts=0; + const run=vi.fn(async(_command:string,args:string[])=>{const restoring=args.some(arg=>arg.includes('set volume output volume 20 '));if(restoring)restorationAttempts+=1;return{code:restoring&&restorationAttempts===1?1:0,stdout:args.includes('set s to get volume settings')?'20,true':'',stderr:'temporary mixer failure'};}); + const lease=await createSystemVolumeController('darwin',run).acquireMinimum(70); + await expect(lease.release()).rejects.toThrow(/temporary mixer failure/);await expect(lease.release()).resolves.toBeUndefined();await lease.release();expect(restorationAttempts).toBe(2); + }); + + it('does not control a Termux mixer through the Linux desktop adapter',async()=>{vi.stubEnv('TERMUX_VERSION','0.118.3');const run=vi.fn();const resolve=vi.fn(()=>'/test/wpctl');await expect(createSystemVolumeController('linux',run,resolve).acquireMinimum(50)).rejects.toThrow(/unavailable.*termux/i);expect(resolve).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();}); + + it.each(['freebsd','openbsd','netbsd','android','haiku','sunos','aix'] as const)('reports unavailable output-volume control on %s without commands',async platform=>{const run=vi.fn();const resolve=vi.fn(()=>'/test/wpctl');await expect(createSystemVolumeController(platform,run,resolve).acquireMinimum(50)).rejects.toThrow(/unavailable/i);expect(resolve).not.toHaveBeenCalled();expect(run).not.toHaveBeenCalled();}); }); diff --git a/src/alarms/system-volume.ts b/src/alarms/system-volume.ts index 7f2c340..4c38292 100644 --- a/src/alarms/system-volume.ts +++ b/src/alarms/system-volume.ts @@ -1,28 +1,95 @@ -import {spawn} from 'node:child_process'; -import {resolveCommand} from '../player/command.js'; +import {spawn,type ChildProcessByStdio} from 'node:child_process'; +import {randomUUID} from 'node:crypto'; +import type {Readable} from 'node:stream'; +import {resolveCommand} from '../platform/executables.js'; +import {identifyPlatform,nativeAdapters} from '../platform/runtime.js'; +import {defaultSystemVolumeDirectory,SystemVolumeOwnership,volumeProcessAlive,type OutputSnapshot,type SystemVolumeState} from './system-volume-ownership.js'; -type Snapshot={volume:number;muted:boolean}; +type Snapshot=OutputSnapshot; type Result={code:number;stdout:string;stderr:string}; type Run=(command:string,args:string[])=>Promise; -export type SystemVolumeLease={release():Promise;message:string}; +export type SystemVolumeLease={release(options?:{preserve?:boolean}):Promise;message:string}; export type SystemVolumeController={acquireMinimum(volume:number):Promise}; -export function createSystemVolumeController(platform:NodeJS.Platform=process.platform,run:Run=runCommand,resolve:(command:string)=>string|null=resolveCommand):SystemVolumeController{ - const backend=platform==='darwin'?macBackend(run):platform==='linux'?linuxBackend(run,resolve):platform==='win32'?windowsBackend(run,resolve):undefined; - return{async acquireMinimum(volume){if(!backend)throw new Error(`System output volume control is unavailable on ${platform}.`);const target=clamp(volume);const before=await backend.read();const applied=Math.max(before.volume,target);if(before.muted||before.volumestring|null=resolveCommand,options:{directory?:string;lockTimeoutMs?:number}={}):SystemVolumeController{ + const host=identifyPlatform({platform});const adapter=nativeAdapters(host).volume; + const backend=adapter==='macos'?macBackend(run):adapter==='unix-audio'?linuxBackend(run,resolve):adapter==='windows'?windowsBackend(run,resolve):undefined; + return{async acquireMinimum(volume){ + if(!backend)throw new Error(`System output volume control is unavailable on ${host.id==='unknown'?platform:host.id}.`); + const ownership=new SystemVolumeOwnership(options.directory??defaultSystemVolumeDirectory(),options.lockTimeoutMs); + const write=(value:Snapshot)=>backend.write(value,run===runCommand?(command,args)=>runCommand(command,args,ownership):run); + return ownership.transaction(async()=>{ + let state=ownership.read(); + if(state){ + state.participants=state.participants.filter(activeParticipant); + if(state.participants.length===0){ + // A crashed owner or failed final restore must not turn the raised + // output into the next alarm's baseline. + if(state.changed&&state.baseline)await write(state.baseline); + ownership.clear();state=undefined; + } + } + const target=clamp(volume);const before=await backend.read();const changed=before.muted||before.volumeitem.id===id?{...item,phase:'active'}:item)}); + }catch(error){ + let restoreError:unknown; + if(changed)try{await write(before);}catch(cause){restoreError=cause;} + try{ + if(restoreError)ownership.save({...joined,participants:joined.participants.filter(item=>item.id!==id)}); + else if(prior)ownership.save(prior);else ownership.clear(); + }catch(saveError){throw new Error(`${messageOf(error)}${restoreError?`; restoring the previous output state also failed: ${messageOf(restoreError)}`:''}; output-volume recovery state could not be updated: ${messageOf(saveError)}`);} + if(restoreError)throw new Error(`${messageOf(error)}; restoring the previous output state also failed: ${messageOf(restoreError)}`); + throw error; + } + let released=false; + return{ + message:changed?`Local output raised to at least ${target}% and unmuted for the alarm.`:`Local output was already at least ${target}% and unmuted.`, + async release({preserve=false}={}){ + if(released)return; + await ownership.transaction(async()=>{ + if(released)return; + const current=ownership.read(); + if(!current||current.generation!==joined.generation){released=true;return;} + current.participants=current.participants.filter(item=>item.id!==id&&activeParticipant(item)); + if(preserve){ + // Handoff commits the current output as the user's new baseline. + // A later acquisition samples it before making another adjustment. + current.baseline=null;current.changed=false; + } + ownership.save(current); + if(current.participants.length===0){ + if(current.changed&¤t.baseline)await write(current.baseline); + ownership.clear(); + } + released=true; + }); + } + }; + }); + }}; } -type Backend={read():Promise;write(value:Snapshot):Promise}; -function macBackend(run:Run):Backend{return{async read(){const result=await checked(run('/usr/bin/osascript',['-e','set s to get volume settings','-e','return (output volume of s as text) & "," & (output muted of s as text)']),'read macOS output volume');const match=/^(\d+)\s*,\s*(true|false)/i.exec(result.stdout.trim());if(!match)throw new Error('macOS returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value){await checked(run('/usr/bin/osascript',['-e',`set volume output volume ${clamp(value.volume)} ${value.muted?'with':'without'} output muted`]),'set macOS output volume');}};} +function activeParticipant(item:SystemVolumeState['participants'][number]):boolean{return item.phase==='active'&&volumeProcessAlive(item.pid);} + +type Backend={read():Promise;write(value:Snapshot,execute?:Run):Promise}; +function macBackend(run:Run):Backend{return{async read(){const result=await checked(run('/usr/bin/osascript',['-e','set s to get volume settings','-e','return (output volume of s as text) & "," & (output muted of s as text)']),'read macOS output volume');const match=/^(\d+)\s*,\s*(true|false)/i.exec(result.stdout.trim());if(!match)throw new Error('macOS returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value,execute=run){await checked(execute('/usr/bin/osascript',['-e',`set volume output volume ${clamp(value.volume)} ${value.muted?'with':'without'} output muted`]),'set macOS output volume');}};} function linuxBackend(run:Run,resolve:(command:string)=>string|null):Backend|undefined{ - const wpctl=resolve('wpctl');if(wpctl)return{async read(){const result=await checked(run(wpctl,['get-volume','@DEFAULT_AUDIO_SINK@']),'read PipeWire output volume');const match=/Volume:\s*([\d.]+)/i.exec(result.stdout);if(!match)throw new Error('wpctl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])*100),muted:/\[MUTED\]/i.test(result.stdout)};},async write(value){await checked(run(wpctl,['set-volume','@DEFAULT_AUDIO_SINK@',`${clamp(value.volume)}%`]),'set PipeWire output volume');await checked(run(wpctl,['set-mute','@DEFAULT_AUDIO_SINK@',value.muted?'1':'0']),'set PipeWire mute state');}}; - const pactl=resolve('pactl');if(pactl)return{async read(){const [volume,mute]=await Promise.all([checked(run(pactl,['get-sink-volume','@DEFAULT_SINK@']),'read PulseAudio output volume'),checked(run(pactl,['get-sink-mute','@DEFAULT_SINK@']),'read PulseAudio mute state')]);const match=/(\d+)%/.exec(volume.stdout);if(!match)throw new Error('pactl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:/yes/i.test(mute.stdout)};},async write(value){await checked(run(pactl,['set-sink-volume','@DEFAULT_SINK@',`${clamp(value.volume)}%`]),'set PulseAudio output volume');await checked(run(pactl,['set-sink-mute','@DEFAULT_SINK@',value.muted?'1':'0']),'set PulseAudio mute state');}}; - const amixer=resolve('amixer');if(amixer)return{async read(){const result=await checked(run(amixer,['get','Master']),'read ALSA output volume');const matches=[...result.stdout.matchAll(/\[(\d+)%\]/g)];const last=matches.at(-1);if(!last)throw new Error('amixer returned an unreadable output-volume state.');return{volume:clamp(Number(last[1])),muted:/\[off\]/i.test(result.stdout)};},async write(value){await checked(run(amixer,['set','Master',`${clamp(value.volume)}%`,value.muted?'mute':'unmute']),'set ALSA output volume');}}; + const wpctl=resolve('wpctl');if(wpctl)return{async read(){const result=await checked(run(wpctl,['get-volume','@DEFAULT_AUDIO_SINK@']),'read PipeWire output volume');const match=/Volume:\s*([\d.]+)/i.exec(result.stdout);if(!match)throw new Error('wpctl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])*100),muted:/\[MUTED\]/i.test(result.stdout)};},async write(value,execute=run){await checked(execute(wpctl,['set-volume','@DEFAULT_AUDIO_SINK@',`${clamp(value.volume)}%`]),'set PipeWire output volume');await checked(execute(wpctl,['set-mute','@DEFAULT_AUDIO_SINK@',value.muted?'1':'0']),'set PipeWire mute state');}}; + const pactl=resolve('pactl');if(pactl)return{async read(){const [volume,mute]=await Promise.all([checked(run(pactl,['get-sink-volume','@DEFAULT_SINK@']),'read PulseAudio output volume'),checked(run(pactl,['get-sink-mute','@DEFAULT_SINK@']),'read PulseAudio mute state')]);const match=/(\d+)%/.exec(volume.stdout);if(!match)throw new Error('pactl returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:/yes/i.test(mute.stdout)};},async write(value,execute=run){await checked(execute(pactl,['set-sink-volume','@DEFAULT_SINK@',`${clamp(value.volume)}%`]),'set PulseAudio output volume');await checked(execute(pactl,['set-sink-mute','@DEFAULT_SINK@',value.muted?'1':'0']),'set PulseAudio mute state');}}; + const amixer=resolve('amixer');if(amixer)return{async read(){const result=await checked(run(amixer,['get','Master']),'read ALSA output volume');const matches=[...result.stdout.matchAll(/\[(\d+)%\]/g)];const last=matches.at(-1);if(!last)throw new Error('amixer returned an unreadable output-volume state.');return{volume:clamp(Number(last[1])),muted:/\[off\]/i.test(result.stdout)};},async write(value,execute=run){await checked(execute(amixer,['set','Master',`${clamp(value.volume)}%`,value.muted?'mute':'unmute']),'set ALSA output volume');}}; return undefined; } -function windowsBackend(run:Run,resolve:(command:string)=>string|null):Backend|undefined{const powershell=resolve('powershell.exe')??resolve('pwsh.exe');if(!powershell)return undefined;return{async read(){const result=await checked(run(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Get()`]),'read Windows output volume');const match=/^(\d+)\s*,\s*(true|false)/im.exec(result.stdout);if(!match)throw new Error('Windows returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value){await checked(run(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted?'true':'false'})`]),'set Windows output volume');}};} +function windowsBackend(run:Run,resolve:(command:string)=>string|null):Backend|undefined{const powershell=resolve('powershell.exe')??resolve('pwsh.exe');if(!powershell)return undefined;return{async read(){const result=await checked(run(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Get()`]),'read Windows output volume');const match=/^(\d+)\s*,\s*(true|false)/im.exec(result.stdout);if(!match)throw new Error('Windows returned an unreadable output-volume state.');return{volume:clamp(Number(match[1])),muted:match[2]?.toLowerCase()==='true'};},async write(value,execute=run){await checked(execute(powershell,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-Command',`${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted?'true':'false'})`]),'set Windows output volume');}};} const windowsCoreAudio=String.raw`Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; @@ -34,5 +101,24 @@ public enum EDataFlow { eRender, eCapture, eAll } public enum ERole { eConsole, public static class RadioCliVolume { static IAudioEndpointVolume Endpoint(){IMMDevice d;((IMMDeviceEnumerator)new MMDeviceEnumerator()).GetDefaultAudioEndpoint(EDataFlow.eRender,ERole.eMultimedia,out d);object o;Guid id=typeof(IAudioEndpointVolume).GUID;d.Activate(ref id,23,IntPtr.Zero,out o);return(IAudioEndpointVolume)o;} public static string Get(){float v;bool m;var e=Endpoint();e.GetMasterVolumeLevelScalar(out v);e.GetMute(out m);return Math.Round(v*100)+","+m.ToString().ToLower();} public static void Set(int v,bool m){var e=Endpoint();e.SetMasterVolumeLevelScalar(Math.Max(0,Math.Min(100,v))/100f,Guid.Empty);e.SetMute(m,Guid.Empty);} } '@`; function clamp(value:number){return Math.max(0,Math.min(100,Math.round(Number.isFinite(value)?value:0)));} +function messageOf(error:unknown){return error instanceof Error?error.message:String(error);} async function checked(promise:Promise,label:string){const result=await promise;if(result.code!==0)throw new Error(`${label} failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);return result;} -function runCommand(command:string,args:string[]):Promise{return new Promise(resolve=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',value=>stdout+=String(value));child.stderr.on('data',value=>stderr+=String(value));child.on('error',error=>resolve({code:127,stdout,stderr:error.message}));child.on('close',code=>resolve({code:code??1,stdout,stderr}));});} +function runCommand(command:string,args:string[],ownership?:SystemVolumeOwnership):Promise{ + return new Promise(resolve=>{ + ownership?.beginNativeWrite(); + let child:ChildProcessByStdio; + try{child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true,timeout:10_000,killSignal:'SIGKILL'});} + catch(error){let message=messageOf(error);try{ownership?.finishNativeWrite();}catch(cleanupError){message+=`; system-output command journal cleanup failed: ${messageOf(cleanupError)}`;}resolve({code:127,stdout:'',stderr:message});return;} + let stdout='';let stderr='';let failure:string|undefined; + child.stdout.on('data',value=>stdout+=String(value));child.stderr.on('data',value=>stderr+=String(value)); + // Spawn/kill errors do not prove the helper has closed. Keep ownership until + // close, including when recording its PID fails after the process starts. + child.on('error',error=>{failure=error.message;}); + child.on('close',(code,signal)=>{ + try{ownership?.finishNativeWrite();}catch(error){failure=`${failure?`${failure}; `:''}system-output command journal cleanup failed: ${messageOf(error)}`;} + resolve({code:failure?1:code??1,stdout,stderr:failure||stderr||(signal?'The output-volume command was interrupted or timed out.':'')}); + }); + if(ownership&&child.pid)try{ownership.recordNativeWritePid(child.pid);} + catch(error){failure=`The system-output helper PID could not be saved: ${messageOf(error)}`;try{child.kill('SIGKILL');}catch(killError){failure+=`; stopping the helper failed: ${messageOf(killError)}`;}} + }); +} diff --git a/src/alarms/terminal-launcher.test.ts b/src/alarms/terminal-launcher.test.ts index 02e6a26..ad6e368 100644 --- a/src/alarms/terminal-launcher.test.ts +++ b/src/alarms/terminal-launcher.test.ts @@ -1,35 +1,69 @@ import {EventEmitter} from 'node:events'; import {spawn as nodeSpawn,type ChildProcess} from 'node:child_process'; +import {connect, type Socket} from 'node:net'; +import {homedir} from 'node:os'; import {describe,expect,it,vi} from 'vitest'; -import {detectAlarmTerminal,openAlarmControls,prepareAlarmTerminalAccess,verifyAlarmTerminalLaunch} from './terminal-launcher.js'; +import {openAlarmControls,prepareAlarmTerminalAccess,verifyAlarmTerminalLaunch} from './terminal-launcher.js'; +import {detectGraphicalTerminal} from '../platform/terminals.js'; function spawnRecorder(){ const calls:Array<{command:string;args:readonly string[]}>=[]; const spawn=vi.fn((command:string,args:readonly string[])=>{calls.push({command,args});const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();queueMicrotask(()=>child.emit('spawn'));return child;}); return{calls,spawn}; } +function powershellInvocation(args: readonly string[]): {command: string; args: string[]} { + const index = args.indexOf('-EncodedCommand'); + expect(index).toBeGreaterThanOrEqual(0); + const script = Buffer.from(args[index + 1]!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + expect(encoded).toBeDefined(); + return JSON.parse(Buffer.from(encoded!, 'base64').toString('utf8')); +} +function bootstrapPayload(args: readonly string[]): {args: string[]; environment: Record} { + return JSON.parse(Buffer.from(args.at(-1)!, 'base64url').toString('utf8')); +} describe('alarm terminal launcher',()=>{ it('remembers recognizable terminal emulators per platform',()=>{ - expect(detectAlarmTerminal('darwin',{TERM_PROGRAM:'iTerm.app'})).toBe('darwin:iterm'); - expect(detectAlarmTerminal('darwin',{})).toBe('darwin:apple-terminal'); - expect(detectAlarmTerminal('win32',{WT_SESSION:'1'})).toBe('win32:windows-terminal'); - expect(detectAlarmTerminal('win32',{})).toBe('win32:console'); + expect(detectGraphicalTerminal('darwin',{TERM_PROGRAM:'iTerm.app'})).toBe('darwin:iterm'); + expect(detectGraphicalTerminal('darwin',{})).toBe('darwin:apple-terminal'); + expect(detectGraphicalTerminal('win32',{WT_SESSION:'1'})).toBe('win32:windows-terminal'); + expect(detectGraphicalTerminal('win32',{})).toBe('win32:console'); }); it('opens Apple Terminal with RadioCLI and its saved data home',async()=>{ const recorded=spawnRecorder(); const result=await openAlarmControls({platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal',RADIOCLI_HOME:"/Data/O'Brien"},nodePath:'/usr/bin/node',cliPath:'/app/cli.js',spawn:recorded.spawn}); - expect(result.opened).toBe(true); + expect(result).toMatchObject({opened:false,requested:true,message:expect.stringMatching(/requested.*unverified/i)}); expect(recorded.calls[0]?.command).toBe('/usr/bin/osascript'); - expect(recorded.calls[0]?.args.at(-1)).toContain("RADIOCLI_HOME='/Data/O'\\''Brien'"); - expect(recorded.calls[0]?.args.at(-1)).toContain("'/app/cli.js'"); + const shell=recorded.calls[0]!.args.at(-1)!; + expect(shell.startsWith("'/usr/bin/node' '-e' ")).toBe(true); + const encoded=/'([A-Za-z0-9_-]+)'$/.exec(shell)?.[1];expect(encoded).toBeDefined(); + expect(bootstrapPayload([encoded!])).toEqual({args:['/app/cli.js'],environment:{RADIOCLI_HOME:"/Data/O'Brien"}}); }); - it('opens the saved Windows Terminal profile in a new RadioCLI tab',async()=>{ + it('reports opened only after the new TUI becomes observable',async()=>{const recorded=spawnRecorder();const hasLiveTui=vi.fn().mockReturnValueOnce(false).mockReturnValue(true);const result=await openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,hasLiveTui,timeoutMs:100});expect(result).toMatchObject({opened:true,requested:true});expect(hasLiveTui).toHaveBeenCalledTimes(2);}); + + it('distinguishes an accepted launch request from a TUI that never appears',async()=>{const recorded=spawnRecorder();const result=await openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,hasLiveTui:()=>false,timeoutMs:20});expect(result).toMatchObject({opened:false,requested:true,message:expect.stringMatching(/requested.*did not become ready/i)});}); + + it('rejects native launcher failure even when a process was spawned',async()=>{const launch=vi.fn(()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();queueMicrotask(()=>{child.emit('spawn');child.emit('close',1);});return child;});await expect(openAlarmControls({platform:'darwin',env:{},nodePath:'/node',cliPath:'/cli.js',spawn:launch})).rejects.toThrow(/terminal.*exit|launcher.*exit/i);}); + + it('requests the saved Windows Terminal with its literal data home in a new tab',async()=>{ const recorded=spawnRecorder(); - await openAlarmControls({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:windows-terminal'},nodePath:'C:\\Node\\node.exe',cliPath:'C:\\RadioCLI\\cli.js',spawn:recorded.spawn}); - expect(recorded.calls[0]).toEqual({command:'wt.exe',args:['-w','new','new-tab','--title','RadioCLI Alarm','C:\\Node\\node.exe','C:\\RadioCLI\\cli.js']}); + const home='C:\\Radio %PATH%! & "quotes" 单播'; + await openAlarmControls({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:windows-terminal',RADIOCLI_HOME:home},nodePath:'C:\\Node\\node.exe',cliPath:'C:\\RadioCLI\\cli.js',spawn:recorded.spawn,resolve:command=>command}); + expect(recorded.calls[0]?.command).toBe('wt.exe'); + expect(recorded.calls[0]?.args.slice(0,7)).toEqual(['-w','new','new-tab','--title','RadioCLI Alarm','powershell.exe','-NoLogo']); + const invocation=powershellInvocation(recorded.calls[0]!.args); + expect(invocation.command).toBe('C:\\Node\\node.exe'); + expect(bootstrapPayload(invocation.args)).toEqual({args:['C:\\RadioCLI\\cli.js'],environment:{RADIOCLI_HOME:home,...(process.platform==='win32'?{USERPROFILE:homedir()}:{})}}); + }); + + it.each(['freebsd','openbsd','netbsd'] as const)('requests an installed graphical terminal on %s',async platform=>{ + const recorded=spawnRecorder();const result=await openAlarmControls({platform,env:{DISPLAY:':0',TERMINAL:'xterm'},nodePath:'/node',cliPath:'/cli.js',spawn:recorded.spawn,resolve:command=>command==='xterm'?'/usr/local/bin/xterm':undefined}); + expect(result).toMatchObject({opened:false,requested:true,terminal:`${platform}:/usr/local/bin/xterm`}); + expect(recorded.calls[0]!.command).toBe('/usr/local/bin/xterm');expect(recorded.calls[0]!.args.slice(0,3)).toEqual(['-e','/node','-e']); + expect(bootstrapPayload(recorded.calls[0]!.args)).toEqual({args:['/cli.js'],environment:{}}); }); it('does not open a second terminal when a RadioCLI TUI is already live',async()=>{ @@ -43,7 +77,25 @@ describe('alarm terminal launcher',()=>{ it('surfaces a denied macOS terminal permission without affecting other platforms',async()=>{const spawn=vi.fn(()=>{const child=new EventEmitter() as ChildProcess;queueMicrotask(()=>child.emit('close',1));return child;});await expect(prepareAlarmTerminalAccess({platform:'darwin',env:{RADIOCLI_ALARM_TERMINAL:'darwin:apple-terminal'},spawn})).rejects.toThrow(/permission.*automatic ringing controls/i);await expect(prepareAlarmTerminalAccess({platform:'win32',spawn})).resolves.toBeUndefined();expect(spawn).toHaveBeenCalledOnce();}); - it('rejects setup verification when no graphical terminal can expose ringing controls',async()=>{await expect(verifyAlarmTerminalLaunch({platform:'freebsd',timeoutMs:10})).rejects.toThrow(/no supported graphical terminal/i);}); + it('rejects setup verification when no graphical terminal can expose ringing controls',async()=>{await expect(verifyAlarmTerminalLaunch({platform:'freebsd',env:{},timeoutMs:10})).rejects.toThrow(/no supported graphical terminal/i);}); - it('requires the launched terminal process to complete an authenticated callback',async()=>{const launch=vi.fn((_command:string,args:readonly string[])=>{const index=args.indexOf(process.execPath);expect(index).toBeGreaterThanOrEqual(0);return nodeSpawn(process.execPath,args.slice(index+1),{stdio:'ignore'});});await expect(verifyAlarmTerminalLaunch({platform:'win32',env:{WT_SESSION:'test'},nodePath:process.execPath,spawn:launch,timeoutMs:2_000})).resolves.toBe('win32:windows-terminal');expect(launch).toHaveBeenCalledOnce();}); + it('requires the launched terminal process to complete an authenticated callback',async()=>{const launch=vi.fn((_command:string,args:readonly string[])=>{const invocation=powershellInvocation(args);expect(invocation.command).toBe(process.execPath);expect(args).not.toContain('-NoExit');return nodeSpawn(invocation.command,invocation.args,{stdio:'ignore'});});await expect(verifyAlarmTerminalLaunch({platform:'win32',env:{WT_SESSION:'test'},nodePath:process.execPath,spawn:launch,resolve:command=>command,timeoutMs:2_000})).resolves.toBe('win32:windows-terminal');expect(launch).toHaveBeenCalledOnce();}); + + it('bounds and closes a callback connection that never sends its token', async () => { + let socket: Socket | undefined; + const launch = vi.fn((_command: string, args: readonly string[]) => { + const child = new EventEmitter() as ChildProcess; + child.unref = vi.fn(); + const probeArgs = bootstrapPayload(powershellInvocation(args).args).args; + const port = Number(probeArgs.at(-3)); + const host = probeArgs.at(-1)!; + socket = connect(port, host); + socket.on('error', () => undefined); + queueMicrotask(() => child.emit('spawn')); + return child; + }); + try { + await expect(verifyAlarmTerminalLaunch({platform: 'win32', env: {WT_SESSION: 'test'}, spawn: launch, resolve: command => command, timeoutMs: 30})).rejects.toThrow(/did not connect back/i); + } finally { socket?.destroy(); } + }); }); diff --git a/src/alarms/terminal-launcher.ts b/src/alarms/terminal-launcher.ts index c123004..e4a96e1 100644 --- a/src/alarms/terminal-launcher.ts +++ b/src/alarms/terminal-launcher.ts @@ -1,76 +1,45 @@ import {spawn, type ChildProcess} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {createServer} from 'node:net'; +import {createServer, type Socket} from 'node:net'; import {randomBytes} from 'node:crypto'; -import {basename, delimiter, join} from 'node:path'; +import {listenLoopback} from '../platform/loopback.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; +import {detectGraphicalTerminal, launchTerminalCommand, type TerminalOptions} from '../platform/terminals.js'; -export type AlarmTerminalLaunchResult = {opened: boolean; terminal: string; message: string}; -type Spawn = (command: string, args: readonly string[]) => ChildProcess; -type LaunchOptions = { - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; +export type AlarmTerminalLaunchResult = {opened: boolean; requested?: boolean; terminal: string; message: string}; +type LaunchOptions = TerminalOptions & { nodePath: string; cliPath: string; - spawn?: Spawn; hasLiveTui?: () => boolean; + timeoutMs?: number; }; -type PermissionOptions={platform?:NodeJS.Platform;env?:NodeJS.ProcessEnv;spawn?:Spawn;permissionTimeoutMs?:number}; -type ProbeOptions=PermissionOptions&{nodePath?:string;timeoutMs?:number}; +type PermissionOptions = TerminalOptions & {permissionTimeoutMs?: number}; +type ProbeOptions = PermissionOptions & {nodePath?: string; timeoutMs?: number}; -const linuxTerminals = new Set(['alacritty','foot','ghostty','gnome-terminal','kitty','konsole','mate-terminal','qterminal','terminator','tilix','wezterm','xfce4-terminal','x-terminal-emulator']); - -export function detectAlarmTerminal(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): string { - const configured = env.RADIOCLI_ALARM_TERMINAL?.trim(); - if (configured && validDescriptor(configured, platform)) return configured; - if (platform === 'darwin') { - const program = `${env.TERM_PROGRAM ?? ''} ${env.__CFBundleIdentifier ?? ''}`.toLowerCase(); - if (program.includes('iterm')) return 'darwin:iterm'; - if (program.includes('wezterm')) return 'darwin:wezterm'; - if (program.includes('ghostty')) return 'darwin:ghostty'; - if (program.includes('kitty')) return 'darwin:kitty'; - return 'darwin:apple-terminal'; - } - if (platform === 'win32') return env.WT_SESSION ? 'win32:windows-terminal' : 'win32:console'; - if (platform === 'linux') { - const requested = env.TERMINAL?.trim() || env.TERM_PROGRAM?.trim(); - const resolved = requested ? resolveExecutable(requested, env) : undefined; - if (resolved && linuxTerminals.has(basename(resolved))) return `linux:${resolved}`; - for (const command of ['ghostty','wezterm','kitty','gnome-terminal','konsole','xfce4-terminal','x-terminal-emulator']) { - const path = resolveExecutable(command, env); if (path) return `linux:${path}`; - } +export async function openAlarmControls(options: LaunchOptions): Promise { + if (options.hasLiveTui?.()) return {opened: false, terminal: 'existing-tui', message: 'An existing RadioCLI TUI will show the ringing controls.'}; + const terminal = await launchTerminalCommand({...options, args: [options.cliPath], title: 'RadioCLI Alarm'}); + if (!options.hasLiveTui) return {opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls; TUI startup is unverified.'}; + const deadline = Date.now() + (options.timeoutMs ?? 8_000); + while (true) { + if (options.hasLiveTui()) return {opened: true, requested: true, terminal, message: 'RadioCLI alarm controls are ready in the saved terminal.'}; + const remaining = deadline - Date.now(); + if (remaining <= 0) return {opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls, but the TUI did not become ready before verification timed out. It may still be starting.'}; + await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining))); } - return `${platform}:unsupported`; } -export async function openAlarmControls(options: LaunchOptions): Promise { - if (options.hasLiveTui?.()) return {opened:false,terminal:'existing-tui',message:'An existing RadioCLI TUI will show the ringing controls.'}; +/** Ask for macOS Automation access while the user is configuring the alarm. */ +export async function prepareAlarmTerminalAccess(options: PermissionOptions = {}): Promise { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; - const terminal = detectAlarmTerminal(platform, env); - const command = shellCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME); - const directArgs = environmentCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME); - const launch = options.spawn ?? spawnDetached; - if (terminal === 'darwin:apple-terminal') await launched(launch('/usr/bin/osascript', appleTerminalScript(command))); - else if (terminal === 'darwin:iterm') await launched(launch('/usr/bin/osascript', iTermScript(command))); - else if (terminal === 'darwin:wezterm') await launched(launch('/usr/bin/open', ['-na','WezTerm','--args','start','--always-new-process','--',...directArgs])); - else if (terminal === 'darwin:ghostty') await launched(launch('/usr/bin/open', ['-na','Ghostty','--args','-e',...directArgs])); - else if (terminal === 'darwin:kitty') await launched(launch('/usr/bin/open', ['-na','kitty','--args','--detach',...directArgs])); - else if (terminal === 'win32:windows-terminal') await launched(launch('wt.exe', ['-w','new','new-tab','--title','RadioCLI Alarm',options.nodePath,options.cliPath])); - else if (terminal === 'win32:console') await launched(launch('cmd.exe', ['/d','/c','start','RadioCLI Alarm','cmd.exe','/k',windowsCommand(options.nodePath,options.cliPath,env.RADIOCLI_HOME)])); - else if (terminal.startsWith('linux:')) { - const executable = terminal.slice('linux:'.length); if (!linuxTerminals.has(basename(executable))) throw new Error('Saved Linux terminal is not supported.'); - const name = basename(executable); const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' ? ['--'] : name === 'wezterm' ? ['start','--always-new-process','--'] : ['-e']; - await launched(launch(executable,[...prefix,...directArgs])); - } else throw new Error('No supported graphical terminal was found. Open radiocli manually and press ! for alarm controls.'); - return {opened:true,terminal,message:'Opened RadioCLI alarm controls in the saved terminal.'}; -} - -/** Ask for macOS Automation access while the user is configuring the alarm. */ -export async function prepareAlarmTerminalAccess(options:PermissionOptions={}):Promise{ - const platform=options.platform??process.platform;if(platform!=='darwin')return; - const terminal=detectAlarmTerminal(platform,options.env??process.env);const application=terminal==='darwin:apple-terminal'?'Terminal':terminal==='darwin:iterm'?'iTerm':undefined;if(!application)return; - const launch=options.spawn??spawnAttached;const child=launch('/usr/bin/osascript',['-e',`tell application "${application}" to count windows`]); - const code=await completed(child,options.permissionTimeoutMs??60_000);if(code!==0)throw new Error(`macOS did not grant RadioCLI permission to control ${application}. Audio can still play, but automatic ringing controls cannot open. Enable Node under System Settings > Privacy & Security > Automation, then press Repair.`); + if (nativeAdapters(identifyPlatform({platform, env})).terminal !== 'macos') return; + const terminal = detectGraphicalTerminal(platform, env, options.resolve); + const application = terminal === 'darwin:apple-terminal' ? 'Terminal' : terminal === 'darwin:iterm' ? 'iTerm' : undefined; + if (!application) return; + const launch = options.spawn ?? spawnAttached; + const child = launch('/usr/bin/osascript', ['-e', `tell application "${application}" to count windows`]); + const code = await completed(child, options.permissionTimeoutMs ?? 60_000); + if (code !== 0) throw new Error(`macOS did not grant RadioCLI permission to control ${application}. Audio can still play, but automatic ringing controls cannot open. Enable Node under System Settings > Privacy & Security > Automation, then press Repair.`); } /** @@ -78,54 +47,78 @@ export async function prepareAlarmTerminalAccess(options:PermissionOptions={}):P * terminal exits immediately after proving that a native background job can * expose the ringing TUI on this desktop session. */ -export async function verifyAlarmTerminalLaunch(options:ProbeOptions={}):Promise{ - const platform=options.platform??process.platform;const env=options.env??process.env; +export async function verifyAlarmTerminalLaunch(options: ProbeOptions = {}): Promise { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; await prepareAlarmTerminalAccess(options); - const terminal=detectAlarmTerminal(platform,env);if(terminal.endsWith(':unsupported'))throw new Error('No supported graphical terminal was found for automatic alarm controls.'); - const token=randomBytes(24).toString('base64url');const server=createServer(); - await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>resolve());}); - const address=server.address();if(!address||typeof address==='string'){server.close();throw new Error('Unable to create the terminal verification channel.');} - const script="const n=require('node:net');const s=n.connect(Number(process.argv[1]),'127.0.0.1',()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));"; - const direct=[options.nodePath??process.execPath,'-e',script,String(address.port),token]; - const command=`${direct.map(shellQuote).join(' ')}; exit`; - const launch=options.spawn??spawnDetached; - const received=new Promise((resolve,reject)=>{server.once('connection',socket=>{let value='';socket.setEncoding('utf8');socket.on('data',chunk=>value+=chunk);socket.on('end',()=>value===token?resolve():reject(new Error('The terminal verification response was not authentic.')));socket.on('error',reject);});}); - try{ - if(terminal==='darwin:apple-terminal')await launched(launch('/usr/bin/osascript',appleTerminalScript(command))); - else if(terminal==='darwin:iterm')await launched(launch('/usr/bin/osascript',iTermScript(command))); - else if(terminal==='darwin:wezterm')await launched(launch('/usr/bin/open',['-na','WezTerm','--args','start','--always-new-process','--',...direct])); - else if(terminal==='darwin:ghostty')await launched(launch('/usr/bin/open',['-na','Ghostty','--args','-e',...direct])); - else if(terminal==='darwin:kitty')await launched(launch('/usr/bin/open',['-na','kitty','--args','--detach',...direct])); - else if(terminal==='win32:windows-terminal')await launched(launch('wt.exe',['-w','new','new-tab','--title','RadioCLI Alarm Verification',...direct])); - else if(terminal==='win32:console')await launched(launch('cmd.exe',['/d','/c','start','RadioCLI Alarm Verification','cmd.exe','/c',windowsCommandArgs(direct)])); - else if(terminal.startsWith('linux:')){const executable=terminal.slice(6);const name=basename(executable);const prefix=name==='gnome-terminal'||name==='mate-terminal'||name==='xfce4-terminal'?['--']:name==='wezterm'?['start','--always-new-process','--']:['-e'];await launched(launch(executable,[...prefix,...direct]));} - else throw new Error('The saved terminal is not supported for alarm controls.'); - await withTimeout(received,options.timeoutMs??8_000,'The terminal opened but did not connect back to RadioCLI.'); + const terminal = detectGraphicalTerminal(platform, env, options.resolve); + if (terminal.endsWith(':unsupported')) throw new Error('No supported graphical terminal was found for automatic alarm controls.'); + const token = randomBytes(24).toString('base64url'); + const server = createServer(); + const address = await listenLoopback(server); + const script = "const n=require('node:net');const s=n.connect(Number(process.argv[1]),process.argv[3],()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));"; + const nodePath = options.nodePath ?? process.execPath; + const args = ['-e', script, String(address.port), token, address.host]; + const sockets = new Set(); + const received = new Promise((resolve, reject) => { + server.on('connection', socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + let value = ''; + socket.setEncoding('utf8'); + socket.on('data', chunk => { + value += chunk; + if (value.length > token.length) { + socket.destroy(); + reject(new Error('The terminal verification response was not authentic.')); + } + }); + socket.on('end', () => value === token ? resolve() : reject(new Error('The terminal verification response was not authentic.'))); + socket.on('error', reject); + }); + }); + // A callback can fail while the launcher is still reporting its own result. + // Keep the rejection observed until it is awaited below. + void received.catch(() => undefined); + try { + await launchTerminalCommand({...options, nodePath, args, title: 'RadioCLI Alarm Verification', closeOnExit: true}); + await withTimeout(received, options.timeoutMs ?? 8_000, 'The terminal opened but did not connect back to RadioCLI.'); return terminal; - }finally{await new Promise(resolve=>server.close(()=>resolve()));} + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } } -function validDescriptor(value:string,platform:NodeJS.Platform):boolean { - if(platform==='darwin')return['darwin:apple-terminal','darwin:iterm','darwin:wezterm','darwin:ghostty','darwin:kitty'].includes(value); - if(platform==='win32')return value==='win32:windows-terminal'||value==='win32:console'; - if(platform==='linux'&&value.startsWith('linux:'))return linuxTerminals.has(basename(value.slice(6))); - return false; +function spawnAttached(command: string, args: readonly string[]): ChildProcess { + return spawn(command, [...args], {stdio: 'ignore', windowsHide: true}); } -function resolveExecutable(input:string,env:NodeJS.ProcessEnv):string|undefined { - if(input.includes('/')&&existsSync(input))return input; - for(const directory of (env.PATH??'').split(delimiter)){const path=join(directory,input);if(existsSync(path))return path;} - return undefined; + +function completed(child: ChildProcess, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (work: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + work(); + }; + const timer = setTimeout(() => { + child.kill('SIGTERM'); + finish(() => reject(new Error('Timed out waiting for the macOS Automation permission response.'))); + }, timeoutMs); + child.once('error', error => finish(() => reject(error))); + child.once('close', code => finish(() => resolve(code ?? 1))); + }); +} + +async function withTimeout(promise: Promise, milliseconds: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), milliseconds); + })]); + } finally { + if (timer) clearTimeout(timer); + } } -function shellQuote(value:string):string{return `'${value.replaceAll("'",`'\\''`)}'`;} -function shellCommand(nodePath:string,cliPath:string,home?:string):string{return `${home?`RADIOCLI_HOME=${shellQuote(home)} `:''}${shellQuote(nodePath)} ${shellQuote(cliPath)}`;} -function environmentCommand(nodePath:string,cliPath:string,home?:string):string[]{return home?['/usr/bin/env',`RADIOCLI_HOME=${home}`,nodePath,cliPath]:[nodePath,cliPath];} -function windowsCommand(nodePath:string,cliPath:string,home?:string):string{return `${home?`set "RADIOCLI_HOME=${cmdEscape(home)}" && `:''}"${nodePath.replaceAll('"','""')}" "${cliPath.replaceAll('"','""')}"`;} -function windowsCommandArgs(values:string[]):string{return values.map(value=>`"${value.replaceAll('"','""')}"`).join(' ');} -function cmdEscape(value:string){return value.replaceAll('%','%%').replaceAll('"','""').replaceAll('^','^^').replaceAll('&','^&').replaceAll('|','^|').replaceAll('<','^<').replaceAll('>','^>');} -function appleTerminalScript(command:string):string[]{return['-e','on run argv','-e','tell application "Terminal"','-e','activate','-e','do script (item 1 of argv)','-e','end tell','-e','end run',command];} -function iTermScript(command:string):string[]{return['-e','on run argv','-e','tell application "iTerm"','-e','activate','-e','set w to (create window with default profile)','-e','tell current session of w to write text (item 1 of argv)','-e','end tell','-e','end run',command];} -function spawnDetached(command:string,args:readonly string[]):ChildProcess{return spawn(command,[...args],{detached:true,stdio:'ignore',windowsHide:false});} -function spawnAttached(command:string,args:readonly string[]):ChildProcess{return spawn(command,[...args],{stdio:'ignore',windowsHide:true});} -function launched(child:ChildProcess):Promise{return new Promise((resolve,reject)=>{child.once('error',reject);child.once('spawn',()=>{child.unref();resolve();});});} -function completed(child:ChildProcess,timeoutMs:number):Promise{return new Promise((resolve,reject)=>{let settled=false;const finish=(work:()=>void)=>{if(settled)return;settled=true;clearTimeout(timer);work();};const timer=setTimeout(()=>{child.kill('SIGTERM');finish(()=>reject(new Error('Timed out waiting for the macOS Automation permission response.')));},timeoutMs);child.once('error',error=>finish(()=>reject(error)));child.once('close',code=>finish(()=>resolve(code??1)));});} -async function withTimeout(promise:Promise,milliseconds:number,message:string):Promise{let timer:NodeJS.Timeout|undefined;try{return await Promise.race([promise,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(message)),milliseconds);})]);}finally{if(timer)clearTimeout(timer);}} diff --git a/src/alarms/tui-presence.ts b/src/alarms/tui-presence.ts index 5166550..bad05d2 100644 --- a/src/alarms/tui-presence.ts +++ b/src/alarms/tui-presence.ts @@ -1,5 +1,5 @@ import {existsSync,mkdirSync,readFileSync,readdirSync,rmSync,writeFileSync} from 'node:fs'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {join} from 'node:path'; export function registerTuiPresence(root=runtimeDirectory(),pid=process.pid):()=>void { @@ -11,5 +11,5 @@ export function hasLiveTui(root=runtimeDirectory(),alive=processAlive):boolean { for(const name of readdirSync(directory)){const path=join(directory,name);try{const value=JSON.parse(readFileSync(path,'utf8')) as {pid?:unknown};if(typeof value.pid==='number'&&Number.isInteger(value.pid)&&alive(value.pid))return true;rmSync(path,{force:true});}catch{rmSync(path,{force:true});}} return false; } -function runtimeDirectory():string{if(process.env.RADIOCLI_HOME)return join(process.env.RADIOCLI_HOME,'runtime');if(process.platform==='win32')return join(process.env.LOCALAPPDATA??join(homedir(),'AppData','Local'),'RadioCLI','runtime');return join(process.env.XDG_RUNTIME_DIR??join(homedir(),'.local','state'),'radiocli');} +function runtimeDirectory():string{return platformPaths().runtime;} function processAlive(pid:number):boolean{try{process.kill(pid,0);return true;}catch{return false;}} diff --git a/src/cli-terminal.test.tsx b/src/cli-terminal.test.tsx new file mode 100644 index 0000000..4acebe7 --- /dev/null +++ b/src/cli-terminal.test.tsx @@ -0,0 +1,60 @@ +import {fileURLToPath} from 'node:url'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {resolveTerminalCapabilities} from './platform/terminal.js'; + +const {render, configure} = vi.hoisted(() => ({render: vi.fn(), configure: vi.fn()})); +vi.mock('ink', () => ({render})); +vi.mock('./ui/App.js', () => ({App: () => null})); +vi.mock('./ui/terminal-renderer.js', () => ({configureTerminalRenderer: configure})); + +const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); +const originalArgv = process.argv; +const originalExitCode = process.exitCode; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubEnv('TERM', 'xterm-256color'); + for (const key of ['NO_COLOR', 'FORCE_COLOR', 'RADIOCLI_SCREEN_READER', 'INK_SCREEN_READER']) vi.stubEnv(key, undefined); + configure.mockImplementation(async (env, evidence) => resolveTerminalCapabilities(env, evidence)); + process.argv = [process.execPath, fileURLToPath(new URL('./cli.tsx', import.meta.url))]; +}); + +afterEach(() => { + process.argv = originalArgv; + process.exitCode = originalExitCode; + if (stdinDescriptor) Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor); + else Reflect.deleteProperty(process.stdin, 'isTTY'); + if (stdoutDescriptor) Object.defineProperty(process.stdout, 'isTTY', stdoutDescriptor); + else Reflect.deleteProperty(process.stdout, 'isTTY'); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('CLI terminal entry point', () => { + it.each([true, undefined])('normalizes stdout.isTTY=%s before configuring Ink', async stdoutTty => { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: true}); + Object.defineProperty(process.stdout, 'isTTY', {configurable: true, value: stdoutTty}); + await import('./cli.js'); + expect(configure).toHaveBeenCalledOnce(); + expect(configure.mock.calls[0]?.[1]).toMatchObject({isTTY: Boolean(stdoutTty)}); + expect(render).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + interactive: stdoutTty ? undefined : false, + debug: !stdoutTty, + kittyKeyboard: expect.objectContaining({mode: stdoutTty ? 'auto' : 'disabled'}) + })); + }); + + it('prints actionable help and exits before rendering when terminal input is absent', async () => { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: undefined}); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await import('./cli.js'); + expect(error).toHaveBeenCalledWith(expect.stringContaining('interactive TUI needs terminal input')); + expect(log).toHaveBeenCalledWith(expect.stringContaining('Usage:')); + expect(process.exitCode).toBe(1); + expect(render).not.toHaveBeenCalled(); + expect(configure).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli.test.ts b/src/cli.test.ts index 4110225..db82003 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -7,6 +7,10 @@ import {isDirectRun, runCommand} from './cli.js'; import {detectPlaybackBackends} from './player/backend-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; +import * as platformPaths from './platform/paths.js'; +import * as schedulers from './alarms/scheduler.js'; +import * as support from './platform/support.js'; +import {identifyPlatform} from './platform/runtime.js'; vi.mock('./player/backend-install.js', async importOriginal => { const actual = await importOriginal(); @@ -92,6 +96,8 @@ describe('CLI command dispatch', () => { backends: string[]; commands: Record; mpv: {path: string | null; discovery: string; launchable: boolean}; + host: {id: string; arch: string}; + capabilities: Record; guidance: string[]; }; expect(report.radioCliVersion).toMatch(/^\d+\.\d+\.\d+/); @@ -100,9 +106,96 @@ describe('CLI command dispatch', () => { expect(report.commands).toHaveProperty('mpv'); expect(report.mpv).toMatchObject({discovery: expect.any(String), launchable: expect.any(Boolean)}); expect(report.guidance).toContain('playback=missing'); + expect(report.host).toMatchObject({id: expect.any(String), arch: process.arch}); + expect(report.capabilities.playback?.status).toBe('unavailable'); + expect(report.capabilities.storage?.status).toBe('available'); + expect(report.capabilities).toHaveProperty('backgroundScheduling'); + expect(report.capabilities).toHaveProperty('terminalReopening'); + expect(report.capabilities).toHaveProperty('screenReader'); + expect(JSON.stringify(report)).not.toContain(radioCliHome); expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); }); + it('diagnoses an inaccessible data destination without creating or replacing it', async () => { + const blockedHome = join(radioCliHome, 'not-a-directory'); + writeFileSync(blockedHome, 'preserve me'); + process.env.RADIOCLI_HOME = blockedHome; + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.storage.status).toBe('unavailable'); + expect(report.capabilities.atomicWrites.status).toBe('unavailable'); + expect(report.capabilities.storage.message).toContain('RADIOCLI_HOME'); + expect(JSON.stringify(report)).not.toContain(blockedHome); + expect(readFileSync(blockedHome, 'utf8')).toBe('preserve me'); + }); + + it('checks the selected legacy library location without migrating data during doctor', async () => { + const paths = platformPaths.platformPaths(); + const spy = vi.spyOn(platformPaths, 'platformPaths').mockReturnValue({...paths, library: join(radioCliHome, 'absent.json'), legacyLibrary: radioCliHome}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.storage.status).toBe('unavailable'); + expect(existsSync(join(radioCliHome, 'absent.json'))).toBe(false); + } finally { spy.mockRestore(); } + }); + + it('reports an inaccessible user scheduler while preserving unrelated diagnostics', async () => { + const adapter = schedulers.createSchedulerAdapter(); + const spy = vi.spyOn(schedulers, 'createSchedulerAdapter').mockReturnValue({...adapter, + probeCapabilities: async () => ({supported: false, message: 'No active systemd user manager.', catchUpAfterWake: false, exactWake: false})}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.backgroundScheduling).toMatchObject({status: 'unavailable', message: 'No active systemd user manager.'}); + expect(report.capabilities.storage.status).toBe('available'); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + } finally { spy.mockRestore(); } + }); + + it('reports offline and constrained-terminal policy without writing state', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + vi.stubEnv('TERM', 'dumb'); + vi.stubEnv('FORCE_COLOR', '3'); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.network).toMatchObject({status: 'offline', offline: true}); + expect(report.terminal).toMatchObject({unicode: false, colorLevel: 0, reduceMotion: true, interactive: false}); + expect(report.capabilities.unicode.status).toBe('unavailable'); + expect(report.capabilities.color.status).toBe('unavailable'); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + } finally {vi.unstubAllEnvs();} + }); + + it('keeps doctor usable if an optional scheduler probe fails', async () => { + const adapter = schedulers.createSchedulerAdapter(); + const spy = vi.spyOn(schedulers, 'createSchedulerAdapter').mockReturnValue({...adapter, + probeCapabilities: async () => { throw new Error('probe failed'); }}); + try { + await runCommand(['doctor', '--json']); + expect(JSON.parse(logs.join('\n')).capabilities.backgroundScheduling.status).toBe('unavailable'); + } finally { spy.mockRestore(); } + }); + + it.each(['first-class', 'supported', 'experimental', 'unsupported'] as const)('preserves the %s support assessment in JSON and text doctor output', async tier => { + const assessment = support.assessPlatformSupport(identifyPlatform()); + const spy = vi.spyOn(support, 'assessPlatformSupport').mockReturnValue({...assessment, tier}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.support.tier).toBe(tier); + expect(report.support.scope).toBe('current-installation'); + expect(report.support.evidence).toEqual(assessment.evidence); + expect(report.backends).toEqual([]); + logs.length = 0; + await runCommand(['doctor']); + expect(logs.join('\n')).toContain(`support_tier=${tier}`); + expect(logs.join('\n')).toContain('support_scope=current-installation'); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + } finally {spy.mockRestore();} + }); + it('rejects unknown commands with the help hint', async () => { await expect(runCommand(['wat'])).rejects.toThrow('Unknown command: wat\nRun radiocli help.'); }); diff --git a/src/cli.tsx b/src/cli.tsx index 7303a6b..d8a96d2 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -4,21 +4,30 @@ import {resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import {ProviderManager} from './providers/provider-manager.js'; import {PlayerController} from './player/player-controller.js'; -import {JsonLibraryStore} from './storage/store.js'; +import {defaultLibraryPath, JsonLibraryStore} from './storage/store.js'; import {parsePlaylistFile, stationFromUrl, writeM3u} from './playlists/playlist.js'; import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backend-install.js'; -import {resolveCommand} from './player/command.js'; +import {resolveCommand} from './platform/executables.js'; import {diagnoseCommand, type CommandDiagnostic} from './player/command-diagnostics.js'; import {airPlaySenderHealth} from './player/airplay-sender-health.js'; import {appVersion} from './version.js'; import {checkForUpdate, installUpdate, updateCommandForInstall} from './update-check.js'; import {runAlarmCommand} from './alarms/cli.js'; +import {createSchedulerAdapter, type SchedulerCapabilities} from './alarms/scheduler.js'; import {runSetup} from './setup.js'; import {runAgentCliCommand, runMcpCommand} from './agent/cli.js'; import {decodeAgentCommand} from './agent/service.js'; import {runHeadlessAgentHost} from './agent/headless-host.js'; import {configureMcpIntegrations} from './agent/mcp-install.js'; import {defaultAgentControlSettings} from './types.js'; +import {identifyPlatform} from './platform/runtime.js'; +import {platformCapabilities} from './platform/capabilities.js'; +import {hasGraphicalSession} from './platform/desktop.js'; +import {detectPackageManager} from './platform/packages.js'; +import {storageReadiness} from './platform/storage.js'; +import {resolveTerminalCapabilities} from './platform/terminal.js'; +import {networkDiagnostic} from './platform/network.js'; +import {assessPlatformSupport} from './platform/support.js'; const runtime = {nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url)}; @@ -28,11 +37,7 @@ if (isDirectRun(process.argv[1], import.meta.url)) { if (args[0] === 'agent-ui') { const encoded = args[1]; if (!encoded) throw new Error('Missing RadioCLI agent startup request.'); - const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); - render(, { - exitOnCtrlC: false, - kittyKeyboard: {mode: 'auto', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']} - }); + await renderTui(encoded); } else if (args[0] === 'agent-host') { await runHeadlessAgentHost(); } else if (args.length > 0) { @@ -41,16 +46,28 @@ if (isDirectRun(process.argv[1], import.meta.url)) { process.exitCode = 1; }); } else { - const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); - render(, { - // App owns Ctrl+C so it can confirm before performing a clean shutdown. - exitOnCtrlC: false, - kittyKeyboard: { - mode: 'auto', - flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] - } - }); + await renderTui(); + } +} + +async function renderTui(encodedCommand?: string): Promise { + if (!process.stdin.isTTY) { + console.error('The interactive TUI needs terminal input. Use radiocli commands in a headless session, or connect with a terminal (for example, ssh -t).'); + printHelp(); + process.exitCode = 1; + return; } + const {configureTerminalRenderer} = await import('./ui/terminal-renderer.js'); + const terminal = await configureTerminalRenderer(process.env, {isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.()}); + const [{render}, {App}] = await Promise.all([import('ink'), import('./ui/App.js')]); + render(, { + // App owns Ctrl+C so it can confirm before performing a clean shutdown. + exitOnCtrlC: false, + isScreenReaderEnabled: terminal.screenReader, + interactive: terminal.interactive ? undefined : false, + debug: !terminal.interactive, + kittyKeyboard: {mode: terminal.interactive ? 'auto' : 'disabled', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']} + }); } export async function runCommand(args: string[]): Promise { @@ -97,13 +114,22 @@ export async function runCommand(args: string[]): Promise { if (command === 'doctor') { const backends = detectPlaybackBackends(); const mpvDiagnostic = diagnoseCommand('mpv'); + const report = await doctorReport(backends, mpvDiagnostic); if (rest.includes('--json')) { - console.log(JSON.stringify(doctorReport(backends, mpvDiagnostic), null, 2)); + console.log(JSON.stringify(report, null, 2)); return; } console.log(`backends=${backends.join(',') || 'none'}`); printMpvDiagnostic(mpvDiagnostic); printPlaybackBackendStatus(backends); + for (const [name, capability] of Object.entries(report.capabilities)) { + console.log(`capability_${name}=${capability.status} ${capability.message}`); + } + console.log(`network=${report.network.status} ${report.network.message}`); + console.log(`support_tier=${report.support.tier} runtime=${report.support.runtime.status}`); + console.log(`support_scope=${report.support.scope}`); + console.log('support_note=Doctor checks this installation without running packed-install or audio acceptance tests; reference CI results are documented in the platform matrix.'); + for (const reason of report.support.reasons) console.log(`support_note=${reason}`); return; } @@ -267,7 +293,9 @@ Usage: radiocli setup --mcp --headless-agent Opt out of external terminal windows radiocli setup --no-mcp Disable and remove agent MCP entries radiocli setup --package-manager Use brew, winget, scoop, choco, - apt, dnf, pacman, apk, or zypper + apt, dnf, pacman, apk, zypper, + pkg (FreeBSD), pkg_add, pkgin, + termux-pkg (Termux), or pkgman (Haiku) `); } @@ -277,16 +305,47 @@ function printPlaybackBackendStatus(backends: string[]): void { } } -function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic): Record { +async function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic) { const commands = Object.fromEntries( ['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]) ); const airPlay = airPlaySenderHealth(); + const host = identifyPlatform(); + const terminal = resolveTerminalCapabilities(process.env, {isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.()}); + let scheduler: SchedulerCapabilities | undefined; + try { + const adapter = createSchedulerAdapter(); + scheduler = await adapter.probeCapabilities?.(); + } catch { + scheduler = {supported: false, exactWake: false, catchUpAfterWake: false, message: 'The optional scheduler probe failed. Run radiocli alarm doctor for repair guidance.'}; + } + const integrationCommands = [ + 'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit', + 'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer', + 'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel', + 'termux-open-url', 'termux-clipboard-set' + ].filter(command => resolveCommand(command) !== null); + const capabilities = platformCapabilities(host, { + backends, + commands: integrationCommands, + graphicalSession: hasGraphicalSession(host), + packageManager: detectPackageManager(process.platform, host.osRelease), + storageWritable: libraryWritesAvailable(), + scheduler, + unicode: terminal.unicode, + color: terminal.colorLevel > 0, + screenReader: terminal.screenReader + }); return { radioCliVersion: appVersion(), nodeVersion: process.version, platform: process.platform, architecture: process.arch, + host: {id: host.id, arch: host.arch, armVersion: host.armVersion, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl}, + support: {scope: 'current-installation' as const, ...assessPlatformSupport(host)}, + capabilities, + terminal, + network: networkDiagnostic(), backends, commands, mpv: redactDiagnostic(mpvDiagnostic), @@ -302,6 +361,11 @@ function doctorReport(backends: string[], mpvDiagnostic: CommandDiagnostic): Rec }; } +function libraryWritesAvailable(): boolean { + try { return storageReadiness(defaultLibraryPath()).status === 'available'; } + catch { return false; } +} + function printMpvDiagnostic(diagnostic: CommandDiagnostic): void { console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`); console.log(`mpv_discovery=${diagnostic.discovery}`); diff --git a/src/platform/capabilities.test.ts b/src/platform/capabilities.test.ts new file mode 100644 index 0000000..4714da1 --- /dev/null +++ b/src/platform/capabilities.test.ts @@ -0,0 +1,52 @@ +import {describe, expect, it} from 'vitest'; +import {identifyPlatform} from './runtime.js'; +import {platformCapabilities} from './capabilities.js'; + +describe('independent capabilities', () => { + it('keeps playback and private storage independent of an unavailable scheduler/desktop', () => { + const report = platformCapabilities(identifyPlatform({platform: 'freebsd', env: {}}), {backends: ['mpv'], commands: [], graphicalSession: false}); + expect(report.playback.status).toBe('available'); + expect(report.playbackControls.status).toBe('available'); + expect(report.backgroundScheduling.status).toBe('unavailable'); + expect(report.externalUrl.status).toBe('unavailable'); + expect(report.clipboard.status).toBe('unavailable'); + expect(report.filePermissions.status).toBe('available'); + expect(report.storage.status).toBe('unverified'); + expect(report.airPlay.status).toBe('unavailable'); + }); + + it('never turns executable discovery into scheduler or wake verification', () => { + const host = identifyPlatform({platform: 'linux', env: {}}); + const evidence = {backends: ['mpv'], commands: ['systemctl', 'systemd-inhibit'], graphicalSession: false}; + expect(platformCapabilities(host, evidence).backgroundScheduling.status).toBe('unverified'); + expect(platformCapabilities(host, {...evidence, scheduler: {supported: false, message: 'No user manager'}}).backgroundScheduling).toMatchObject({status: 'unavailable', message: 'No user manager'}); + expect(platformCapabilities(host, {...evidence, scheduler: {supported: true, message: 'User manager responded'}}).backgroundScheduling.status).toBe('available'); + expect(platformCapabilities(host, evidence).wakeRequests.status).toBe('unavailable'); + expect(platformCapabilities(host, evidence).sleepInhibition.status).toBe('unverified'); + }); + + it.each(['ffplay', 'vlc'])('reports %s playback with explicitly unavailable interactive controls', backend => { + const report = platformCapabilities(identifyPlatform({platform: 'linux', env: {}}), {backends: [backend], commands: [], graphicalSession: false}); + expect(report.playback.status).toBe('available'); + expect(report.playbackControls).toMatchObject({status: 'unavailable'}); + expect(report.playbackControls.message).toContain('mpv'); + }); + + it('reports an absent optional command instead of advertising a usable integration', () => { + const report = platformCapabilities(identifyPlatform({platform: 'darwin', env: {}}), {backends: [], commands: [], graphicalSession: true}); + expect(report.playback.status).toBe('unavailable'); + expect(report.externalUrl.status).toBe('unavailable'); + expect(report.clipboard.status).toBe('unavailable'); + expect(report.sleepInhibition.status).toBe('unavailable'); + expect(report.airPlay.status).toBe('unavailable'); + }); + + it('keeps read-only storage and missing tools useful to doctor', () => { + const report = platformCapabilities(identifyPlatform({platform: 'win32', env: {}}), {backends: ['mpv'], commands: [], graphicalSession: false, storageWritable: false}); + expect(report.storage).toMatchObject({status: 'unavailable'}); + expect(report.storage.message).toContain('RADIOCLI_HOME'); + expect(report.filePermissions.message).toContain('ACL'); + expect(report.atomicWrites.status).toBe('unavailable'); + expect(report.playback.status).toBe('available'); + }); +}); diff --git a/src/platform/capabilities.ts b/src/platform/capabilities.ts new file mode 100644 index 0000000..3e27a05 --- /dev/null +++ b/src/platform/capabilities.ts @@ -0,0 +1,68 @@ +import {browserCommands, clipboardCandidates} from './desktop.js'; +import {nativeAdapters, type PlatformProfile} from './runtime.js'; + +export type Capability = {status: 'available' | 'unavailable' | 'unverified'; message: string; adapter?: string}; +type CapabilityEvidence = { + backends: readonly string[]; + commands: readonly string[]; + graphicalSession: boolean; + packageManager?: string | null; + storageWritable?: boolean; + scheduler?: {supported: boolean; message: string; catchUpAfterWake?: boolean}; + unicode?: boolean; + color?: boolean; + screenReader?: boolean; +}; + +const available = (message: string, adapter?: string): Capability => ({status: 'available', message, ...(adapter ? {adapter} : {})}); +const unavailable = (message: string): Capability => ({status: 'unavailable', message}); +const unverified = (message: string, adapter?: string): Capability => ({status: 'unverified', message, ...(adapter ? {adapter} : {})}); + +/** + * Availability is independent per feature. Tool discovery never substitutes for + * desktop, scheduler, audio-device, permission, or upstream runtime verification. + */ +export function platformCapabilities(host: PlatformProfile, evidence: CapabilityEvidence) { + const adapters = nativeAdapters(host); + const commands = new Set(evidence.commands); + const has = (name: string) => commands.has(name); + const backend = ['mpv', 'ffplay', 'vlc', 'airplay'].find(name => evidence.backends.includes(name)); + const desktop = evidence.graphicalSession; + const opener = browserCommands(host).find(item => has(item.command)); + const clipboard = clipboardCandidates(host).find(item => has(item.command)); + const schedulerTool = adapters.scheduler === 'launchd' ? 'launchctl' + : adapters.scheduler === 'task-scheduler' ? 'schtasks.exe' : adapters.scheduler === 'systemd' ? 'systemctl' : null; + const backgroundScheduling = evidence.scheduler + ? evidence.scheduler.supported ? available(evidence.scheduler.message, adapters.scheduler ?? undefined) : unavailable(evidence.scheduler.message) + : schedulerTool && has(schedulerTool) ? unverified('Native scheduler command found; verify the per-user session with radiocli alarm doctor.', adapters.scheduler ?? undefined) + : unavailable(adapters.scheduler ? `The ${schedulerTool} scheduler command is unavailable.` : `Reliable background scheduling has no verified adapter on ${host.id}.`); + const sleepTool = adapters.inhibitor === 'caffeinate' ? 'caffeinate' : adapters.inhibitor === 'logind' ? 'systemd-inhibit' : adapters.inhibitor === 'windows' ? 'powershell.exe' : null; + const volumeTool = adapters.volume === 'macos' ? has('osascript') ? 'osascript' : null + : adapters.volume === 'windows' ? ['powershell.exe', 'pwsh.exe'].find(has) + : adapters.volume === 'unix-audio' ? ['wpctl', 'pactl', 'amixer'].find(has) : null; + const storage = evidence.storageWritable === true ? available('The local data directory is writable; operations still verify each write.') + : evidence.storageWritable === false ? unavailable('Local data is not writable. Set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.') + : unverified('Local JSON data uses the existing private user paths; writability is checked when used.'); + return { + playback: backend ? available(`${backend} executable detected; stream and audio-device readiness are checked when tuning.`, backend) : unavailable('No playback backend detected. Run radiocli setup or configure a native player path.'), + playbackControls: backend === 'mpv' ? available('mpv IPC provides pause, mute, volume, metadata, and media keys.', 'mpv') + : unavailable(backend === 'airplay' ? 'AirPlay provides volume and mute; pause and media-key playback controls require local mpv.' : 'Pause, mute, volume, metadata, and media-key playback controls require mpv.'), + packageInstallation: evidence.packageManager ? available('Explicit setup plans require confirmation or --yes; --dry-run never installs.', evidence.packageManager) : unavailable('No supported package manager detected; install playback prerequisites manually.'), + storage, + filePermissions: adapters.posixPermissions ? available('Private files use mode 0600 and private directories use mode 0700; the filesystem must enforce POSIX permissions.') : available('Private user directories inherit Windows ACLs; POSIX modes do not configure Windows ACLs.'), + atomicWrites: evidence.storageWritable === false ? unavailable('Atomic replacement requires a writable destination directory.') : available('Temporary files are renamed within the destination directory; write failures are reported.'), + externalUrl: desktop && opener ? available('A desktop URL handler is available; execution is checked when used.', opener.command) : unavailable('No usable desktop URL handler/session detected. Open the station homepage manually.'), + clipboard: desktop && clipboard ? available('A clipboard command is available; execution is checked when used.', clipboard.command) : unavailable('No usable clipboard command/session detected. RadioCLI can display the text for manual copying.'), + terminalReopening: desktop && adapters.terminal ? unverified('A supported installed terminal must be found and its launch verified.', adapters.terminal) : unavailable('No supported graphical terminal session. Use the current TUI or explicitly select headless agent playback.'), + backgroundScheduling, + catchUp: backgroundScheduling.status === 'available' && evidence.scheduler?.catchUpAfterWake ? available('The native scheduler can deliver a missed occurrence; RadioCLI still enforces the configured grace window.') + : backgroundScheduling.status === 'unverified' ? unverified('Catch-up depends on an accessible native user scheduler and its session.') : unavailable('Catch-up delivery is not verified in this session.'), + wakeRequests: adapters.scheduler === 'task-scheduler' ? unverified('Task Scheduler can request WakeToRun; hardware, login state, and power policy decide delivery.') : unavailable('No privileged or exact wake request is made on this platform. Catch-up and idle-sleep inhibition are separate capabilities.'), + sleepInhibition: sleepTool && has(sleepTool) ? unverified('A sleep-inhibitor tool is installed; its lease must remain alive to provide protection.', sleepTool) : unavailable('No usable per-process sleep inhibitor. Normal playback remains available; sleep may interrupt it.'), + systemVolume: volumeTool ? unverified('The system mixer must respond before alarm volume can be raised and restored.', volumeTool) : unavailable('System output volume cannot be adjusted. Set the device volume manually; player volume remains independent.'), + unicode: evidence.unicode === false ? unavailable('ASCII decoration mode is selected; international station data is preserved.') : available('Unicode decoration is enabled; terminal font coverage determines glyph appearance.'), + color: evidence.color === false ? unavailable('Color is disabled for the terminal or by user preference.') : available('Color is enabled at the terminal supported depth.'), + screenReader: available(evidence.screenReader ? 'Screen-reader mode is enabled; decorative animation is disabled.' : 'Screen-reader mode is available through INK_SCREEN_READER=true.'), + airPlay: adapters.airPlay && evidence.backends.includes('airplay') ? available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay') : unavailable(adapters.airPlay ? 'Experimental AirPlay requires FFmpeg, Bonjour, and the safe optional sender.' : 'AirPlay output is available only on macOS.') + }; +} diff --git a/src/platform/desktop.test.ts b/src/platform/desktop.test.ts new file mode 100644 index 0000000..5046942 --- /dev/null +++ b/src/platform/desktop.test.ts @@ -0,0 +1,36 @@ +import {describe, expect, it} from 'vitest'; +import {browserCommands, clipboardCandidates, hasGraphicalSession} from './desktop.js'; +import {identifyPlatform} from './runtime.js'; + +describe('portable desktop command plans', () => { + it.each(['linux', 'freebsd', 'openbsd', 'netbsd'])('uses explicitly supported Unix desktop tools on %s', platform => { + const host = identifyPlatform({platform, env: {}}); + expect(browserCommands(host)).toEqual([{command: 'xdg-open', args: []}]); + expect(clipboardCandidates(host).map(item => item.command)).toEqual(['wl-copy', 'xclip', 'xsel']); + expect(hasGraphicalSession(host, {})).toBe(false); + expect(hasGraphicalSession(host, {DISPLAY: ':0'})).toBe(true); + }); + it('requires a deliberate adapter on an unknown Unix system', () => { + const host = identifyPlatform({platform: 'futureos', env: {}}); + expect(browserCommands(host)).toEqual([]); + expect(clipboardCandidates(host)).toEqual([]); + expect(hasGraphicalSession(host, {DISPLAY: ':0'})).toBe(false); + }); + + it.each(['android', 'linux'])('plans native Termux browser and stdin clipboard helpers on %s', platform => { + const env = {TERMUX_VERSION: '0.118.3'}; + const host = identifyPlatform({platform, env}); + expect(browserCommands(host)).toEqual([{command: 'termux-open-url', args: []}]); + expect(clipboardCandidates(host)).toEqual([{command: 'termux-clipboard-set', args: []}]); + expect(hasGraphicalSession(host, env)).toBe(true); + expect(hasGraphicalSession(host, {...env, SSH_CONNECTION: 'remote'})).toBe(false); + expect(hasGraphicalSession(host, {...env, SSH_TTY: '/dev/pts/1', DISPLAY: ':0'})).toBe(false); + }); + + it.each(['android', 'haiku', 'sunos', 'aix'])('does not infer desktop integration for %s from a DISPLAY value', platform => { + const host = identifyPlatform({platform, env: {}}); + expect(browserCommands(host)).toEqual([]); + expect(clipboardCandidates(host)).toEqual([]); + expect(hasGraphicalSession(host, {DISPLAY: ':0'})).toBe(false); + }); +}); diff --git a/src/platform/desktop.ts b/src/platform/desktop.ts new file mode 100644 index 0000000..284d69a --- /dev/null +++ b/src/platform/desktop.ts @@ -0,0 +1,32 @@ +import {identifyPlatform, type PlatformProfile} from './runtime.js'; + +export type SystemCommand = {command: string; args: string[]}; + +/** Command plans only. Callers must check the session and observe execution. */ +export function browserCommands(host: PlatformProfile = identifyPlatform()): SystemCommand[] { + // termux-tools opens one URL through Android's VIEW intent. + if (host.id === 'termux') return [{command: 'termux-open-url', args: []}]; + if (host.id === 'darwin') return [{command: 'open', args: []}]; + if (host.id === 'win32') return [{command: 'explorer', args: []}]; + if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id)) return [{command: 'xdg-open', args: []}]; + return []; +} + +export function clipboardCandidates(host: PlatformProfile = identifyPlatform()): SystemCommand[] { + // No arguments makes termux-clipboard-set read stdin. Its matching Android + // API app and permissions are still required; the caller checks execution. + if (host.id === 'termux') return [{command: 'termux-clipboard-set', args: []}]; + if (host.id === 'darwin') return [{command: 'pbcopy', args: []}]; + if (host.id === 'win32') return [{command: 'clip', args: []}]; + if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id)) return [ + {command: 'wl-copy', args: []}, + {command: 'xclip', args: ['-selection', 'clipboard']}, + {command: 'xsel', args: ['--clipboard', '--input']} + ]; + return []; +} + +export function hasGraphicalSession(host: PlatformProfile, env: NodeJS.ProcessEnv = process.env): boolean { + if (host.id === 'darwin' || host.id === 'win32' || host.id === 'termux') return !env.SSH_CONNECTION && !env.SSH_TTY; + return ['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id) && Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); +} diff --git a/src/player/command.test.ts b/src/platform/executable-locations.test.ts similarity index 97% rename from src/player/command.test.ts rename to src/platform/executable-locations.test.ts index 8a5589e..778a5ea 100644 --- a/src/player/command.test.ts +++ b/src/platform/executable-locations.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {resolveCommandDetails} from './command.js'; +import {resolveCommandDetails} from './executables.js'; describe('command resolution', () => { it('finds the WinGet shinchiro mpv installer location without PATH', () => { diff --git a/src/platform/executables.test.ts b/src/platform/executables.test.ts new file mode 100644 index 0000000..08f414d --- /dev/null +++ b/src/platform/executables.test.ts @@ -0,0 +1,92 @@ +import {chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {clearCommandCache, resolveCommandDetails} from './executables.js'; + +const roots: string[] = []; +afterEach(() => { + vi.unstubAllEnvs(); + clearCommandCache(); + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); + +describe('safe executable configuration and permissions', () => { + it.each([ + ['freebsd', '/usr/local/bin/mpv'], ['openbsd', '/usr/local/bin/mpv'], ['netbsd', '/usr/pkg/bin/mpv'], + ['sunos', '/opt/local/bin/mpv'], ['sunos', '/usr/pkg/bin/mpv'], ['haiku', '/boot/system/bin/mpv'], + ['haiku', '/boot/home/config/bin/mpv'], ['haiku', '/boot/home/config/non-packaged/bin/mpv'] + ] as const)('finds the %s package prefix when an inherited PATH is incomplete', (platform, path) => { + expect(resolveCommandDetails('mpv', {platform, env: {PATH: '/bin'}, home: '/boot/home', isRunnable: candidate => candidate === path})).toMatchObject({path, discovery: 'package-manager-shim'}); + }); + + it.each(['android', 'linux'] as const)('finds native Termux commands using PREFIX on %s', platform => { + const prefix = '/data/user/10/com.termux/files/usr'; + const path = `${prefix}/bin/mpv`; + expect(resolveCommandDetails('mpv', {platform, env: {PATH: '/system/bin', PREFIX: prefix}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'package-manager-shim'}); + }); + + it('prefers the inherited PATH to the Termux prefix', () => { + const path = '/private/player/bin/mpv'; + expect(resolveCommandDetails('mpv', {platform: 'android', env: {PATH: '/private/player/bin', TERMUX_VERSION: '0.118.3', PREFIX: '/data/data/com.termux/files/usr'}, + isRunnable: candidate => [path, '/data/data/com.termux/files/usr/bin/mpv'].includes(candidate)})).toEqual({path, discovery: 'path'}); + }); + + it.each([undefined, 'relative/usr', '/data/data/com.termux/files/usr\n'])('does not guess a Termux prefix from %s or fall back to glibc paths', prefix => { + expect(resolveCommandDetails('mpv', {platform: 'android', env: {TERMUX_VERSION: '0.118.3', PREFIX: prefix}, isRunnable: () => true})).toEqual({path: null, discovery: 'missing'}); + }); + + it('ignores an unrelated PREFIX on ordinary Linux', () => { + expect(resolveCommandDetails('mpv', {platform: 'linux', env: {PREFIX: '/opt/custom'}, isRunnable: candidate => candidate === '/opt/custom/bin/mpv'})).toEqual({path: null, discovery: 'missing'}); + }); + + it('finds an installed AIX Toolbox executable without inferring playback package availability', () => { + const path = '/opt/freeware/bin/python3'; + expect(resolveCommandDetails('python3', {platform: 'aix', env: {PATH: '/usr/bin'}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'package-manager-shim'}); + }); + it('treats a configured path with spaces and Unicode as a single executable', () => { + const path = '/opt/Radio 日本/player mpv'; + expect(resolveCommandDetails('mpv', {platform: 'linux', env: {PATH: '/usr/bin', RADIOCLI_MPV_PATH: path}, isRunnable: candidate => candidate === path})).toEqual({path, discovery: 'configured-path'}); + }); + + it('never falls back silently from a broken explicit player configuration', () => { + const result = resolveCommandDetails('mpv', {platform: 'linux', env: {RADIOCLI_MPV_PATH: '/missing/mpv'}, isRunnable: path => path === '/usr/bin/mpv'}); + expect(result).toMatchObject({path: null, discovery: 'configured-path', error: expect.stringContaining('RADIOCLI_MPV_PATH')}); + expect(resolveCommandDetails('mpv', {platform: 'linux', env: {RADIOCLI_MPV_PATH: 'mpv --script=untrusted'}, isRunnable: () => true}).path).toBeNull(); + }); + + it('rejects shell scripts as configured Windows player commands', () => { + expect(resolveCommandDetails('mpv', {platform: 'win32', env: {RADIOCLI_MPV_PATH: 'C:\\Tools\\mpv.cmd'}, isRunnable: () => true}).path).toBeNull(); + expect(resolveCommandDetails('mpv', {platform: 'win32', env: {RADIOCLI_MPV_PATH: 'D:\\Tools 日本\\mpv.exe'}, isRunnable: () => true})).toMatchObject({path: 'D:\\Tools 日本\\mpv.exe', discovery: 'configured-path'}); + }); + + it('does not call an executable directory a runnable file', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); + roots.push(root); + const directory = join(root, 'mpv'); + mkdirSync(directory); + expect(resolveCommandDetails(directory).path).toBeNull(); + }); + + it('finds a runnable agent command on the supplied PATH', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); + roots.push(root); + const path = join(root, `radiocli-test-command${process.platform === 'win32' ? '.exe' : ''}`); + writeFileSync(path, 'test', {mode: 0o700}); + expect(resolveCommandDetails('radiocli-test-command', {env: {PATH: root}, platform: process.platform}).path).toBe(path); + }); + + it.skipIf(process.platform === 'win32')('honors POSIX executable permission and environment changes without a stale cache', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-executable-')); + roots.push(root); + const path = join(root, 'player 日本'); + writeFileSync(path, '#!/bin/sh\nexit 0\n', {mode: 0o600}); + vi.stubEnv('RADIOCLI_MPV_PATH', path); + expect(resolveCommandDetails('mpv').path).toBeNull(); + chmodSync(path, 0o700); + clearCommandCache(); + expect(resolveCommandDetails('mpv').path).toBe(path); + vi.stubEnv('RADIOCLI_MPV_PATH', `${path}-missing`); + expect(resolveCommandDetails('mpv').path).toBeNull(); + }); +}); diff --git a/src/player/command.ts b/src/platform/executables.ts similarity index 73% rename from src/player/command.ts rename to src/platform/executables.ts index 4843b17..622f7ab 100644 --- a/src/player/command.ts +++ b/src/platform/executables.ts @@ -1,13 +1,15 @@ import {spawnSync} from 'node:child_process'; -import {accessSync, constants, existsSync} from 'node:fs'; +import {accessSync, constants, statSync} from 'node:fs'; import {homedir} from 'node:os'; import {posix, win32} from 'node:path'; +import {identifyPlatform} from './runtime.js'; -export type CommandDiscovery = 'path' | 'package-manager-shim' | 'application-directory' | 'windows-registry' | 'missing'; +export type CommandDiscovery = 'path' | 'configured-path' | 'package-manager-shim' | 'application-directory' | 'windows-registry' | 'missing'; export type CommandResolution = { path: string | null; discovery: CommandDiscovery; + error?: string; }; type CommandResolutionOptions = { @@ -33,8 +35,10 @@ export function resolveCommandDetails( command: string, overrides?: Partial ): CommandResolution { + const variable = overrideVariable(command); + const cacheKey = [command, process.env.PATH, process.env.PATHEXT, process.env.PREFIX, process.env.TERMUX_VERSION, variable ? process.env[variable] : undefined].join('\0'); if (!overrides) { - const cached = commandCache.get(command); + const cached = commandCache.get(cacheKey); if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs) return cached.resolution; } @@ -48,11 +52,24 @@ export function resolveCommandDetails( }; const resolution = resolveUncached(command, options); - if (!overrides) commandCache.set(command, {resolution, checkedAt: Date.now()}); + if (!overrides) { + if (commandCache.size >= 256) commandCache.clear(); + commandCache.set(cacheKey, {resolution, checkedAt: Date.now()}); + } return resolution; } function resolveUncached(command: string, options: CommandResolutionOptions): CommandResolution { + const variable = overrideVariable(command); + const configured = variable ? options.env[variable] : undefined; + if (configured !== undefined) { + const pathApi = options.platform === 'win32' ? win32 : posix; + if (!configured || /[\0\r\n]/.test(configured) || !pathApi.isAbsolute(configured) || (options.platform === 'win32' && !/\.(exe|com)$/i.test(configured))) { + return {path: null, discovery: 'configured-path', error: `${variable} must be an absolute native executable path, without shell arguments.`}; + } + return options.isRunnable(configured) ? {path: configured, discovery: 'configured-path'} + : {path: null, discovery: 'configured-path', error: `${variable} does not name a runnable file.`}; + } const pathMatch = lookupOnPath(command, options); if (pathMatch) return {path: pathMatch, discovery: 'path'}; @@ -70,6 +87,10 @@ function resolveUncached(command: string, options: CommandResolutionOptions): Co return {path: null, discovery: 'missing'}; } +function overrideVariable(command: string): string | undefined { + return ({mpv: 'RADIOCLI_MPV_PATH', ffplay: 'RADIOCLI_FFPLAY_PATH', vlc: 'RADIOCLI_VLC_PATH', cvlc: 'RADIOCLI_VLC_PATH', ffmpeg: 'RADIOCLI_FFMPEG_PATH'} as Record)[command]; +} + function lookupOnPath(command: string, options: CommandResolutionOptions): string | null { if (command.includes('/') || command.includes('\\')) return options.isRunnable(command) ? command : null; @@ -112,10 +133,9 @@ export function clearCommandCache(): void { } function isRunnable(path: string, platform: NodeJS.Platform): boolean { - if (!existsSync(path)) return false; - if (platform === 'win32') return true; try { - accessSync(path, constants.X_OK); + if (!statSync(path).isFile()) return false; + if (platform !== 'win32') accessSync(path, constants.X_OK); return true; } catch { return false; @@ -123,10 +143,22 @@ function isRunnable(path: string, platform: NodeJS.Platform): boolean { } function knownBinaryDirs({platform, env, home}: CommandResolutionOptions): string[] { + if (identifyPlatform({platform, env}).id === 'termux') { + // Use the running app's prefix, including alternate Android user installs. + // Generic /usr paths can refer to a different libc inside a proot environment. + const prefix = env.PREFIX; + return prefix && posix.isAbsolute(prefix) && !/[\0\r\n]/.test(prefix) ? [posix.join(prefix, 'bin')] : []; + } if (platform === 'darwin') { return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', posix.join(home, '.local', 'bin')]; } + if (platform === 'freebsd' || platform === 'openbsd') return ['/usr/local/bin', '/usr/bin']; + if (platform === 'netbsd') return ['/usr/pkg/bin', '/usr/local/bin', '/usr/bin']; + if (platform === 'sunos') return ['/opt/local/bin', '/usr/pkg/bin', '/usr/local/bin', '/usr/bin']; + if (platform === 'haiku') return [posix.join(home, 'config', 'non-packaged', 'bin'), posix.join(home, 'config', 'bin'), '/boot/system/non-packaged/bin', '/boot/system/bin']; + if (platform === 'aix') return ['/opt/freeware/bin', '/usr/local/bin', '/usr/bin']; + if (platform === 'win32') { const dirs: string[] = []; if (env.LOCALAPPDATA) dirs.push(win32.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links')); diff --git a/src/platform/ipc.test.ts b/src/platform/ipc.test.ts new file mode 100644 index 0000000..3ec797c --- /dev/null +++ b/src/platform/ipc.test.ts @@ -0,0 +1,17 @@ +import {describe, expect, it} from 'vitest'; +import {mpvIpcPath} from './ipc.js'; + +describe('native mpv IPC endpoints', () => { + it('uses the Windows pipe namespace and does not interpret the temp directory', () => { + expect(mpvIpcPath({platform: 'win32', pid: 42, timestamp: 123, directory: 'D:\\Long temp 日本'})).toBe('\\\\.\\pipe\\radiocli-42-123'); + }); + it.each(['linux', 'darwin', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'])( + 'keeps %s on a Unix socket with spaces and non-ASCII preserved', platform => { + expect(mpvIpcPath({platform, pid: 42, timestamp: 123, directory: '/tmp/日本 space'})).toBe('/tmp/日本 space/radiocli-42-123.sock'); + } + ); + it('checks the portable socket limit in bytes, before a player is launched', () => { + expect(() => mpvIpcPath({platform: 'freebsd', directory: `/tmp/${'é'.repeat(42)}`, pid: 42, timestamp: 123})).toThrow(/socket.*shorter.*TMPDIR/i); + expect(() => mpvIpcPath({platform: 'linux', directory: `/tmp/${'a'.repeat(150)}`})).toThrow(/socket/i); + }); +}); diff --git a/src/platform/ipc.ts b/src/platform/ipc.ts new file mode 100644 index 0000000..56c62e4 --- /dev/null +++ b/src/platform/ipc.ts @@ -0,0 +1,16 @@ +import {tmpdir} from 'node:os'; +import {posix} from 'node:path'; +import {identifyPlatform, nativeAdapters} from './runtime.js'; + +type IpcOptions = {platform?: string; directory?: string; pid?: number; timestamp?: number}; + +export function mpvIpcPath({platform = process.platform, directory = tmpdir(), pid = process.pid, timestamp = Date.now()}: IpcOptions = {}): string { + if (nativeAdapters(identifyPlatform({platform})).ipc === 'named-pipe') return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`; + const path = posix.join(directory, `radiocli-${pid}-${timestamp}.sock`); + // BSD/macOS sockaddr_un is smaller than Linux's. Count encoded bytes and + // leave room for the terminator; JavaScript string length is not sufficient. + if (Buffer.byteLength(path) > 100) { + throw new Error('The mpv socket path is too long. Select a shorter private TMPDIR and restart RadioCLI.'); + } + return path; +} diff --git a/src/platform/launch-command.test.ts b/src/platform/launch-command.test.ts new file mode 100644 index 0000000..5e628a1 --- /dev/null +++ b/src/platform/launch-command.test.ts @@ -0,0 +1,164 @@ +import {execFileSync, spawn, spawnSync} from 'node:child_process'; +import {once} from 'node:events'; +import {mkdirSync, mkdtempSync, realpathSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {describe, expect, it, vi} from 'vitest'; +import {launchEnvironment, nodeLaunchCommand, waitForLaunch} from './launch-command.js'; + +const pathsModule = new URL('./paths.ts', import.meta.url).href; +const tsxModule = import.meta.resolve('tsx'); +function probeArgs(platform: string, absolute = false): string[] { + return ['--import', tsxModule, '--input-type=module', '-e', ` + import {platformPaths} from ${JSON.stringify(pathsModule)}; + import {posix,win32} from 'node:path'; + const paths=platformPaths({platform: ${JSON.stringify(platform)}}); + const path=${platform === 'win32' ? 'win32' : 'posix'}; + process.stdout.write(JSON.stringify(${absolute ? 'Object.fromEntries(Object.entries(paths).map(([key,value])=>[key,path.resolve(value)]))' : 'paths'})); + `]; +} +function run(command: readonly string[], env: NodeJS.ProcessEnv, cwd?: string) { + return JSON.parse(execFileSync(command[0]!, command.slice(1), {env, cwd, encoding: 'utf8'})); +} + +it('waits for an actual stubborn bootstrap to terminate before reporting its timeout', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + const child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});setInterval(()=>{},1000);process.send('ready');"], {stdio: ['ignore', 'ignore', 'ignore', 'ipc']}); + let closed = false; + child.once('close', () => { closed = true; }); + const result = waitForLaunch(child, {waitForExit: true}).catch(error => error as Error); + try { + await once(child, 'message'); + await vi.advanceTimersByTimeAsync(10_250); + expect(await result).toMatchObject({message: 'Launch bootstrap did not complete.'}); + expect(closed).toBe(true); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + } finally { + vi.useRealTimers(); + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'close'); + child.kill('SIGKILL'); + await exited; + } + } +}); + +describe('launch path identity', () => { + const literal = "Radio Data ' \" $() ; & %PATH%! 单播"; + function compareWorkingDirectories(platform: string, env: NodeJS.ProcessEnv) { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'radiocli-launch-cwd-'))); + try { + const parentCwd = join(root, 'parent workspace');const childCwd = join(root, 'unrelated session'); + mkdirSync(parentCwd);mkdirSync(childCwd); + const args = probeArgs(platform, true); + const parent = run([process.execPath, ...args], env, parentCwd); + const snapshot = launchEnvironment(env, {platform, cwd: parentCwd}); + const child = run(nodeLaunchCommand(process.execPath, args, snapshot), {}, childCwd); + expect(child).toEqual(parent); + } finally { rmSync(root, {recursive: true, force: true}); } + } + + it.skipIf(process.platform === 'win32').each([ + {HOME: './listener', RADIOCLI_HOME: `./${literal}`}, + {HOME: './listener', RADIO_ATLAS_HOME: `./${literal}`, XDG_RUNTIME_DIR: './run'}, + {HOME: './listener', XDG_DATA_HOME: `../${literal}`, XDG_CACHE_HOME: './cache', XDG_RUNTIME_DIR: './run'}, + {HOME: './listener', RADIOCLI_HOME: '', RADIO_ATLAS_HOME: `./${literal}`}, + {HOME: './listener', RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '', XDG_DATA_HOME: '', XDG_CACHE_HOME: '', XDG_RUNTIME_DIR: ''}, + {HOME: ''} + ])('keeps POSIX storage identity across different working directories: %j', env => compareWorkingDirectories('linux', env), 10_000); + + it.skipIf(process.platform !== 'win32').each([ + {RADIOCLI_HOME: '.\\Radio Data', USERPROFILE: '.\\listener'}, + {RADIO_ATLAS_HOME: '.\\Radio Data', USERPROFILE: '.\\listener', LOCALAPPDATA: '.\\local'}, + {USERPROFILE: '.\\listener', APPDATA: '.\\roaming', LOCALAPPDATA: '.\\local'}, + {RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '.\\Radio Data', USERPROFILE: '.\\listener'}, + {APPDATA: '', LOCALAPPDATA: '', XDG_DATA_HOME: '', XDG_CACHE_HOME: ''} + ])('keeps native Windows storage identity across different working directories: %j', env => compareWorkingDirectories('win32', env)); + + it.each([ + ['linux', {HOME: `/home/${literal}`, RADIO_ATLAS_HOME: `/legacy/${literal}`, XDG_RUNTIME_DIR: `/run/${literal}`}], + ['linux', {HOME: `/home/${literal}`, XDG_DATA_HOME: `/data/${literal}`, XDG_CACHE_HOME: `/cache/${literal}`, XDG_RUNTIME_DIR: `/run/${literal}`}], + ['freebsd', {HOME: `/home/${literal}`, XDG_DATA_HOME: `/data/${literal}`, XDG_CACHE_HOME: `/cache/${literal}`}], + ['darwin', {HOME: `/Users/${literal}`, XDG_RUNTIME_DIR: `/run/${literal}`}], + ['win32', {USERPROFILE: `C:\\Users\\${literal}`, APPDATA: `D:\\Roaming\\${literal}`, LOCALAPPDATA: `D:\\Local\\${literal}`}], + ['linux', {HOME: `/home/${literal}`, RADIOCLI_HOME: `/current/${literal}`, RADIO_ATLAS_HOME: `/legacy/${literal}`}] + ] as const)('preserves %s parent library, cache, and runtime in a clean child with %j', (platform, env) => { + const args = probeArgs(platform); + const parent = run([process.execPath, ...args], env); + // Both processes receive only the fixture environment. In particular, + // neither can recover a dropped selector from the test runner's env. + const child = run(nodeLaunchCommand(process.execPath, args, launchEnvironment(env, {platform})), {}); + expect(child).toEqual(parent); + if ('RADIO_ATLAS_HOME' in env && !('RADIOCLI_HOME' in env)) { + expect(child.library).toBe(`${env.RADIO_ATLAS_HOME}/radio-atlas.json`); + expect(child.cache).toBe(`${env.RADIO_ATLAS_HOME}/radio-atlas-cache.json`); + } + }); + + // A Windows process cannot emulate POSIX's HOME-based os.homedir fallback: + // libuv uses the native Windows profile even when the fixture sets HOME. + it.skipIf(process.platform === 'win32').each([ + {}, + {HOME: '/home/saved'}, + {HOME: '/home/saved', RADIO_ATLAS_HOME: '/saved legacy'}, + {HOME: '/home/saved', RADIOCLI_HOME: '', XDG_DATA_HOME: '', XDG_CACHE_HOME: '', XDG_RUNTIME_DIR: ''} + ])('preserves absent and empty selectors despite inherited launcher contamination: %j', env => { + const args = probeArgs('linux', true); + const expected = run([process.execPath, ...args], env); + const inherited = { + HOME: '/home/unrelated', USERPROFILE: 'C:\\Unrelated', APPDATA: '/unrelated/roaming', LOCALAPPDATA: '/unrelated/local', + RADIOCLI_HOME: '/unrelated/current', RADIO_ATLAS_HOME: '/unrelated/legacy', + XDG_DATA_HOME: '/unrelated/data', XDG_CACHE_HOME: '/unrelated/cache', XDG_RUNTIME_DIR: '/unrelated/runtime' + }; + expect(run(nodeLaunchCommand(process.execPath, args, launchEnvironment(env, {platform: 'linux'})), inherited)).toEqual(expected); + }); + + it('persists only approved application, path, and optional desktop settings', () => { + const env = {RADIO_ATLAS_HOME: '/legacy', XDG_RUNTIME_DIR: '/run/user/1000', RADIOCLI_OFFLINE: '1', DISPLAY: ':0', API_TOKEN: 'secret', NODE_OPTIONS: '--require unsafe.cjs', PATH: '/shell/tools'}; + expect(launchEnvironment(env, {platform: 'linux'})).toEqual({RADIO_ATLAS_HOME: '/legacy', XDG_RUNTIME_DIR: '/run/user/1000', RADIOCLI_OFFLINE: '1'}); + expect(launchEnvironment(env, {platform: 'linux', includeDesktop: true, terminal: 'linux:/bin/kitty'})).toEqual({RADIO_ATLAS_HOME: '/legacy', XDG_RUNTIME_DIR: '/run/user/1000', RADIOCLI_OFFLINE: '1', DISPLAY: ':0', RADIOCLI_ALARM_TERMINAL: 'linux:/bin/kitty'}); + }); + + it('resolves Windows relative, drive-relative, and rooted selectors against the launch directory', () => { + expect(launchEnvironment({RADIOCLI_HOME: 'Radio Data', RADIO_ATLAS_HOME: 'C:legacy', APPDATA: '\\roaming', LOCALAPPDATA: '', USERPROFILE: '.\\listener'}, {platform: 'win32', cwd: 'C:\\Project Workspace'})).toEqual({ + RADIOCLI_HOME: 'C:\\Project Workspace\\Radio Data', RADIO_ATLAS_HOME: 'C:\\Project Workspace\\legacy', APPDATA: 'C:\\roaming', LOCALAPPDATA: 'C:\\Project Workspace', USERPROFILE: 'C:\\Project Workspace\\listener' + }); + }); + + it('preserves empty fallback overrides and the invalid empty Windows user profile', () => { + expect(launchEnvironment({RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '', USERPROFILE: ''}, {platform: 'win32', cwd: 'C:\\Project'})).toEqual({RADIOCLI_HOME: '', RADIO_ATLAS_HOME: '', USERPROFILE: ''}); + }); + + it('validates the captured directory before it becomes a persisted path selector', () => { + expect(() => launchEnvironment({XDG_DATA_HOME: 'data'}, {platform: 'linux', cwd: '/parent\nworkspace'})).toThrow(/control characters/); + }); + + it.skipIf(process.platform !== 'win32')('clears inherited Windows path selectors regardless of environment-key casing', () => { + const args = probeArgs('win32'); + const inherited = {UserProfile: 'C:\\Unrelated', AppData: 'C:\\Unrelated\\Roaming', LocalAppData: 'C:\\Unrelated\\Local', radiocli_home: 'C:\\Unrelated\\RadioCLI'}; + expect(run(nodeLaunchCommand(process.execPath, args, launchEnvironment({})), inherited)).toEqual(run([process.execPath, ...args], {})); + }); + + it('applies Windows case-insensitive identity cleanup at the bootstrap boundary', () => { + const args = ['-e', "process.stdout.write(JSON.stringify(Object.keys(process.env).filter(key=>/^(appdata|radiocli_home)$/i.test(key))))"]; + const command = nodeLaunchCommand(process.execPath, args, {}); + // Inject only the native platform boundary; the generated bootstrap runs + // unchanged and launches a real child with the resulting environment. + const probe = `const [program,payload]=JSON.parse(process.argv[1]);require('node:vm').runInNewContext(program,{Buffer,require,console,process:{...process,platform:'win32',argv:[process.execPath,payload],env:{AppData:'C:\\\\Unrelated',radiocli_home:'C:\\\\Other'}}});`; + expect(run([process.execPath, '-e', probe, JSON.stringify(command.slice(2))], {})).toEqual([]); + }); + + it.each([0, 7])('returns the actual application exit status %s through the bootstrap', code => { + const command = nodeLaunchCommand(process.execPath, ['-e', `process.exit(${code})`], {}); + expect(spawnSync(command[0]!, command.slice(1), {env: {}}).status).toBe(code); + }); + + it.skipIf(process.platform === 'win32')('reports application signal termination as a launch failure', () => { + const command = nodeLaunchCommand(process.execPath, ['-e', "process.kill(process.pid,'SIGTERM')"], {}); + expect(spawnSync(command[0]!, command.slice(1), {env: {}}).status).toBe(1); + }); + + it.each(['RADIO_ATLAS_HOME', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'])('rejects control characters in the persisted %s selector', key => { + expect(() => launchEnvironment({[key]: 'bad\nvalue'})).toThrow(/control characters/); + }); +}); diff --git a/src/platform/launch-command.ts b/src/platform/launch-command.ts new file mode 100644 index 0000000..bdfbfae --- /dev/null +++ b/src/platform/launch-command.ts @@ -0,0 +1,118 @@ +import {pathEnvironmentKeys} from './paths.js'; +import type {ChildProcess} from 'node:child_process'; +import {homedir} from 'node:os'; +import {posix, win32} from 'node:path'; + +const applicationEnvironmentKeys = [ + 'RADIOCLI_MPV_PATH', + 'RADIOCLI_FFPLAY_PATH', + 'RADIOCLI_VLC_PATH', + 'RADIOCLI_FFMPEG_PATH', + 'RADIOCLI_OFFLINE', + 'RADIOCLI_LOW_BANDWIDTH' +] as const; + +const desktopEnvironmentKeys = ['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS'] as const; + +/** Persist only application settings and the desktop connection a job needs. */ +export function launchEnvironment( + env: NodeJS.ProcessEnv, + options: {includeDesktop?: boolean; terminal?: string; platform?: string; cwd?: string} = {} +): Record { + const result: Record = {}; + const platform = options.platform ?? process.platform; + const path = platform === 'win32' ? win32 : posix; + const add = (key: string, value: string | undefined) => { + if (value === undefined) return; + if (/[\r\n\0]/.test(value)) throw new Error(`${key} cannot contain control characters in a launch environment.`); + result[key] = value; + }; + const capturePath = (key: string, value: string | undefined) => { + add(key, value); + if (value === undefined) return; + // Empty overrides fall back; empty roots select the invoking directory. + // Windows rejects an empty USERPROFILE, while HOME is unused there. + if (value === '' && (key === 'RADIOCLI_HOME' || key === 'RADIO_ATLAS_HOME' || key === 'USERPROFILE' || key === 'HOME' && platform === 'win32')) return; + // Windows root-relative paths also depend on the invoking drive. Fully + // qualified values need no cwd lookup (which can fail after cwd removal). + const absolute = path.isAbsolute(value) && (platform !== 'win32' || /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(value)); + if (!absolute) add(key, path.resolve(options.cwd ?? process.cwd(), value)); + }; + for (const key of pathEnvironmentKeys) capturePath(key, env[key]); + // On Windows, removing USERPROFILE from a child environment does not + // reliably erase a differently-cased inherited value. Persist the effective + // account home so an absent selector cannot be replaced by the launcher. + if (platform === 'win32' && process.platform === 'win32' && env.USERPROFILE === undefined) { + capturePath('USERPROFILE', homedir()); + } + for (const key of applicationEnvironmentKeys) add(key, env[key]); + if (options.includeDesktop) for (const key of desktopEnvironmentKeys) add(key, env[key]); + add('RADIOCLI_ALARM_TERMINAL', options.terminal); + return result; +} + +/** Keep application argv and approved environment out of native shell syntax. */ +export function nodeLaunchCommand(nodePath: string, args: readonly string[], environment: Record): string[] { + if ([nodePath, ...args, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) { + throw new Error('Launch command values cannot contain NUL bytes.'); + } + // This fixed program contains no double quotes, including after expansion. + // PowerShell 5 and Task Scheduler only receive this program and encoded data. + // A terminal server or scheduler may have unrelated path overrides. Clear the + // complete identity before restoring this launch's snapshot, preserving absent + // selectors and the path layer's existing empty-value/default semantics. + const keys = pathEnvironmentKeys.map(key => `'${key}'`).join(','); + const program = `const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const s=new Set([${keys}]);const e=Object.fromEntries(Object.entries(process.env).filter(([k])=>!s.has(process.platform==='win32'?k.toUpperCase():k)));const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...e,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);`; + return [nodePath, '-e', program, Buffer.from(JSON.stringify({args, environment}), 'utf8').toString('base64url')]; +} + +/** Process acceptance is separate from application/session readiness. */ +export function waitForLaunch(child: ChildProcess, options: {waitForExit?: boolean} = {}): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let acceptedTimer: NodeJS.Timeout | undefined; + let forceTimer: NodeJS.Timeout | undefined; + let cleanupTimer: NodeJS.Timeout | undefined; + let stopError: Error | undefined; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(startupTimer); + if (acceptedTimer) clearTimeout(acceptedTimer); + clearTimeout(forceTimer); + clearTimeout(cleanupTimer); + if (error) reject(error); else resolve(); + }; + const signal = (value: NodeJS.Signals) => { + try { child.kill(value); } catch { /* Retain the launch failure and bounded cleanup. */ } + }; + const stop = (error: Error) => { + if (settled || stopError) return; + stopError = error; + clearTimeout(startupTimer); + if (acceptedTimer) clearTimeout(acceptedTimer); + forceTimer = setTimeout(() => signal('SIGKILL'), 250); + cleanupTimer = setTimeout(() => { child.unref(); finish(error); }, 1_000); + signal('SIGTERM'); + }; + const startupTimer = setTimeout(() => { + const error = new Error('Process launcher did not report process startup.'); + if (child.pid) stop(error); else finish(error); + }, 3_000); + // Keep a listener after acceptance: a late native error must not become an + // unhandled EventEmitter error in the application that requested the launch. + child.on('error', error => { + if (!settled && child.pid) stop(error); else finish(error); + }); + child.once('close', (code, signal) => finish(stopError ?? (code === 0 ? undefined : new Error(`Process launcher exited with ${code ?? signal ?? 'unknown status'}.`)))); + child.once('spawn', () => { + if (options.waitForExit) { + clearTimeout(startupTimer); + acceptedTimer = setTimeout(() => stop(new Error('Launch bootstrap did not complete.')), 10_000); + } else { + child.unref(); + acceptedTimer = setTimeout(() => finish(), 100); + } + }); + }); +} diff --git a/src/platform/loopback.test.ts b/src/platform/loopback.test.ts new file mode 100644 index 0000000..de9583a --- /dev/null +++ b/src/platform/loopback.test.ts @@ -0,0 +1,70 @@ +import {createServer, type Server} from 'node:net'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {isLoopbackHost, listenLoopback} from './loopback.js'; + +const servers: Server[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(() => resolve())))); +}); + +describe('private loopback listeners', () => { + it('accepts only the two literal loopback addresses', () => { + expect(isLoopbackHost('127.0.0.1')).toBe(true); + expect(isLoopbackHost('::1')).toBe(true); + for (const host of ['localhost', '0.0.0.0', '::', '127.0.0.2', '[::1]', '::ffff:127.0.0.1', 'example.com', undefined]) { + expect(isLoopbackHost(host), String(host)).toBe(false); + } + }); + + it('binds an ephemeral IPv4 loopback port without exposing a wildcard listener', async () => { + const server = createServer(); + servers.push(server); + const address = await listenLoopback(server); + expect(address.host).toBe('127.0.0.1'); + expect(address.port).toBeGreaterThan(0); + expect(server.address()).toMatchObject({address: '127.0.0.1', port: address.port}); + expect(server.listenerCount('error')).toBe(0); + expect(server.listenerCount('listening')).toBe(0); + }); + + for (const code of ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL']) { + it(`falls back to a real IPv6 loopback listener after ${code} on IPv4`, async context => { + const server = createServer(); + servers.push(server); + const listen = vi.spyOn(server, 'listen').mockImplementationOnce(() => { + queueMicrotask(() => server.emit('error', Object.assign(new Error('IPv4 unavailable'), {code}))); + return server; + }); + let address; + try { + address = await listenLoopback(server); + } catch (error) { + expect(listen).toHaveBeenCalledTimes(2); + if (['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip('The host does not provide IPv6 loopback.'); + } + throw error; + } + expect(address).toMatchObject({host: '::1'}); + expect(server.address()).toMatchObject({address: '::1', port: address.port}); + expect(listen.mock.calls.map(call => call[1])).toEqual(['127.0.0.1', '::1']); + expect(server.listenerCount('error')).toBe(0); + }); + } + + it('does not hide permission errors by retrying another address family', async () => { + const server = createServer(); + servers.push(server); + const denied = Object.assign(new Error('permission denied'), {code: 'EACCES'}); + const listen = vi.spyOn(server, 'listen').mockImplementationOnce(() => { + queueMicrotask(() => server.emit('error', denied)); + return server; + }); + await expect(listenLoopback(server)).rejects.toBe(denied); + expect(listen).toHaveBeenCalledOnce(); + expect(server.listenerCount('error')).toBe(0); + expect(server.listenerCount('listening')).toBe(0); + }); +}); diff --git a/src/platform/loopback.ts b/src/platform/loopback.ts new file mode 100644 index 0000000..8c1b51d --- /dev/null +++ b/src/platform/loopback.ts @@ -0,0 +1,47 @@ +import type {Server} from 'node:net'; + +export type LoopbackHost = '127.0.0.1' | '::1'; + +export function isLoopbackHost(value: unknown): value is LoopbackHost { + return value === '127.0.0.1' || value === '::1'; +} + +/** Bind only literal loopback addresses, including hosts without an IPv4 stack. */ +export async function listenLoopback(server: Server): Promise<{host: LoopbackHost; port: number}> { + try { + return await listen(server, '127.0.0.1'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EAFNOSUPPORT' && code !== 'EPROTONOSUPPORT' && code !== 'EADDRNOTAVAIL') throw error; + return listen(server, '::1'); + } +} + +function listen(server: Server, host: LoopbackHost): Promise<{host: LoopbackHost; port: number}> { + return new Promise((resolve, reject) => { + const cleanup = () => { + server.removeListener('error', failed); + server.removeListener('listening', ready); + }; + const failed = (error: Error) => { + cleanup(); + reject(error); + }; + const ready = () => { + cleanup(); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Unable to create a local control endpoint.')); + return; + } + resolve({host, port: address.port}); + }; + server.once('error', failed); + server.once('listening', ready); + try { + server.listen(0, host); + } catch (error) { + failed(error as Error); + } + }); +} diff --git a/src/platform/network.test.ts b/src/platform/network.test.ts new file mode 100644 index 0000000..6dcd9d8 --- /dev/null +++ b/src/platform/network.test.ts @@ -0,0 +1,343 @@ +import {createServer, request as requestHttp, type Server} from 'node:http'; +import {createServer as createHttpsServer} from 'node:https'; +import {connect} from 'node:net'; +import {spawn} from 'node:child_process'; +import type {Duplex} from 'node:stream'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + allowsDirectDnsFallback, + fetchHttpsWithSystemResolver, + networkDiagnostic, + networkPolicy, + withExternalResponse +} from './network.js'; + +const servers: Server[] = []; +const sockets = new Set(); +const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, 'node')!; +const execArgv = [...process.execArgv]; +const proxyVariables = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy']; + +beforeEach(() => { + for (const key of [...proxyVariables, 'NODE_USE_ENV_PROXY', 'NODE_OPTIONS', 'NODE_TLS_REJECT_UNAUTHORIZED', 'RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH']) vi.stubEnv(key, undefined); +}); + +afterEach(async () => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + Object.defineProperty(process.versions, 'node', nodeVersion); + process.execArgv.splice(0, process.execArgv.length, ...execArgv); + for (const socket of sockets) socket.destroy(); + sockets.clear(); + await Promise.all(servers.splice(0).map(server => { + server.closeAllConnections(); + return new Promise(resolve => server.close(() => resolve())); + })); +}); + +describe('public network policy', () => { + it('reports configuration separately from connectivity and redacts proxy credentials', () => { + expect(networkDiagnostic()).toMatchObject({status: 'configured', offline: false}); + vi.stubEnv('HTTPS_PROXY', 'socks5://private-user:private-password@proxy.invalid:1080'); + expect(networkDiagnostic()).toMatchObject({status: 'unavailable'}); + expect(JSON.stringify(networkDiagnostic())).not.toMatch(/private-user|private-password|proxy.invalid/); + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + expect(networkDiagnostic()).toMatchObject({status: 'offline', offline: true}); + }); + + it('keeps offline and low-bandwidth preferences independent and explicitly opt in', () => { + expect(networkPolicy({})).toEqual({offline: false, lowBandwidth: false}); + expect(networkPolicy({RADIOCLI_OFFLINE: '1'})).toEqual({offline: true, lowBandwidth: false}); + expect(networkPolicy({RADIOCLI_LOW_BANDWIDTH: '1'})).toEqual({offline: false, lowBandwidth: true}); + expect(networkPolicy({RADIOCLI_OFFLINE: '0', RADIOCLI_LOW_BANDWIDTH: 'true'})).toEqual({offline: false, lowBandwidth: false}); + }); + + it('permits direct DNS recovery only when no proxy route is configured', () => { + expect(allowsDirectDnsFallback({})).toBe(true); + expect(allowsDirectDnsFallback({NO_PROXY: '*'})).toBe(true); + for (const name of ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy']) { + expect(allowsDirectDnsFallback({[name]: 'http://proxy.example:8080'})).toBe(false); + } + }); + + it('does not invoke fetch in offline mode', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/offline/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + for (const variable of ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy']) { + it(`rejects unsupported ${variable} without contacting the destination or exposing credentials`, async () => { + let destinationRequests = 0; + const server = createServer((_request, response) => {destinationRequests += 1;response.end('{}');}); + const url = await start(server); + vi.stubEnv(variable, 'socks5://private-user:private-password@127.0.0.1:9'); + const error = await withExternalResponse(url, {timeoutMs: 100}, response => response.json()).then(() => null, value => value as Error); + expect(error).toBeInstanceOf(Error); + expect(error!.message).toMatch(/SOCKS|ALL_PROXY|all_proxy/); + expect(error!.message).not.toMatch(/private-user|private-password|127\.0\.0\.1:9/); + expect(error!.cause).toBeUndefined(); + expect(destinationRequests).toBe(0); + }); + } + + it('explains that ALL_PROXY alone is not implemented even for HTTP proxy URLs', async () => { + vi.stubEnv('ALL_PROXY', 'http://proxy.invalid:8080'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/ALL_PROXY/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('redacts invalid proxy URLs and invalid percent-encoded credentials', async () => { + const fetchImpl = vi.fn(async () => new Response('{}')); + for (const value of ['not-a-url-private-password', 'http://private-user:private-password%ZZ@proxy.invalid']) { + vi.stubEnv('HTTPS_PROXY', value); + const error = await withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json()).then(() => null, value => value as Error); + expect(error!.message).toMatch(/HTTPS_PROXY/i); + expect(error!.message).not.toMatch(/private-user|private-password|proxy\.invalid/); + expect(error!.cause).toBeUndefined(); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('requires native proxy opt-in instead of silently sending configured traffic directly', async () => { + vi.stubEnv('HTTPS_PROXY', 'http://proxy.invalid:8080'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/NODE_USE_ENV_PROXY=1/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('honors lowercase proxy precedence and the fetch HTTPS fallback to HTTP_PROXY', async () => { + vi.stubEnv('NODE_USE_ENV_PROXY', '1'); + vi.stubEnv('HTTP_PROXY', 'socks5://ignored.invalid:1'); + vi.stubEnv('http_proxy', 'http://proxy.invalid:8080'); + vi.stubEnv('ALL_PROXY', 'socks5://also-ignored.invalid:1'); + const fetchImpl = vi.fn(async () => new Response('{"ok":true}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).resolves.toEqual({ok: true}); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('recognizes a launch flag but respects an explicit command-line override', async () => { + vi.stubEnv('HTTPS_PROXY', 'http://proxy.invalid:8080'); + vi.stubEnv('NODE_OPTIONS', '--use-env-proxy'); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).resolves.toEqual({}); + process.execArgv.push('--no-use-env-proxy'); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/NODE_USE_ENV_PROXY=1/); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('reports unsupported Node versions before attempting a configured proxy', async () => { + vi.stubEnv('NODE_USE_ENV_PROXY', '1'); + vi.stubEnv('HTTPS_PROXY', 'http://proxy.invalid:8080'); + const fetchImpl = vi.fn(async () => new Response('{}')); + for (const version of ['22.0.0', '22.20.0', '23.11.0']) { + Object.defineProperty(process.versions, 'node', {...nodeVersion, value: version}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).rejects.toThrow(/22\.21|24/); + } + expect(fetchImpl).not.toHaveBeenCalled(); + for (const version of ['22.21.0', '24.0.0', '24.5.0']) { + Object.defineProperty(process.versions, 'node', {...nodeVersion, value: version}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.json())).resolves.toEqual({}); + } + }); +}); + +describe('external response lifetime', () => { + it('uses resolved IP addresses while preserving the HTTPS hostname and Host header', async () => { + let host: string | undefined; + const server = createHttpsServer({key: fixtureKey, cert: fixtureCertificate}, (request, response) => { + host = request.headers.host; + response.end('{"route":"resolved"}'); + }); + const bound = new URL(await start(server)); + const resolve4 = vi.fn(async () => ['127.0.0.1']); + const resolve6 = vi.fn(async () => []); + + const response = await fetchHttpsWithSystemResolver( + `https://localhost:${bound.port}/json/stats`, + {headers: {Accept: 'application/json'}}, + {resolve4, resolve6, ca: fixtureCertificate} + ); + + await expect(response.json()).resolves.toEqual({route: 'resolved'}); + expect(response.status).toBe(200); + expect(host).toBe(`localhost:${bound.port}`); + expect(resolve4).toHaveBeenCalledWith('localhost'); + expect(resolve6).toHaveBeenCalledWith('localhost'); + }); + + it('keeps certificate verification enabled for resolved-address HTTPS requests', async () => { + let requests = 0; + const server = createHttpsServer({key: fixtureKey, cert: fixtureCertificate}, (_request, response) => { + requests += 1; + response.end('{}'); + }); + const bound = new URL(await start(server)); + const runtime = {resolve4: async () => ['127.0.0.1'], resolve6: async () => []}; + + await expect(fetchHttpsWithSystemResolver(`https://localhost:${bound.port}`, {}, runtime)) + .rejects.toMatchObject({code: 'DEPTH_ZERO_SELF_SIGNED_CERT'}); + expect(requests).toBe(0); + }); + + it('keeps the deadline alive after real response headers while the body is stalled', async () => { + const server = createServer((_request, response) => {response.writeHead(200, {'content-type': 'application/json'});response.flushHeaders();response.write('{');}); + const url = await start(server); + let receivedHeaders = false; + const started = Date.now(); + await expect(withExternalResponse(url, {timeoutMs: 1000}, response => { + receivedHeaders = true; + return response.json(); + })).rejects.toThrow(/timed out/i); + expect(receivedHeaders).toBe(true); + expect(Date.now() - started).toBeLessThan(4500); + }); + + it('cancels an unused body after a status-only callback', async () => { + const cancel = vi.fn(); + const fetchImpl = async () => new Response(new ReadableStream({cancel}), {status: 503}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, response => response.status)).resolves.toBe(503); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('cancels an unused body when the consumer rejects the response', async () => { + const cancel = vi.fn(); + const fetchImpl = async () => new Response(new ReadableStream({cancel}), {status: 503}); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, () => {throw new Error('bad status');})).rejects.toThrow('bad status'); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('bounds the callback even when a custom fetch implementation does not react to abort', async () => { + vi.useFakeTimers(); + const cancel = vi.fn(); + const fetchImpl = async () => new Response(new ReadableStream({cancel})); + const outcome = withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, () => new Promise(() => {})).then(() => null, error => error as Error); + await vi.advanceTimersByTimeAsync(100); + expect((await outcome)?.message).toMatch(/timed out/i); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('preserves caller cancellation', async () => { + const controller = new AbortController(); + const reason = new Error('caller cancelled'); + controller.abort(reason); + const fetchImpl = vi.fn(async () => new Response('{}')); + await expect(withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl, init: {signal: controller.signal}}, response => response.json())).rejects.toBe(reason); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('cancels a late response without invoking its consumer after the deadline', async () => { + vi.useFakeTimers(); + let deliver!: (response: Response) => void; + const fetchImpl = () => new Promise(resolve => {deliver = resolve;}); + const consume = vi.fn((response: Response) => response.status); + const outcome = withExternalResponse('https://example.com', {timeoutMs: 100, fetchImpl}, consume).catch(error => error as Error); + await vi.advanceTimersByTimeAsync(100); + expect(await outcome).toMatchObject({message: expect.stringMatching(/timed out/i)}); + const cancel = vi.fn(); + deliver(new Response(new ReadableStream({cancel}))); + await vi.advanceTimersByTimeAsync(0); + expect(consume).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('preserves TLS certificate verification against a real self-signed server', async () => { + let requests = 0; + const server = createHttpsServer({key: fixtureKey, cert: fixtureCertificate}, (_request, response) => {requests += 1;response.end('{}');}); + const url = (await start(server)).replace('http:', 'https:'); + await expect(withExternalResponse(url, {timeoutMs: 1000}, response => response.json())).rejects.toMatchObject({cause: {code: 'DEPTH_ZERO_SELF_SIGNED_CERT'}}); + expect(requests).toBe(0); + expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined(); + }); + + it.skipIf(!process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))('uses the native HTTP proxy in a fresh Node process', async () => { + let originProxyAuthorization: string | undefined; + const origin = createServer((request, response) => {originProxyAuthorization = request.headers['proxy-authorization'];response.end('{"route":"origin"}');}); + const destination = await start(origin); + let proxyConnections = 0; + let authenticatedProxy = false; + let proxyTarget: string | undefined; + // Native fetch may use absolute-form HTTP requests or a CONNECT tunnel. + const proxy = createServer((request, response) => { + proxyConnections += 1; + authenticatedProxy = request.headers['proxy-authorization'] === `Basic ${Buffer.from('test-user:test-password').toString('base64')}`; + proxyTarget = new URL(request.url!).origin; + const headers = {...request.headers}; + delete headers['proxy-authorization']; + const upstream = requestHttp(request.url!, {method: request.method, headers, agent: false}, upstreamResponse => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }); + upstream.on('socket', socket => sockets.add(socket)); + upstream.on('error', error => response.destroy(error)); + request.on('error', error => upstream.destroy(error)); + request.pipe(upstream); + }); + proxy.on('connect', (request, socket, head) => { + proxyConnections += 1; + authenticatedProxy = request.headers['proxy-authorization'] === `Basic ${Buffer.from('test-user:test-password').toString('base64')}`; + proxyTarget = `http://${request.url}`; + sockets.add(socket); + const upstream = connect(Number(new URL(destination).port), '127.0.0.1', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length) upstream.write(head); + upstream.pipe(socket); + socket.pipe(upstream); + }); + sockets.add(upstream); + socket.on('error', () => upstream.destroy()); + upstream.on('error', () => socket.destroy()); + }); + const proxyUrl = new URL(await start(proxy)); + proxyUrl.username = 'test-user'; + proxyUrl.password = 'test-password'; + const env = {...process.env}; + for (const key of Object.keys(env)) if (/^(?:https?|all|no)_proxy$/i.test(key) || key === 'NODE_OPTIONS') delete env[key]; + env.NODE_USE_ENV_PROXY = '1'; + env.HTTP_PROXY = proxyUrl.href; + env.NO_PROXY = ''; + const source = `import {withExternalResponse} from ${JSON.stringify(new URL('./network.ts', import.meta.url).href)};console.log(JSON.stringify(await withExternalResponse(${JSON.stringify(destination)}, {timeoutMs: 2000}, response => response.json())));`; + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', source], {env, timeout: 5000}); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => {stdout += String(chunk);}); + child.stderr.on('data', chunk => {stderr += String(chunk);}); + child.once('error', reject); + child.once('close', code => code === 0 ? resolve(stdout) : reject(new Error(`Proxy probe exited ${code}: ${stderr}`))); + }); + expect(JSON.parse(result)).toEqual({route: 'origin'}); + expect(proxyConnections).toBe(1); + expect(proxyTarget).toBe(destination); + expect(authenticatedProxy).toBe(true); + expect(originProxyAuthorization).toBeUndefined(); + }); +}); + +async function start(server: Server): Promise { + servers.push(server); + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, '127.0.0.1', resolve);}); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + return `http://127.0.0.1:${address.port}`; +} + +// Public test-only key and self-signed certificate; never used outside this listener. +const fixtureKey = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdlaXqgyzfUEvYbyF +RQfTLHuSl6q9hS1Xei3+nXI+RaqhRANCAASEdb1PI+/xQa9NAqepSjOLpJTpWDyV +Bhw6kqUXtZJVSTPyFFo5z9DrziH6UnxATozGgJv27wWRJoboTqzHTHr+ +-----END PRIVATE KEY-----`; +const fixtureCertificate = `-----BEGIN CERTIFICATE----- +MIIBfTCCASOgAwIBAgIUCUcRiI7iLHzUheIbTonNEDx6QO8wCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkwNzIzMTYyNVoXDTM2MDkwNDIz +MTYyNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEhHW9TyPv8UGvTQKnqUozi6SU6Vg8lQYcOpKlF7WSVUkz8hRaOc/Q684h ++lJ8QE6MxoCb9u8FkSaG6E6sx0x6/qNTMFEwHQYDVR0OBBYEFCWgV7aRyaZ6Tf19 +5SYahE4kmBRlMB8GA1UdIwQYMBaAFCWgV7aRyaZ6Tf195SYahE4kmBRlMA8GA1Ud +EwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIhAIJO7DSRzV+s7lDMftcC7CwU +tZVZiikVcdL2DYCLTu1TAiBjJblbBFao0A6d/BrkpMbn6M/JdApiza4cxQW520VZ +vQ== +-----END CERTIFICATE-----`; diff --git a/src/platform/network.ts b/src/platform/network.ts new file mode 100644 index 0000000..c7c2a48 --- /dev/null +++ b/src/platform/network.ts @@ -0,0 +1,296 @@ +import {resolve4, resolve6} from 'node:dns/promises'; +import {request as requestHttps, type RequestOptions as HttpsRequestOptions} from 'node:https'; + +type FetchLike = (input: string | URL, init?: RequestInit) => Promise; +type ExternalRequestOptions = {timeoutMs: number; init?: RequestInit; fetchImpl?: FetchLike}; +type ResolvedAddress = {address: string; family: 4 | 6}; +type ResolverRuntime = { + resolve4?: (hostname: string) => Promise; + resolve6?: (hostname: string) => Promise; + request?: typeof requestHttps; + ca?: HttpsRequestOptions['ca']; +}; + +const maxResolvedResponseBytes = 128 * 1024 * 1024; + +export function networkPolicy(env: NodeJS.ProcessEnv = process.env): {offline: boolean; lowBandwidth: boolean} { + return {offline: env.RADIOCLI_OFFLINE === '1', lowBandwidth: env.RADIOCLI_LOW_BANDWIDTH === '1'}; +} + +/** A direct connection must never silently bypass an operator-configured proxy. */ +export function allowsDirectDnsFallback(env: NodeJS.ProcessEnv = process.env): boolean { + return !['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY'] + .some(name => Boolean(env[name]?.trim())); +} + +/** Configuration preflight only: no network request or proxy credentials. */ +export function networkDiagnostic() { + const policy = networkPolicy(); + if (policy.offline) return {...policy, status: 'offline', message: 'Public directory and update requests are disabled; cached data and direct stream URLs remain usable.'}; + try { + validateProxyConfiguration(new URL('http://example.invalid')); + validateProxyConfiguration(new URL('https://example.invalid')); + return {...policy, status: 'configured', message: 'Network configuration accepted; reachability and TLS are checked per request. External player networking is separate.'}; + } catch (error) { + return {...policy, status: 'unavailable', message: error instanceof Error ? error.message : 'Network configuration is invalid.'}; + } +} + +/** Keep the request deadline active through body consumption, including errors. */ +export async function withExternalResponse( + url: string | URL, + {timeoutMs, init = {}, fetchImpl = fetch}: ExternalRequestOptions, + consume: (response: Response) => T | Promise +): Promise { + if (networkPolicy().offline) throw new Error('RadioCLI is offline (RADIOCLI_OFFLINE=1).'); + validateProxyConfiguration(new URL(url)); + init.signal?.throwIfAborted(); + + const controller = new AbortController(); + const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal; + let onAbort: () => void; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, {once: true}); + }); + const timeout = setTimeout(() => controller.abort(new Error(`Network request timed out after ${timeoutMs} ms.`)), timeoutMs); + let response: Response | undefined; + const work = async () => { + response = await fetchImpl(url, {...init, signal}); + try { + // A custom fetch can resolve after the deadline without observing abort. + signal.throwIfAborted(); + return await consume(response); + } finally { + cancelBody(response); + } + }; + try { + return await Promise.race([work(), aborted]); + } finally { + clearTimeout(timeout); + signal.removeEventListener('abort', onAbort!); + controller.abort(); + if (response) cancelBody(response); + } +} + +/** + * HTTPS fetch fallback for systems where libc getaddrinfo() reports EAI_AGAIN + * but Node's DNS-protocol resolver can still reach the configured nameserver. + * The original hostname remains the HTTP Host and TLS SNI identity, so normal + * certificate-chain and hostname verification remain enabled. + */ +export async function fetchHttpsWithSystemResolver( + input: string | URL, + init: RequestInit = {}, + runtime: ResolverRuntime = {} +): Promise { + const url = new URL(input); + if (url.protocol !== 'https:') throw new Error('The DNS fallback only supports HTTPS requests.'); + if (url.username || url.password) throw new Error('The DNS fallback does not accept URL credentials.'); + const method = (init.method ?? 'GET').toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') throw new Error('The DNS fallback only supports GET and HEAD requests.'); + if (init.body) throw new Error('The DNS fallback does not accept a request body.'); + init.signal?.throwIfAborted(); + + const addresses = await resolveHttpsAddresses(url.hostname, init.signal, runtime); + let lastError: Error | null = null; + for (const address of addresses) { + try { + return await requestResolvedHttps(url, address, init, runtime, method); + } catch (error) { + if (init.signal?.aborted) throw init.signal.reason; + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + throw lastError ?? new Error(`DNS did not return an address for ${url.hostname}.`); +} + +async function resolveHttpsAddresses( + hostname: string, + signal: AbortSignal | null | undefined, + runtime: ResolverRuntime +): Promise { + let lastErrors: unknown[] = []; + for (let attempt = 0; attempt < 2; attempt += 1) { + const results = await raceAbort( + Promise.allSettled([ + (runtime.resolve4 ?? resolve4)(hostname), + (runtime.resolve6 ?? resolve6)(hostname) + ]), + signal + ); + const addresses = [ + ...(results[0].status === 'fulfilled' ? results[0].value.map(address => ({address, family: 4 as const})) : []), + ...(results[1].status === 'fulfilled' ? results[1].value.map(address => ({address, family: 6 as const})) : []) + ]; + if (addresses.length > 0) return dedupeAddresses(addresses); + + lastErrors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []); + if (attempt === 1 || !lastErrors.some(isTemporaryDnsError)) break; + await abortableDelay(100, signal); + } + + const lastError = lastErrors.find(value => value instanceof Error); + throw lastError instanceof Error ? lastError : new Error(`DNS did not return an address for ${hostname}.`); +} + +function requestResolvedHttps( + url: URL, + address: ResolvedAddress, + init: RequestInit, + runtime: ResolverRuntime, + method: string +): Promise { + return new Promise((resolve, reject) => { + const headers = new Headers(init.headers); + if (!headers.has('host')) headers.set('host', url.host); + if (!headers.has('accept-encoding')) headers.set('accept-encoding', 'identity'); + const request = (runtime.request ?? requestHttps)({ + hostname: address.address, + family: address.family, + port: url.port ? Number(url.port) : 443, + path: `${url.pathname}${url.search}`, + method, + headers: Object.fromEntries(headers.entries()), + servername: url.hostname, + signal: init.signal ?? undefined, + ca: runtime.ca, + agent: false + }, response => { + const chunks: Buffer[] = []; + let bytes = 0; + response.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > maxResolvedResponseBytes) { + response.destroy(new Error('HTTPS response exceeded the 128 MiB safety limit.')); + return; + } + chunks.push(buffer); + }); + response.once('error', reject); + response.once('aborted', () => reject(new Error('HTTPS response ended before completion.'))); + response.once('end', () => { + const responseHeaders = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) { + for (const item of value) responseHeaders.append(name, item); + } else if (value !== undefined) { + responseHeaders.set(name, value); + } + } + const status = response.statusCode ?? 500; + const body = method === 'HEAD' || status === 204 || status === 205 || status === 304 + ? null + : Buffer.concat(chunks); + resolve(new Response(body, { + status, + statusText: response.statusMessage, + headers: responseHeaders + })); + }); + }); + request.once('error', reject); + request.end(); + }); +} + +function dedupeAddresses(addresses: ResolvedAddress[]): ResolvedAddress[] { + const seen = new Set(); + return addresses.filter(({address, family}) => { + const key = `${family}:${address}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function isTemporaryDnsError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EAI_AGAIN'; +} + +function raceAbort(promise: Promise, signal: AbortSignal | null | undefined): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, {once: true}); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + +function abortableDelay(ms: number, signal: AbortSignal | null | undefined): Promise { + if (!signal) return new Promise(resolve => setTimeout(resolve, ms)); + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(done, ms); + const onAbort = () => done(signal.reason); + signal.addEventListener('abort', onAbort, {once: true}); + function done(error?: unknown) { + clearTimeout(timeout); + signal!.removeEventListener('abort', onAbort); + if (error) reject(error); + else resolve(); + } + }); +} + +function cancelBody(response: Response): void { + // Cancellation is best-effort and must not extend the request deadline. A + // locked native body is interrupted by the request's AbortController instead. + if (response.body && !response.body.locked) void response.body.cancel().catch(() => {}); +} + +function validateProxyConfiguration(url: URL): void { + const http = proxyVariable('http_proxy', 'HTTP_PROXY'); + const https = proxyVariable('https_proxy', 'HTTPS_PROXY'); + for (const proxy of [http, https]) { + if (!proxy.value) continue; + let parsed: URL; + try { + if (/[\r\n]/.test(proxy.value)) throw new Error(); + parsed = new URL(proxy.value); + decodeURIComponent(parsed.username); + decodeURIComponent(parsed.password); + } catch { + // URL parse errors can contain credentials. Do not attach the raw cause. + throw new Error(`${proxy.name} contains an invalid proxy URL.`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`${proxy.name} uses an unsupported proxy protocol. This Node HTTP client supports HTTP(S) proxies; SOCKS proxies are unavailable.`); + } + } + + // Native fetch falls back to HTTP_PROXY for HTTPS when HTTPS_PROXY is unset. + const selected = url.protocol === 'https:' && https.value ? https : http; + if (!selected.value) { + const all = proxyVariable('all_proxy', 'ALL_PROXY'); + if (all.value) throw new Error(`${all.name} is not supported by this Node HTTP client. Configure HTTP_PROXY or HTTPS_PROXY with an HTTP(S) proxy; SOCKS proxies are unavailable.`); + return; + } + + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number); + if (major < 24 && !(major === 22 && minor >= 21)) { + throw new Error('Native HTTP(S) proxy support requires Node 22.21+ or Node 24+.'); + } + let enabled = process.env.NODE_USE_ENV_PROXY === '1'; + const environmentFlags = (process.env.NODE_OPTIONS ?? '').match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + for (const raw of [...environmentFlags, ...process.execArgv]) { + const flag = raw.replace(/^["']|["']$/g, ''); + if (flag === '--use-env-proxy') enabled = true; + if (flag === '--no-use-env-proxy') enabled = false; + } + if (!enabled) { + throw new Error(`${selected.name} is configured. Start RadioCLI with NODE_USE_ENV_PROXY=1 to enable the native HTTP(S) proxy.`); + } + // Node configures its dispatcher at startup. Do not mutate proxy environment + // variables or install a global dispatcher here; local IPC has a direct agent. +} + +function proxyVariable(lower: string, upper: string): {name: string; value: string | undefined} { + const name = process.env[lower] !== undefined ? lower : upper; + return {name, value: process.env[name]}; +} diff --git a/src/platform/packages.test.ts b/src/platform/packages.test.ts new file mode 100644 index 0000000..50779e1 --- /dev/null +++ b/src/platform/packages.test.ts @@ -0,0 +1,126 @@ +import {describe, expect, it} from 'vitest'; +import {detectPackageManager, ffplayInstallCommand, mpvInstallCommand, packageCommandInvocation, packageInstallCommand, packageManagerNeedsRoot, packageManagerNotes, platformLabel} from './packages.js'; +import {identifyPlatform} from './runtime.js'; + +describe('native package plans', () => { + it.each(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'sunos', 'haiku', 'android'] as const)( + 'gives manual guidance instead of an unavailable installer on %s', platform => { + const options = {hasCommand: () => false, getUid: () => 1000}; + for (const hint of [mpvInstallCommand(platform, 'ID=ubuntu', {}, options), ffplayInstallCommand(platform, 'ID=ubuntu', {}, options)]) { + expect(hint).toMatch(/manual|manually/); + expect(hint).not.toMatch(/(?:brew|winget|sudo|doas|pkg|pkgin|pkgman) (?:install|add|-I|-y)/); + } + } + ); + + it('does not suggest a privilege helper that is missing or needed by root', () => { + const hasCommand = (command: string): boolean => command === 'apt'; + expect(mpvInstallCommand('linux', 'ID=debian', {}, {hasCommand, getUid: () => 1000})).toBe('apt-get install -y mpv (run as root; sudo/doas unavailable)'); + expect(mpvInstallCommand('linux', 'ID=debian', {}, {hasCommand, getUid: () => 0})).toBe('apt-get install -y mpv'); + }); + + it('keeps Termux prerequisites explicit when pkg is absent or the caller is root', () => { + const env = {TERMUX_VERSION: '0.118.3'}; + expect(mpvInstallCommand('android', '', env, {hasCommand: () => false})).toContain('manually'); + const ffplay = ffplayInstallCommand('android', '', env, {hasCommand: () => false}); + expect(ffplay).toContain('x11-repo'); + expect(ffplay).toContain('configured display/audio'); + expect(ffplay).not.toContain('pkg install'); + expect(mpvInstallCommand('android', '', env, {hasCommand: () => true, getUid: () => 0})).toContain('normal Termux app user, without root'); + }); + + it.each([ + ['freebsd', 'pkg'], ['openbsd', 'pkg_add'], ['netbsd', 'pkgin'], ['sunos', 'pkgin'], ['haiku', 'pkgman'] + ] as const)('selects %s package tools without confusing other Unix commands', (platform, expected) => { + expect(detectPackageManager(platform, '', () => true)).toBe(expected); + expect(detectPackageManager(platform, '', () => false)).toBeNull(); + }); + + it('does not treat the illumos IPS pkg command as FreeBSD pkg', () => { + expect(detectPackageManager('sunos', '', command => command === 'pkg')).toBeNull(); + }); + + it.each(['android', 'linux'] as const)('keeps Termux pkg distinct on %s and never falls back to a Linux installer', platform => { + const env = {TERMUX_VERSION: '0.118.3', PREFIX: '/data/data/com.termux/files/usr'}; + expect(detectPackageManager(platform, 'ID=debian', () => true, env)).toBe('termux-pkg'); + expect(detectPackageManager(platform, 'ID=debian', command => command === 'apt', env)).toBeNull(); + expect(platformLabel(platform, '', env)).toBe('Android / Termux'); + }); + + it('recognizes the shared Termux prefix without accepting pkg on ordinary Linux or Android', () => { + expect(detectPackageManager('linux', '', command => command === 'pkg', {PREFIX: '/data/user/10/com.termux/files/usr'})).toBe('termux-pkg'); + expect(detectPackageManager('linux', '', command => command === 'pkg', {})).toBeNull(); + expect(detectPackageManager('android', '', () => true, {})).toBeNull(); + expect(detectPackageManager('freebsd', '', () => true, {TERMUX_VERSION: '0.118.3'})).toBe('pkg'); + }); + + it('requires a manual AIX playback installation even when DNF is installed', () => { + expect(detectPackageManager('aix', '', () => true, {})).toBeNull(); + expect(mpvInstallCommand('aix', '', {})).toContain('no verified AIX'); + expect(ffplayInstallCommand('aix', '', {})).toContain('no verified AIX'); + expect(packageManagerNotes(null, identifyPlatform({platform: 'aix', env: {}})).join(' ')).toContain('RADIOCLI_MPV_PATH'); + }); + + it.each(['sudo', 'doas', null] as const)('does not elevate Termux or Haiku package commands with %s', elevation => { + expect(packageInstallCommand('termux-pkg', 'mpv', {elevation})).toMatchObject({program: 'pkg', args: ['install', '-y', 'mpv']}); + expect(packageInstallCommand('pkgman', 'mpv', {elevation})).toMatchObject({program: 'pkgman', args: ['install', '-y', 'mpv']}); + expect(packageManagerNeedsRoot('termux-pkg')).toBe(false); + expect(packageManagerNeedsRoot('pkgman')).toBe(false); + }); + + it('plans only verified Termux and Haiku components', () => { + expect(packageInstallCommand('termux-pkg', 'ffmpeg')).toBeNull(); + expect(packageInstallCommand('termux-pkg', 'vlc')).toBeNull(); + expect(packageInstallCommand('pkgman', 'ffmpeg')).toMatchObject({program: 'pkgman', args: ['install', '-y', 'ffmpeg8_tools']}); + expect(packageInstallCommand('pkgman', 'vlc')).toBeNull(); + expect(packageManagerNotes('termux-pkg').join(' ')).toContain('x11-repo'); + expect(packageManagerNotes('pkgman').join(' ')).toMatch(/Node 20.*Node 22/); + }); + + it('keeps the printed installation hints consistent with each verified route', () => { + const termux = {TERMUX_VERSION: '0.118.3'}; + const options = {hasCommand: () => true, getUid: () => 1000}; + expect(mpvInstallCommand('android', '', termux, options)).toBe('pkg install -y mpv'); + expect(ffplayInstallCommand('linux', '', termux, options)).toContain('x11-repo'); + expect(mpvInstallCommand('haiku', '', {}, options)).toBe('pkgman install -y mpv'); + expect(ffplayInstallCommand('haiku', '', {}, options)).toBe('pkgman install -y ffmpeg8_tools'); + expect(mpvInstallCommand('sunos', '', {}, options)).toContain('pkgin -y install mpv'); + expect(ffplayInstallCommand('sunos', '', {}, options)).toContain('matching pkgsrc'); + }); + + it('uses verified noninteractive BSD syntax', () => { + expect(packageInstallCommand('pkg', 'mpv')).toMatchObject({program: 'sudo', args: ['pkg', 'install', '-y', 'mpv']}); + expect(packageInstallCommand('pkg_add', 'mpv', {elevation: 'doas'})).toMatchObject({program: 'doas', args: ['pkg_add', '-I', 'mpv']}); + expect(packageInstallCommand('pkgin', 'mpv', {elevation: null})).toMatchObject({program: 'pkgin', args: ['-y', 'install', 'mpv']}); + expect(packageInstallCommand('pkgin', 'ffmpeg')).toBeNull(); + }); + + it('uses the available privilege boundary for non-systemd Linux', () => { + expect(packageInstallCommand('apk', 'mpv', {elevation: 'doas'})).toMatchObject({program: 'doas', args: ['apk', 'add', 'mpv']}); + expect(packageInstallCommand('apt', 'mpv', {elevation: null})).toMatchObject({program: 'apt-get', args: ['install', '-y', 'mpv']}); + }); + + it('executes a discovered package manager path through the selected privilege helper', () => { + const plan = packageInstallCommand('pkgin', 'mpv', {elevation: 'doas'})!; + const paths: Record = {pkgin: '/opt/local/bin/pkgin', doas: '/usr/local/bin/doas'}; + expect(packageCommandInvocation(plan, 'sunos', command => paths[command] ?? null)).toEqual({ + program: '/usr/local/bin/doas', args: ['/opt/local/bin/pkgin', '-y', 'install', 'mpv'] + }); + expect(plan.display).toBe('doas pkgin -y install mpv'); + expect(packageCommandInvocation(packageInstallCommand('termux-pkg', 'mpv')!, 'android', command => command === 'pkg' ? '/data/data/com.termux/files/usr/bin/pkg' : null)).toEqual({ + program: '/data/data/com.termux/files/usr/bin/pkg', args: ['install', '-y', 'mpv'] + }); + }); + + it('uses an encoded PowerShell transport for Windows script shims', () => { + const plan = packageInstallCommand('scoop', 'mpv')!; + const windows = packageCommandInvocation(plan, 'win32'); + expect(windows.program).toBe('powershell.exe'); + expect(windows.args).toContain('-EncodedCommand'); + expect(windows.args).not.toContain('/c'); + const script = Buffer.from(windows.args.at(-1)!, 'base64').toString('utf16le'); + const payload = JSON.parse(Buffer.from(/FromBase64String\('([^']+)'\)/.exec(script)![1]!, 'base64').toString('utf8')); + expect(payload).toEqual({command: 'scoop', args: ['install', 'mpv'], environment: {}}); + expect(packageCommandInvocation(plan, 'linux')).toEqual({program: 'scoop', args: ['install', 'mpv']}); + }); +}); diff --git a/src/platform/packages.ts b/src/platform/packages.ts new file mode 100644 index 0000000..a38e51f --- /dev/null +++ b/src/platform/packages.ts @@ -0,0 +1,215 @@ +import {commandExists} from './executables.js'; +import {powershellCommand} from './shell.js'; +import {identifyPlatform, readLinuxOsRelease, type PlatformProfile} from './runtime.js'; + +export type SetupComponent = 'mpv' | 'ffmpeg' | 'vlc'; +export const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper', 'pkg', 'pkg_add', 'pkgin', 'termux-pkg', 'pkgman'] as const; +export type SetupPackageManager = typeof packageManagers[number]; + +export type SetupCommand = { + component: SetupComponent | null; + label: string; + program: string; + args: string[]; + display: string; +}; + +/** FreeBSD pkg and Termux pkg have distinct recipes and privilege models. */ +export function packageManagerProgram(manager: SetupPackageManager): string { + return manager === 'termux-pkg' ? 'pkg' : manager; +} + +/** Package names/options are selected from the recipes below, never shell input. */ +export function packageCommandInvocation( + command: SetupCommand, + platform: NodeJS.Platform, + resolve: (command: string) => string | null = () => null +): {program: string; args: string[]} { + const program = resolve(command.program) ?? command.program; + const args = (command.program === 'sudo' || command.program === 'doas') && command.args[0] + ? [resolve(command.args[0]) ?? command.args[0], ...command.args.slice(1)] + : command.args; + return platform === 'win32' + ? {program: resolve('powershell.exe') ?? 'powershell.exe', args: powershellCommand([program, ...args])} + : {program, args}; +} + +export function detectPackageManager( + platform: NodeJS.Platform, + osRelease: string, + hasCommand: (command: string) => boolean = commandExists, + env: NodeJS.ProcessEnv = process.env +): SetupPackageManager | null { + const host = identifyPlatform({platform, osRelease, env}); + if (host.id === 'termux') return hasCommand('pkg') ? 'termux-pkg' : null; + if (platform === 'darwin') return hasCommand('brew') ? 'brew' : null; + if (platform === 'win32') return firstAvailable(['winget', 'scoop', 'choco'], hasCommand); + if (platform === 'freebsd') return hasCommand('pkg') ? 'pkg' : null; + if (platform === 'openbsd') return hasCommand('pkg_add') ? 'pkg_add' : null; + // pkg on Solaris/illumos is IPS, not the FreeBSD installer. + if (platform === 'netbsd' || platform === 'sunos') return hasCommand('pkgin') ? 'pkgin' : null; + if (platform === 'haiku') return hasCommand('pkgman') ? 'pkgman' : null; + if (platform !== 'linux') return null; + + const ids = linuxReleaseIds(osRelease); + const preferred: SetupPackageManager[] = hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop']) + ? ['apt'] + : hasAny(ids, ['fedora', 'rhel', 'centos']) + ? ['dnf'] + : hasAny(ids, ['arch', 'manjaro']) + ? ['pacman'] + : hasAny(ids, ['alpine']) + ? ['apk'] + : hasAny(ids, ['opensuse', 'suse']) + ? ['zypper'] + : ['apt', 'dnf', 'pacman', 'apk', 'zypper']; + return firstAvailable(preferred, hasCommand); +} + +export function packageInstallCommand( + manager: SetupPackageManager, + component: SetupComponent, + {elevation = 'sudo'}: {elevation?: 'sudo' | 'doas' | null} = {} +): SetupCommand | null { + const packages: Partial>>> = { + winget: {mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC'}, + // pkgsrc FFmpeg/ffplay packages and executables carry a major version. + // Choose an available matching pair manually instead of guessing a catalog. + pkgin: {mpv: 'mpv', vlc: 'vlc'}, + // Termux's main ffmpeg package omits ffplay. Enabling x11-repo and its + // display/audio services is an explicit manual step, not a setup side effect. + 'termux-pkg': {mpv: 'mpv'}, + pkgman: {mpv: 'mpv', ffmpeg: 'ffmpeg8_tools'} + }; + const packageName = packages[manager] ? packages[manager][component] : component; + if (!packageName) return null; + let program = packageManagerProgram(manager); + let args: string[]; + + if (manager === 'brew') args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])]; + else if (manager === 'winget') args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements']; + else if (manager === 'scoop') args = ['install', packageName]; + else if (manager === 'choco') args = ['install', packageName, '-y']; + else if (manager === 'apt') { program = 'apt-get'; args = ['install', '-y', packageName]; } + else if (manager === 'pacman') args = ['-S', '--needed', '--noconfirm', packageName]; + else if (manager === 'apk') args = ['add', packageName]; + else if (manager === 'zypper') args = ['--non-interactive', 'install', packageName]; + else if (manager === 'dnf' || manager === 'pkg' || manager === 'termux-pkg' || manager === 'pkgman') args = ['install', '-y', packageName]; + else if (manager === 'pkg_add') args = ['-I', packageName]; + else args = ['-y', 'install', packageName]; + + if (packageManagerNeedsRoot(manager) && elevation) { + args = [program, ...args]; + program = elevation; + } + return {component, label: componentLabel(component), program, args, display: [program, ...args].join(' ')}; +} + +export function packageManagerNeedsRoot(manager: SetupPackageManager): boolean { + return !['brew', 'winget', 'scoop', 'choco', 'termux-pkg', 'pkgman'].includes(manager); +} + +export function packageManagerNotes(manager: SetupPackageManager | null, host: PlatformProfile = identifyPlatform()): string[] { + if (manager === 'termux-pkg' || (!manager && host.id === 'termux')) return [ + 'Termux pkg runs as the normal app user, without sudo or root. Use --package-manager=termux-pkg to select this recipe explicitly.', + 'The Termux ffmpeg package does not include ffplay. The separate ffplay package needs x11-repo and a configured display/audio environment; mpv is the recommended audio player.', + 'Android clipboard integration requires the termux-api package and a compatible Termux:API app; helper execution can still fail if Android denies access.' + ]; + if (manager === 'pkgman' || (!manager && host.id === 'haiku')) return [ + 'The verified HaikuPorts runtime recipe provides Node 20, below RadioCLI\'s Node 22 minimum. These playback recipes do not resolve that runtime blocker; a separate working Node 22+ port is required.', + 'Haiku ffmpeg8_tools provides ffmpeg and ffplay. A secondary-architecture install may require explicit RADIOCLI_FFMPEG_PATH and RADIOCLI_FFPLAY_PATH values.' + ]; + if (host.id === 'aix') return ['There is no verified AIX mpv or ffplay package recipe. Provision a working native player manually and set RADIOCLI_MPV_PATH or RADIOCLI_FFPLAY_PATH; IBM Toolbox DNF availability does not establish a playback package.']; + if (manager === 'pkg') return ['FreeBSD ffmpeg provides ffplay only when built with SDL; verify the executable separately.']; + if (manager === 'pkgin' || host.id === 'sunos') return [ + ...(host.id === 'sunos' ? ['Solaris/illumos setup uses an existing pkgsrc/pkgin installation. IPS pkg is a different tool and is not used by this recipe.'] : []), + 'pkgsrc uses versioned FFmpeg/ffplay packages. Choose a matching available pair and set RADIOCLI_FFPLAY_PATH to the absolute ffplay executable.' + ]; + return []; +} + +export function platformLabel(platform: NodeJS.Platform, osRelease: string, env: NodeJS.ProcessEnv = process.env): string { + if (identifyPlatform({platform, osRelease, env}).id === 'termux') return 'Android / Termux'; + if (platform === 'darwin') return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`; + if (platform === 'win32') return 'Windows'; + if (platform === 'haiku') return 'Haiku'; + if (platform === 'sunos') return 'Solaris / illumos'; + if (platform === 'aix') return 'AIX'; + if (platform === 'android') return 'Android'; + if (platform === 'linux') return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux'; + return platform; +} + +export function componentLabel(component: SetupComponent): string { + if (component === 'ffmpeg') return 'FFmpeg / ffplay'; + if (component === 'vlc') return 'VLC fallback'; + return 'mpv'; +} + +function firstAvailable(values: T[], hasCommand: (command: string) => boolean): T | null { + return values.find(hasCommand) ?? null; +} + +function linuxReleaseIds(osRelease: string): Set { + const ids = new Set(); + for (const line of osRelease.split('\n')) { + const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); + if (!match) continue; + for (const value of match[2]!.replaceAll('"', '').split(/\s+/)) if (value.trim()) ids.add(value.trim().toLowerCase()); + } + return ids; +} + +function osReleaseValue(osRelease: string, key: string): string { + const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`)); + return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? ''; +} + +function hasAny(values: Set, candidates: string[]): boolean { + return candidates.some(candidate => values.has(candidate)); +} + +export type PackageHintOptions = { + hasCommand?: (command: string) => boolean; + getUid?: () => number | undefined; +}; + +export function packageManagerElevation(hasCommand: (command: string) => boolean, isRoot: boolean): 'sudo' | 'doas' | null { + return isRoot ? null : firstAvailable(['sudo', 'doas'], hasCommand); +} + +export function packageInstallPrerequisites(manager: SetupPackageManager, components: SetupComponent[]): SetupCommand[] { + if (manager !== 'scoop' || !components.some(component => component === 'mpv' || component === 'vlc')) return []; + return [{component: null, label: 'Scoop extras bucket', program: 'scoop', args: ['bucket', 'add', 'extras'], display: 'scoop bucket add extras'}]; +} + +export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env, options: PackageHintOptions = {}): string { + return playbackInstallHint('mpv', platform, osRelease, env, options); +} + +export function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease(platform), env: NodeJS.ProcessEnv = process.env, options: PackageHintOptions = {}): string { + return playbackInstallHint('ffmpeg', platform, osRelease, env, options); +} + +function playbackInstallHint(component: 'mpv' | 'ffmpeg', platform: NodeJS.Platform, osRelease: string, env: NodeJS.ProcessEnv, {hasCommand = commandExists, getUid = process.getuid}: PackageHintOptions): string { + const host = identifyPlatform({platform, osRelease, env}); + const player = component === 'mpv' ? 'mpv' : 'ffplay'; + if (host.id === 'aix') return `manual native player required; no verified AIX ${player} package recipe (set RADIOCLI_${player.toUpperCase()}_PATH)`; + const manager = detectPackageManager(platform, osRelease, hasCommand, env); + if (component === 'ffmpeg' && host.id === 'termux') return `enable x11-repo manually, then ${manager ? 'pkg install -y ffplay' : 'install the separate ffplay package manually'} (requires configured display/audio); use mpv for audio`; + if (component === 'ffmpeg' && (platform === 'netbsd' || platform === 'sunos')) return 'manually install a matching pkgsrc ffmpegN/ffplayN pair; set RADIOCLI_FFPLAY_PATH'; + + const notes: string[] = []; + if (component === 'ffmpeg' && platform === 'freebsd') notes.push('ffplay requires the SDL build option'); + if (!manager) return [`install ${component === 'mpv' ? 'mpv' : 'FFmpeg'} manually; no supported package manager was found`, ...notes].join('; '); + const isRoot = getUid?.() === 0; + const elevation = packageManagerElevation(hasCommand, isRoot); + const command = packageInstallCommand(manager, component, {elevation}); + if (!command) return `install ${componentLabel(component)} manually`; + if (packageManagerNeedsRoot(manager) && !isRoot && !elevation) notes.unshift('run as root; sudo/doas unavailable'); + if (manager === 'pkgin') notes.push('requires configured pkgsrc'); + if (manager === 'termux-pkg' && isRoot) notes.push('run as the normal Termux app user, without root'); + const prerequisites = packageInstallPrerequisites(manager, [component]); + for (const prerequisite of prerequisites) notes.push(`first run ${prerequisite.display}`); + return command.display + (notes.length ? ` (${notes.join('; ')})` : ''); +} diff --git a/src/platform/paths.test.ts b/src/platform/paths.test.ts new file mode 100644 index 0000000..9d579c5 --- /dev/null +++ b/src/platform/paths.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, it} from 'vitest'; +import {platformPaths} from './paths.js'; + +describe('portable paths and historical compatibility', () => { + it('preserves macOS data/cache paths and the two existing runtime namespaces', () => { + const paths = platformPaths({platform: 'darwin', home: '/Users/Listener', env: {}}); + expect(paths).toMatchObject({ + library: '/Users/Listener/Library/Application Support/radiocli/radiocli.json', + legacyLibrary: '/Users/Listener/Library/Application Support/radio-atlas/radio-atlas.json', + cache: '/Users/Listener/Library/Caches/radiocli/radiocli-cache.json', + legacyCache: '/Users/Listener/Library/Application Support/radio-atlas/radio-atlas-cache.json', + runtime: '/Users/Listener/.local/state/radiocli', + alarmRuntime: '/Users/Listener/Library/Application Support/radiocli/runtime' + }); + }); + + it('uses Windows path semantics even in a mocked host test', () => { + expect(platformPaths({platform: 'win32', home: 'C:\\Users\\Zoë', env: {APPDATA: 'D:\\Roaming Data', LOCALAPPDATA: 'D:\\Local Data'}})).toMatchObject({ + library: 'D:\\Roaming Data\\RadioCLI\\radiocli.json', + cache: 'D:\\Local Data\\RadioCLI\\radiocli-cache.json', + runtime: 'D:\\Local Data\\RadioCLI\\runtime', + alarmRuntime: 'D:\\Local Data\\RadioCLI\\runtime' + }); + }); + + it.each(['linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const)( + 'retains the existing XDG paths on %s', platform => { + expect(platformPaths({platform, home: '/home/Zoë', env: {XDG_DATA_HOME: '/data space', XDG_CACHE_HOME: '/cache space', XDG_RUNTIME_DIR: '/runtime space'}})).toMatchObject({ + library: '/data space/radiocli/radiocli.json', + cache: '/cache space/radiocli/radiocli-cache.json', + runtime: '/runtime space/radiocli', + alarmRuntime: '/runtime space/radiocli' + }); + } + ); + + it('keeps explicit current and legacy home overrides in their original precedence', () => { + expect(platformPaths({platform: 'linux', home: '/home/test', env: {RADIOCLI_HOME: '/radio 新', RADIO_ATLAS_HOME: '/old'}})).toMatchObject({ + library: '/radio 新/radiocli.json', cache: '/radio 新/radiocli-cache.json', runtime: '/radio 新/runtime', alarmRuntime: '/radio 新/runtime' + }); + expect(platformPaths({platform: 'linux', home: '/home/test', env: {RADIO_ATLAS_HOME: '/old'}})).toMatchObject({ + library: '/old/radio-atlas.json', cache: '/old/radio-atlas-cache.json', runtime: '/home/test/.local/state/radiocli' + }); + }); +}); diff --git a/src/platform/paths.ts b/src/platform/paths.ts new file mode 100644 index 0000000..c0e687d --- /dev/null +++ b/src/platform/paths.ts @@ -0,0 +1,44 @@ +import {homedir} from 'node:os'; +import {posix, win32} from 'node:path'; + +type PathInput = {platform?: string; home?: string; env?: NodeJS.ProcessEnv}; + +/** Path selectors form one launch identity, including selectors that are absent. */ +export const pathEnvironmentKeys = [ + 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', + 'RADIOCLI_HOME', 'RADIO_ATLAS_HOME', + 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR' +] as const; + +/** + * Keep historical namespaces stable. In particular, macOS alarm-control/Guard + * files and general agent/occurrence runtime files already use different roots. + * Moving either would strand active authenticated sessions during an upgrade. + */ +export function platformPaths({platform = process.platform, home = homedir(), env = process.env}: PathInput = {}) { + const path = platform === 'win32' ? win32 : posix; + const dataRoot = platform === 'darwin' ? path.join(home, 'Library', 'Application Support') + : platform === 'win32' ? env.APPDATA ?? path.join(home, 'AppData', 'Roaming') + : env.XDG_DATA_HOME ?? path.join(home, '.local', 'share'); + const cacheRoot = platform === 'darwin' ? path.join(home, 'Library', 'Caches') + : platform === 'win32' ? env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local') + : env.XDG_CACHE_HOME ?? path.join(home, '.cache'); + const appName = platform === 'win32' ? 'RadioCLI' : 'radiocli'; + const legacyRoot = platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : env.XDG_DATA_HOME ?? path.join(home, '.local', 'share'); + const legacyCacheRoot = platform === 'darwin' ? legacyRoot : env.XDG_CACHE_HOME ?? path.join(home, '.cache'); + const override = env.RADIOCLI_HOME || env.RADIO_ATLAS_HOME; + const overrideName = env.RADIOCLI_HOME ? 'radiocli' : 'radio-atlas'; + const library = override ? path.join(override, `${overrideName}.json`) : path.join(dataRoot, appName, 'radiocli.json'); + const cache = override ? path.join(override, `${overrideName}-cache.json`) : path.join(cacheRoot, appName, 'radiocli-cache.json'); + const runtime = env.RADIOCLI_HOME ? path.join(env.RADIOCLI_HOME, 'runtime') + : platform === 'win32' ? path.join(cacheRoot, appName, 'runtime') + : path.join(env.XDG_RUNTIME_DIR ?? path.join(home, '.local', 'state'), 'radiocli'); + return { + library, + legacyLibrary: override ? library : path.join(legacyRoot, 'radio-atlas', 'radio-atlas.json'), + cache, + legacyCache: override ? cache : path.join(legacyCacheRoot, 'radio-atlas', 'radio-atlas-cache.json'), + runtime, + alarmRuntime: !env.RADIOCLI_HOME && platform === 'darwin' ? path.join(dataRoot, 'radiocli', 'runtime') : runtime + }; +} diff --git a/src/platform/runtime.test.ts b/src/platform/runtime.test.ts new file mode 100644 index 0000000..1daad8f --- /dev/null +++ b/src/platform/runtime.test.ts @@ -0,0 +1,61 @@ +import {describe, expect, it} from 'vitest'; +import {identifyPlatform, nativeAdapters} from './runtime.js'; + +describe('platform identity and adapter policy', () => { + it.each(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'sunos', 'aix', 'haiku'] as const)( + 'identifies %s without borrowing another operating system identity', platform => { + const host = identifyPlatform({platform, arch: 'x64', env: {}, release: '1', nodeVersion: '22.23.2'}); + expect(host.id).toBe(platform); + expect(host.platform).toBe(platform); + expect(host.isWsl).toBe(false); + } + ); + + it('distinguishes Android/Termux from ordinary Linux and musl', () => { + expect(identifyPlatform({platform: 'android', env: {TERMUX_VERSION: '0.118.3'}})).toMatchObject({id: 'termux', libc: 'bionic'}); + expect(identifyPlatform({platform: 'linux', env: {TERMUX_VERSION: '0.118.3', PREFIX: '/data/data/com.termux/files/usr'}})).toMatchObject({id: 'termux', libc: 'bionic'}); + expect(identifyPlatform({platform: 'android', env: {}})).toMatchObject({id: 'android', libc: 'bionic'}); + expect(identifyPlatform({platform: 'darwin', env: {TERMUX_VERSION: '0.118.3'}}).id).toBe('darwin'); + }); + + it('records WSL and libc independently of Linux adapters', () => { + const host = identifyPlatform({platform: 'linux', release: '6.6.87.2-microsoft-standard-WSL2', libc: 'glibc', env: {}}); + expect(host).toMatchObject({id: 'linux', isWsl: true, libc: 'glibc'}); + expect(nativeAdapters(host).scheduler).toBe('systemd'); + expect(identifyPlatform({platform: 'linux', release: '6.6', libc: 'musl', env: {}})).toMatchObject({isWsl: false, libc: 'musl'}); + }); + + it('records explicit ARM ISA evidence without assuming every arm runtime is ARMv7', () => { + expect(identifyPlatform({platform: 'linux', arch: 'arm', armVersion: 7, env: {}})).toMatchObject({arch: 'arm', armVersion: 7}); + expect(identifyPlatform({platform: 'linux', arch: 'arm', armVersion: null, env: {}}).armVersion).toBeNull(); + expect(identifyPlatform({platform: 'linux', arch: 'arm64', armVersion: 7, env: {}}).armVersion).toBeNull(); + }); + + it('does not invent a native service for BSD, Termux, Haiku, illumos, or AIX', () => { + for (const platform of ['freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const) { + const policy = nativeAdapters(identifyPlatform({platform, env: {}})); + expect(policy.scheduler, platform).toBeNull(); + expect(policy.inhibitor, platform).toBeNull(); + expect(policy.airPlay, platform).toBe(false); + expect(policy.ipc, platform).toBe('unix-socket'); + expect(policy.posixPermissions, platform).toBe(true); + } + }); + + it('preserves the existing macOS, Linux, and Windows adapter selections', () => { + const host = (platform: NodeJS.Platform) => identifyPlatform({platform, env: {}}); + expect(nativeAdapters(host('darwin'))).toMatchObject({scheduler: 'launchd', inhibitor: 'caffeinate', volume: 'macos', terminal: 'macos', airPlay: true, ipc: 'unix-socket'}); + expect(nativeAdapters(host('linux'))).toMatchObject({scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix', airPlay: false, ipc: 'unix-socket'}); + expect(nativeAdapters(host('win32'))).toMatchObject({scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', airPlay: false, ipc: 'named-pipe', posixPermissions: false}); + }); + + it.each(['freebsd', 'openbsd', 'netbsd'])('selects a graphical terminal family independently of scheduling on %s', platform => { + expect(nativeAdapters(identifyPlatform({platform, env: {}}))).toMatchObject({terminal: 'unix', scheduler: null, inhibitor: null}); + }); + + it('keeps unknown runtimes explicit rather than calling them Linux', () => { + const host = identifyPlatform({platform: 'future-os', arch: 'future-cpu', nodeVersion: '24.20.0', env: {}}); + expect(host).toMatchObject({id: 'unknown', platform: 'future-os', arch: 'future-cpu', nodeMajor: 24}); + expect(nativeAdapters(host)).toMatchObject({scheduler: null, inhibitor: null, volume: null, terminal: null, airPlay: false}); + }); +}); diff --git a/src/platform/runtime.ts b/src/platform/runtime.ts new file mode 100644 index 0000000..e35a05a --- /dev/null +++ b/src/platform/runtime.ts @@ -0,0 +1,97 @@ +import {readFileSync} from 'node:fs'; +import {endianness, release} from 'node:os'; + +type PlatformId = 'darwin' | 'win32' | 'linux' | 'freebsd' | 'openbsd' | 'netbsd' | 'android' | 'termux' | 'haiku' | 'sunos' | 'aix' | 'unknown'; +type Libc = 'glibc' | 'musl' | 'bionic' | 'unknown' | 'none'; + +export type PlatformProfile = { + id: PlatformId; + platform: string; + arch: string; + armVersion: number | null; + endianness: 'LE' | 'BE'; + release: string; + nodeVersion: string; + nodeMajor: number; + libc: Libc; + isWsl: boolean; + osRelease: string; +}; + +type PlatformInput = { + platform?: string; + arch?: string; + armVersion?: number | null; + endianness?: 'LE' | 'BE'; + release?: string; + nodeVersion?: string; + libc?: Libc; + env?: NodeJS.ProcessEnv; + osRelease?: string; +}; + +const knownPlatforms = new Set(['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix']); + +/** Host facts are independent from service readiness and project support tiers. */ +export function identifyPlatform(input: PlatformInput = {}): PlatformProfile { + const platform = input.platform ?? process.platform; + const env = input.env ?? process.env; + const termux = (platform === 'android' || platform === 'linux') && Boolean(env.TERMUX_VERSION || env.PREFIX?.match(/^\/data\/(?:data|user\/\d+)\/com\.termux(?:\.[^/]+)?\/files\/usr\/?$/)); + const id: PlatformId = termux ? 'termux' : knownPlatforms.has(platform) ? platform as PlatformId : 'unknown'; + const kernelRelease = input.release ?? release(); + const nodeVersion = (input.nodeVersion ?? process.versions.node).replace(/^v/, ''); + const arch = input.arch ?? process.arch; + const armVersion = input.armVersion !== undefined ? input.armVersion + : platform === process.platform && arch === process.arch ? Number(Reflect.get(process.config.variables, 'arm_version')) : null; + return { + id, + platform, + arch, + armVersion: arch === 'arm' && armVersion !== null && Number.isInteger(armVersion) && armVersion > 0 ? armVersion : null, + endianness: input.endianness ?? endianness(), + release: kernelRelease, + nodeVersion, + nodeMajor: Number.parseInt(nodeVersion, 10), + libc: platform === 'android' || termux ? 'bionic' : input.libc ?? (platform === 'linux' ? nativeLibc(platform) : 'none'), + isWsl: id === 'linux' && Boolean(env.WSL_INTEROP || env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(kernelRelease)), + osRelease: input.osRelease ?? (platform === 'linux' && platform === process.platform ? readLinuxOsRelease() : '') + }; +} + +export type NativeAdapterPolicy = { + scheduler: 'launchd' | 'task-scheduler' | 'systemd' | null; + inhibitor: 'caffeinate' | 'logind' | 'windows' | null; + volume: 'macos' | 'windows' | 'unix-audio' | null; + terminal: 'macos' | 'windows' | 'unix' | null; + ipc: 'named-pipe' | 'unix-socket'; + posixPermissions: boolean; + airPlay: boolean; +}; + +/** Selecting a native adapter does not establish that its service is running. */ +export function nativeAdapters(host: PlatformProfile = identifyPlatform()): NativeAdapterPolicy { + const portable: NativeAdapterPolicy = {scheduler: null, inhibitor: null, volume: null, terminal: null, ipc: 'unix-socket', posixPermissions: true, airPlay: false}; + if (host.id === 'darwin') return {...portable, scheduler: 'launchd', inhibitor: 'caffeinate', volume: 'macos', terminal: 'macos', airPlay: true}; + if (host.id === 'win32') return {...portable, scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', ipc: 'named-pipe', posixPermissions: false}; + if (host.id === 'linux') return {...portable, scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix'}; + if (['freebsd', 'openbsd', 'netbsd'].includes(host.id)) return {...portable, terminal: 'unix'}; + return portable; +} + +export function readLinuxOsRelease(platform: string = process.platform): string { + if (platform !== 'linux') return ''; + try { return readFileSync('/etc/os-release', 'utf8'); } + catch { return ''; } +} + +let detectedLibc: Libc | undefined; +function nativeLibc(platform: string): Libc { + if (platform !== process.platform) return 'unknown'; + if (detectedLibc) return detectedLibc; + try { + const report = process.report.getReport() as {header?: {glibcVersionRuntime?: string}; sharedObjects?: string[]}; + detectedLibc = report.header?.glibcVersionRuntime ? 'glibc' + : report.sharedObjects?.some(path => /(?:ld-musl|libc\.musl)/.test(path)) ? 'musl' : 'unknown'; + } catch { detectedLibc = 'unknown'; } + return detectedLibc; +} diff --git a/src/platform/shell.test.ts b/src/platform/shell.test.ts new file mode 100644 index 0000000..f2d7592 --- /dev/null +++ b/src/platform/shell.test.ts @@ -0,0 +1,43 @@ +import {spawn} from 'node:child_process'; +import {describe,expect,it} from 'vitest'; +import {powershellCommand} from './shell.js'; +import {resolveCommandDetails} from './executables.js'; +import {nodeLaunchCommand} from './launch-command.js'; + +describe('PowerShell command transport',()=>{ + it('transports paths, arguments, and environment as data rather than executable source',()=>{ + const values=['C:\\Tools %PATH%! & \' 单播\\scoop.cmd','install','mpv','text "quoted" ; $(throw boom)'];const environment={RADIOCLI_HOME:'C:\\Data %PATH%! & "quoted" \' 单播'}; + const args=powershellCommand(values,environment);expect(args.slice(0,-1)).toEqual(['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand']); + const script=Buffer.from(args.at(-1)!,'base64').toString('utf16le');const encoded=/FromBase64String\('([^']+)'\)/.exec(script)?.[1];expect(encoded).toBeDefined(); + expect(JSON.parse(Buffer.from(encoded!,'base64').toString('utf8'))).toEqual({command:values[0],args:values.slice(1),environment}); + expect(script).not.toContain(values[0]);expect(script).not.toContain(values.at(-1));expect(script).not.toContain(environment.RADIOCLI_HOME);expect(script).toContain('exit $LASTEXITCODE'); + }); + + it('can leave an interactive console open after the child command',()=>{const args=powershellCommand(['node','cli.js'],{}, {keepOpen:true});expect(args).toContain('-NoExit');expect(args).not.toContain('-NonInteractive');expect(Buffer.from(args.at(-1)!,'base64').toString('utf16le')).not.toContain('exit $LASTEXITCODE');}); + it('silences first-use module progress before invoking cmdlets while retaining errors',()=>{ + const script=Buffer.from(powershellCommand(['node','cli.js']).at(-1)!,'base64').toString('utf16le'); + expect(script).toMatch(/^\$ErrorActionPreference='Stop';\$ProgressPreference='SilentlyContinue';/); + }); + it('rejects empty commands and NUL bytes before starting a process',()=>{expect(()=>powershellCommand([])).toThrow(/command/i);expect(()=>powershellCommand(['node','bad\0value'])).toThrow(/NUL/i);}); + + it.skipIf(process.platform!=='win32')('preserves special data-home characters under native Windows PowerShell',async()=>{ + const powershell=resolveCommandDetails('powershell.exe').path;expect(powershell).not.toBeNull();const home='C:\\Data %PATH%! & "quoted" \' 单播'; + // Keep captured output ASCII so this checks environment transport without + // depending on the Windows console's separate legacy code-page behavior. + const args=powershellCommand([process.execPath,'-e',"process.stdout.write(Buffer.from(process.env.RADIOCLI_HOME,'utf8').toString('base64'))"],{RADIOCLI_HOME:home}); + const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{const child=spawn(powershell!,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',chunk=>stdout+=String(chunk));child.stderr.on('data',chunk=>stderr+=String(chunk));child.once('error',reject);child.once('close',code=>resolve({code:code??1,stdout,stderr}));}); + expect(result.code).toBe(0);expect(result.stderr).toBe('');expect(Buffer.from(result.stdout,'base64').toString('utf8')).toBe(home); + }); + + it.skipIf(process.platform!=='win32')('runs the complete Node bootstrap with literal argv and environment through native Windows PowerShell',async()=>{ + const powershell=resolveCommandDetails('powershell.exe').path;expect(powershell).not.toBeNull(); + const home='C:\\Data %PATH%! & "quoted" \' 单播';const mpv="C:\\Players %PATH%! & ' 单播\\mpv.exe"; + const values=['space value','%PATH%!','a&b','"quoted"',"'quoted'",'$(throw boom);','单播','']; + const script="process.stdout.write(Buffer.from(JSON.stringify({args:process.argv.slice(1),home:process.env.RADIOCLI_HOME,mpv:process.env.RADIOCLI_MPV_PATH}),'utf8').toString('base64'))"; + const bootstrap=nodeLaunchCommand(process.execPath,['-e',script,'--',...values],{RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv}); + const args=powershellCommand(bootstrap); + const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{const child=spawn(powershell!,args,{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='';let stderr='';child.stdout.on('data',chunk=>stdout+=String(chunk));child.stderr.on('data',chunk=>stderr+=String(chunk));child.once('error',reject);child.once('close',code=>resolve({code:code??1,stdout,stderr}));}); + expect(result.code,JSON.stringify(result)).toBe(0);expect(result.stderr).toBe(''); + expect(JSON.parse(Buffer.from(result.stdout,'base64').toString('utf8'))).toEqual({args:values,home,mpv}); + }); +}); diff --git a/src/platform/shell.ts b/src/platform/shell.ts new file mode 100644 index 0000000..4a26eaf --- /dev/null +++ b/src/platform/shell.ts @@ -0,0 +1,27 @@ +/** + * Keep values out of PowerShell source when a native tool or script shim must + * run there. Native argument conversion still belongs to the target command; + * terminal launches use an encoded Node bootstrap for arbitrary argv values. + */ +export function powershellCommand( + values: readonly string[], + environment: Record = {}, + options: {keepOpen?: boolean} = {} +): string[] { + if (!values[0]) throw new Error('A PowerShell command is required.'); + if ([...values, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) { + throw new Error('PowerShell command values cannot contain NUL bytes.'); + } + const payload = Buffer.from(JSON.stringify({command: values[0], args: values.slice(1), environment}), 'utf8').toString('base64'); + const script = [ + "$ErrorActionPreference='Stop'", + "$ProgressPreference='SilentlyContinue'", + `$radiocliInvocation=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json`, + "foreach($radiocliVariable in $radiocliInvocation.environment.PSObject.Properties){[Environment]::SetEnvironmentVariable($radiocliVariable.Name,[string]$radiocliVariable.Value,'Process')}", + '$radiocliArguments=@($radiocliInvocation.args | ForEach-Object {[string]$_})', + '$global:LASTEXITCODE=0', + '& ([string]$radiocliInvocation.command) @radiocliArguments', + ...(options.keepOpen ? [] : ['$radiocliSucceeded=$?', 'if(-not $radiocliSucceeded -and $LASTEXITCODE -eq 0){exit 1}', 'exit $LASTEXITCODE']) + ].join(';'); + return ['-NoLogo', '-NoProfile', ...(options.keepOpen ? ['-NoExit'] : ['-NonInteractive']), '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]; +} diff --git a/src/platform/storage.test.ts b/src/platform/storage.test.ts new file mode 100644 index 0000000..8bb342d --- /dev/null +++ b/src/platform/storage.test.ts @@ -0,0 +1,92 @@ +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it} from 'vitest'; +import {storageReadiness} from './storage.js'; + +const roots: string[] = []; +// Windows ACLs and a privileged POSIX user do not enforce these chmod fixtures. +const nativePermissions = process.platform !== 'win32' && process.getuid?.() !== 0; + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'radiocli storage café ')); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); + +describe('storageReadiness', () => { + it('checks the nearest existing parent without creating directories or files', () => { + const root = fixture(); + const nested = join(root, 'new data', '音楽'); + + expect(storageReadiness(join(nested, 'library.json')).status).toBe('available'); + expect(existsSync(nested)).toBe(false); + expect(readdirSync(root)).toEqual([]); + }); + + it('reports a file in the parent chain without exposing its path', () => { + const root = fixture(); + const obstacle = join(root, 'private account'); + writeFileSync(obstacle, 'not a directory'); + + const result = storageReadiness(join(obstacle, 'new', 'library.json')); + expect(result.status).toBe('unavailable'); + expect(result.message).toMatch(/RADIOCLI_HOME.*writable/i); + expect(result.message).not.toContain(root); + expect(readdirSync(root)).toEqual(['private account']); + }); + + it('does not consider an existing directory to be a writable library file', () => { + const root = fixture(); + const file = join(root, 'library.json'); + mkdirSync(file); + + expect(storageReadiness(file).status).toBe('unavailable'); + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it.skipIf(!nativePermissions)('reports a read-only nearest parent without creating a write probe', () => { + const root = fixture(); + chmodSync(root, 0o500); + try { + const result = storageReadiness(join(root, 'new data', 'library.json')); + expect(result.status).toBe('unavailable'); + expect(result.message).toMatch(/RADIOCLI_HOME.*writable/i); + expect(result.message).not.toContain(root); + expect(readdirSync(root)).toEqual([]); + } finally { + chmodSync(root, 0o700); + } + }); + + it.skipIf(!nativePermissions)('reports a read-only file even when its parent is writable', () => { + const root = fixture(); + const file = join(root, 'library.json'); + writeFileSync(file, '{}'); + chmodSync(file, 0o400); + try { + expect(storageReadiness(file).status).toBe('unavailable'); + expect(readdirSync(root)).toEqual(['library.json']); + } finally { + chmodSync(file, 0o600); + } + }); + + it.skipIf(!nativePermissions)('reports inaccessible ancestors without treating the destination as missing', () => { + const root = fixture(); + const parent = join(root, 'private'); + mkdirSync(parent); + chmodSync(parent, 0); + try { + const result = storageReadiness(join(parent, 'new', 'library.json')); + expect(result.status).toBe('unavailable'); + expect(result.message).not.toContain(root); + } finally { + chmodSync(parent, 0o700); + } + }); +}); diff --git a/src/platform/storage.ts b/src/platform/storage.ts new file mode 100644 index 0000000..245a0b8 --- /dev/null +++ b/src/platform/storage.ts @@ -0,0 +1,46 @@ +import {accessSync, constants, statSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; + +/** Access checks are advisory: ACLs, mounts, and permissions can change before a write. */ +export function storageReadiness(filePath: string): {status: 'available' | 'unavailable'; message: string} { + try { + assertStorageWritable(filePath); + return { + status: 'available', + message: 'Filesystem access checks passed for the library location; each operation still verifies its write.' + }; + } catch { + return { + status: 'unavailable', + message: 'The library location is not accessible for writes. Check permissions or set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.' + }; + } +} + +/** Does not create a probe file or missing directories. The actual write remains authoritative. */ +export function assertStorageWritable(filePath: string): void { + try { + if (!statSync(filePath).isFile()) { + throw Object.assign(new Error('The library destination is not a regular file.'), {code: 'EINVAL'}); + } + // An atomic rename could otherwise replace a deliberately read-only file on POSIX. + accessSync(filePath, constants.R_OK | constants.W_OK); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + let parent = dirname(resolve(filePath)); + while (true) { + try { + if (!statSync(parent).isDirectory()) { + throw Object.assign(new Error('The library parent is not a directory.'), {code: 'ENOTDIR'}); + } + accessSync(parent, constants.W_OK | constants.X_OK); + return; + } catch (error) { + const ancestor = dirname(parent); + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || ancestor === parent) throw error; + parent = ancestor; + } + } +} diff --git a/src/platform/support.test.ts b/src/platform/support.test.ts new file mode 100644 index 0000000..aa2366a --- /dev/null +++ b/src/platform/support.test.ts @@ -0,0 +1,274 @@ +import {describe, expect, it} from 'vitest'; +import {identifyPlatform, type PlatformProfile} from './runtime.js'; +import {assessPlatformSupport, type PlatformSupportEvidence} from './support.js'; + +function host(platform: string, arch = 'x64', overrides: Partial = {}): PlatformProfile { + const release = platform === 'darwin' ? '23.6.0' : platform === 'win32' ? '10.0.26100' : '6.12.0'; + return {...identifyPlatform({platform, arch, armVersion: null, release, nodeVersion: '22.23.2', endianness: arch === 's390x' ? 'BE' : 'LE', libc: platform === 'linux' ? 'glibc' : 'none', env: {}, osRelease: ''}), ...overrides}; +} + +const verified: PlatformSupportEvidence = { + nativeRuntimeVerified: true, + packedInstallVerified: true, + playbackVerified: true, + requiredCi: true +}; + +describe('platform runtime eligibility', () => { + it.each([ + ['darwin', 'x64', '20.0.0', 22, 'tier-1'], + ['darwin', 'arm64', '22.6.0', 24, 'tier-1'], + ['win32', 'x64', '10.0.19045', 22, 'tier-1'], + ['win32', 'x64', '10.0.26100', 24, 'tier-1'], + ['win32', 'arm64', '10.0.26100', 22, 'tier-2'], + ['win32', 'arm64', '10.0.26100', 24, 'tier-2'], + ['win32', 'ia32', '10.0.19045', 22, 'tier-1'] + ] as const)('records %s/%s kernel %s with Node %s independently of project evidence', (platform, arch, release, nodeMajor, upstream) => { + const result = assessPlatformSupport(host(platform, arch, {release, nodeVersion: `${nodeMajor}.20.0`, nodeMajor})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + }); + + it.each([ + ['darwin', 'x64', '19.6.0', 22, 'macOS 11'], + ['darwin', 'arm64', '22.5.0', 24, 'macOS 13.5'], + ['darwin', 'ia32', '23.6.0', 22, '32-bit'], + ['win32', 'ia32', '10.0.19045', 24, 'Node.js 22'], + ['win32', 'x64', '6.1.7601', 22, 'Windows 10'] + ] as const)('diagnoses a blocked %s/%s runtime', (platform, arch, release, nodeMajor, reason) => { + const result = assessPlatformSupport(host(platform, arch, {release, nodeVersion: `${nodeMajor}.20.0`, nodeMajor}), verified); + expect(result).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + expect(result.reasons.join(' ')).toContain(reason); + }); + + it.each(['', 'unknown', '22'])('does not turn an incomplete Darwin release %j into a blocked runtime', release => { + const result = assessPlatformSupport(host('darwin', 'arm64', {release, nodeVersion: '24.20.0', nodeMajor: 24})); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + expect(result.runtime.reasons.join(' ')).toContain('Darwin'); + }); + + it('reads Darwin kernel versions rather than comparing them directly to macOS versions', () => { + const old = host('darwin', 'x64', {release: '13.5.0', nodeVersion: '24.20.0', nodeMajor: 24}); + expect(assessPlatformSupport(old).runtime.status).toBe('unsupported'); + expect(assessPlatformSupport({...old, release: '22.6.0'}).runtime.status).toBe('eligible'); + }); + + it.each([ + ['win32', 'ia32', 24], ['darwin', 'ia32', 22], ['aix', 'ppc64', 22] + ] as const)('does not lose a known %s/%s runtime exclusion when the OS release is missing', (platform, arch, nodeMajor) => { + expect(assessPlatformSupport(host(platform, arch, {release: '', nodeVersion: `${nodeMajor}.20.0`, nodeMajor}), verified)).toMatchObject({ + tier: 'unsupported', runtime: {status: 'unsupported'} + }); + }); + + it.each(['linux', 'win32', 'freebsd', 'netbsd', 'openbsd'])( + 'keeps an unknown %s OS release unverified without an independent runtime blocker', platform => { + expect(assessPlatformSupport(host(platform, 'x64', {release: ''}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'unverified'} + }); + } + ); + + it.each([ + ['x64', 22, 'tier-1'], ['x64', 24, 'tier-1'], + ['arm64', 22, 'tier-1'], ['arm64', 24, 'tier-1'], + ['armv7l', 22, 'tier-1'], ['armv7l', 24, 'experimental'], + ['ppc64', 22, 'tier-2'], ['ppc64', 24, 'tier-2'], + ['s390x', 22, 'tier-2'], ['s390x', 24, 'tier-2'], + ['riscv64', 24, 'experimental'], ['ia32', 24, 'experimental'] + ] as const)('assesses glibc Linux %s on Node %s', (arch, nodeMajor, upstream) => { + const result = assessPlatformSupport(host('linux', arch, {nodeVersion: `${nodeMajor}.20.0`, nodeMajor})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + expect(result.runtime.reasons.join(' ')).toContain('glibc'); + }); + + it('requires the ARM ISA revision before calling generic arm an upstream ARMv7 target', () => { + const result = assessPlatformSupport(host('linux', 'arm')); + expect(result.runtime.status).toBe('unverified'); + expect(result.runtime.reasons.join(' ')).toContain('ARMv7'); + }); + + it('uses an explicitly reported ARMv7 ISA while leaving older ARM unverified', () => { + expect(assessPlatformSupport(host('linux', 'arm', {armVersion: 7})).runtime).toMatchObject({status: 'eligible', upstream: 'tier-1'}); + expect(assessPlatformSupport(host('linux', 'arm', {armVersion: 6})).runtime.status).toBe('unverified'); + expect(assessPlatformSupport(host('linux', 'arm', {armVersion: 7, nodeVersion: '24.20.0', nodeMajor: 24})).runtime.upstream).toBe('experimental'); + }); + + it.each(['amd64', 'x86_64', 'aarch64', 'armv7', 'ppc64le', 'i386', 'i686'])( + 'recognizes the explicit architecture spelling %s', arch => { + expect(assessPlatformSupport(host('linux', arch)).runtime.status).toBe('eligible'); + } + ); + + it.each([22, 24])('keeps big-endian Linux ppc64 separate from ppc64le on Node %s', nodeMajor => { + const base = host('linux', 'ppc64', {nodeVersion: `${nodeMajor}.20.0`, nodeMajor}); + expect(assessPlatformSupport(base).runtime.upstream).toBe('tier-2'); + const result = assessPlatformSupport({...base, endianness: 'BE'}); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + expect(result.runtime.reasons.join(' ')).toContain('big-endian'); + }); + + it('leaves conflicting POWER endianness and unknown 32-bit POWER ports unverified', () => { + expect(assessPlatformSupport(host('linux', 'ppc64le', {endianness: 'BE'})).runtime.status).toBe('unverified'); + expect(assessPlatformSupport(host('linux', 'ppc', {endianness: 'BE'})).runtime.status).toBe('unverified'); + }); + + it.each([ + ['x64', 22, 'experimental'], ['x64', 24, 'experimental'], + ['arm64', 22, 'community'], ['arm64', 24, 'community'], + ['armv7l', 22, 'community'], ['s390x', 24, 'community'] + ] as const)('does not borrow glibc support for musl %s on Node %s', (arch, nodeMajor, upstream) => { + const result = assessPlatformSupport(host('linux', arch, {libc: 'musl', nodeVersion: `${nodeMajor}.20.0`, nodeMajor})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + expect(result.runtime.reasons.join(' ')).toContain('musl'); + }); + + it.each([ + {libc: 'unknown'}, {libc: 'bionic'}, {release: ''}, {release: '3.10.0'}, + {arch: 'riscv64'}, {arch: 'future-cpu'}, {arch: 'armv7l', libc: 'musl', nodeVersion: '24.20.0', nodeMajor: 24} + ] satisfies Partial[])('leaves incomplete Linux runtime evidence unverified: %j', overrides => { + const result = assessPlatformSupport(host('linux', 'x64', overrides)); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + expect(result.runtime.reasons.length).toBeGreaterThan(0); + }); + + it('keeps WSL separate from upstream native Linux support', () => { + const result = assessPlatformSupport(host('linux', 'x64', {isWsl: true})); + expect(result.runtime).toMatchObject({status: 'unverified', upstream: 'community'}); + expect(result.runtime.reasons.join(' ')).toContain('WSL'); + }); + + it.each([ + ['freebsd', 'x64', 'experimental'], ['freebsd', 'arm64', 'community'], + ['freebsd', 'ppc64', 'community'], ['openbsd', 'x64', 'community'], + ['openbsd', 'arm64', 'community'], ['openbsd', 'riscv64', 'community'], + ['netbsd', 'x64', 'community'], ['netbsd', 'arm64', 'community'] + ] as const)('recognizes a community %s/%s route without claiming a native RadioCLI run', (platform, arch, upstream) => { + const result = assessPlatformSupport(host(platform, arch, {release: platform === 'freebsd' ? '14.4-RELEASE' : '11.0'})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream}); + expect(result.tier).toBe('experimental'); + expect(result.evidence.nativeRuntimeVerified).toBe(false); + }); + + it('does not infer a FreeBSD RISC-V Node port from the availability of a VM image', () => { + expect(assessPlatformSupport(host('freebsd', 'riscv64', {release: '15.1-RELEASE'}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'unverified'} + }); + }); + + it('does not hard-block a community FreeBSD port below the upstream experimental baseline', () => { + expect(assessPlatformSupport(host('freebsd', 'x64', {release: '13.1-RELEASE'}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'unverified'} + }); + }); + + it.each(['freebsd', 'openbsd', 'netbsd'])('keeps the %s port on Node 24 eligible but experimentally supported without evidence', platform => { + expect(assessPlatformSupport(host(platform, 'x64', {release: '14.4', nodeVersion: '24.20.0', nodeMajor: 24}))).toMatchObject({ + tier: 'experimental', runtime: {status: 'eligible'} + }); + }); + + it.each(['android', 'linux'])('recognizes Termux on %s as a separate Bionic community route', platform => { + const profile = identifyPlatform({platform, arch: 'arm64', nodeVersion: '24.20.0', release: '6.6.0', env: {TERMUX_VERSION: '0.118.3'}}); + const result = assessPlatformSupport(profile); + expect(result.runtime).toMatchObject({status: 'eligible', upstream: 'community'}); + expect(result.runtime.reasons.join(' ')).toContain('Bionic'); + expect(result.tier).toBe('experimental'); + }); + + it('leaves Android outside Termux unverified', () => { + expect(assessPlatformSupport(host('android', 'arm64'))).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + }); + + it('blocks Haiku with its usual Node 20 runtime but leaves a newer custom port experimental', () => { + const haiku = host('haiku', 'x64', {nodeVersion: '20.15.1', nodeMajor: 20}); + expect(assessPlatformSupport(haiku)).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + const custom = assessPlatformSupport({...haiku, nodeVersion: '22.23.2', nodeMajor: 22}); + expect(custom).toMatchObject({tier: 'experimental', runtime: {status: 'unverified', upstream: 'community'}}); + expect(custom.runtime.reasons.join(' ')).toContain('Haiku'); + }); + + it('does not identify generic SunOS release 5.11 as a verified SmartOS distribution', () => { + const result = assessPlatformSupport(host('sunos', 'x64', {release: '5.11'})); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified', upstream: 'community'}}); + expect(result.runtime.reasons.join(' ')).toContain('SmartOS'); + expect(result.runtime.reasons.join(' ')).toContain('illumos'); + }); + + it('recognizes AIX big-endian POWER without treating uname release as a full AIX version', () => { + const result = assessPlatformSupport(host('aix', 'ppc64', {endianness: 'BE', release: '3'})); + expect(result.runtime).toMatchObject({status: 'eligible', upstream: 'tier-2'}); + expect(result.tier).toBe('experimental'); + expect(result.runtime.reasons.join(' ')).toContain('7.2 TL04'); + }); + + it('diagnoses a little-endian AIX architecture mismatch', () => { + const result = assessPlatformSupport(host('aix', 'ppc64', {endianness: 'LE', release: '3'})); + expect(result).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + expect(result.runtime.reasons.join(' ')).toContain('big-endian'); + }); + + it('keeps unknown operating systems and architectures visible', () => { + const result = assessPlatformSupport(host('future-os', 'future-cpu')); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified', upstream: 'unverified'}}); + expect(result.runtime.reasons.join(' ')).toContain('future-os/future-cpu'); + }); + + it.each([18, 20, 21])('blocks Node %s before considering any platform or evidence', nodeMajor => { + const result = assessPlatformSupport(host('linux', 'x64', {nodeVersion: `${nodeMajor}.0.0`, nodeMajor}), verified); + expect(result).toMatchObject({tier: 'unsupported', runtime: {status: 'unsupported'}}); + expect(result.reasons.join(' ')).toContain('Node.js 22 or newer'); + }); + + it.each(['unknown', '22broken', '26.8.1'])('does not borrow a Node 22/24 policy for %s', nodeVersion => { + const result = assessPlatformSupport(host('linux', 'x64', {nodeVersion, nodeMajor: Number.parseInt(nodeVersion, 10)})); + expect(result).toMatchObject({tier: 'experimental', runtime: {status: 'unverified'}}); + }); +}); + +describe('project support evidence', () => { + it('never promotes a familiar platform from its identity alone', () => { + const result = assessPlatformSupport(host('darwin', 'arm64')); + expect(result.tier).toBe('experimental'); + expect(result.evidence).toEqual({nativeRuntimeVerified: false, packedInstallVerified: false, playbackVerified: false, requiredCi: false}); + expect(result.reasons.join(' ')).toContain('not been verified'); + }); + + it.each(['nativeRuntimeVerified', 'packedInstallVerified', 'playbackVerified'] as const)('requires %s even when every other evidence item is supplied', missing => { + const result = assessPlatformSupport(host('linux'), {...verified, [missing]: false}); + expect(result.tier).toBe('experimental'); + }); + + it('promotes verified runtime, packed install, and playback to supported', () => { + const result = assessPlatformSupport(host('freebsd', 'x64', {release: '14.4-RELEASE'}), {...verified, requiredCi: false}); + expect(result.tier).toBe('supported'); + expect(result.runtime.upstream).toBe('experimental'); + expect(result.reasons.join(' ')).toContain('required CI'); + }); + + it('requires ongoing required CI for first-class support', () => { + const result = assessPlatformSupport(host('win32', 'arm64'), verified); + expect(result.tier).toBe('first-class'); + expect(result.runtime.upstream).toBe('tier-2'); + }); + + it('allows actual project verification to establish support for a custom port independently of its upstream status', () => { + const result = assessPlatformSupport(host('haiku'), {...verified, requiredCi: false}); + expect(result.tier).toBe('supported'); + expect(result.runtime).toMatchObject({status: 'unverified', upstream: 'community'}); + }); + + it('does not infer missing evidence from a required CI flag', () => { + expect(assessPlatformSupport(host('linux'), {requiredCi: true}).tier).toBe('experimental'); + }); + + it('copies supplied evidence without mutating the caller or retaining its object', () => { + const input = {...verified}; + const result = assessPlatformSupport(host('linux'), input); + expect(input).toEqual(verified); + input.playbackVerified = false; + expect(result.evidence.playbackVerified).toBe(true); + }); +}); diff --git a/src/platform/support.ts b/src/platform/support.ts new file mode 100644 index 0000000..1c72f58 --- /dev/null +++ b/src/platform/support.ts @@ -0,0 +1,222 @@ +import {identifyPlatform, type PlatformProfile} from './runtime.js'; + +type SupportTier = 'first-class' | 'supported' | 'experimental' | 'unsupported'; +type RuntimeStatus = 'eligible' | 'unverified' | 'unsupported'; +type UpstreamSupport = 'tier-1' | 'tier-2' | 'experimental' | 'community' | 'unverified' | 'unsupported'; + +/** Evidence must describe this OS, architecture, libc, and Node release line. */ +export type PlatformSupportEvidence = { + /** A real target runtime was exercised; mocks and foreign-CPU emulation do not establish this. */ + nativeRuntimeVerified: boolean; + /** The packed npm artifact was installed and exercised on the target. */ + packedInstallVerified: boolean; + /** Playback was exercised; discovering an executable or a release asset is insufficient. */ + playbackVerified: boolean; + /** The verified target is covered by required, maintained CI. */ + requiredCi: boolean; +}; + +type RuntimeAssessment = { + /** Eligibility for a known runtime route, not proof that RadioCLI was executed. */ + status: RuntimeStatus; + upstream: UpstreamSupport; + reasons: string[]; +}; + +export type PlatformSupportAssessment = { + tier: SupportTier; + runtime: RuntimeAssessment; + reasons: string[]; + evidence: PlatformSupportEvidence; +}; + +/** + * Runtime policy and project evidence are deliberately independent. Catalogs, + * platform mocks, containers, and downloaded artifacts never supply evidence. + * This checks the Node 22/24 reference matrix; other newer lines remain unverified. + * OS vendor lifecycle, CPU instruction levels, libc versions, and audio services + * require separate checks when the profile does not contain those facts. + * + * Reference policy (checked 2026-09-07): + * https://github.com/nodejs/node/blob/v22.x/BUILDING.md#platform-list + * https://github.com/nodejs/node/blob/v24.x/BUILDING.md#platform-list + */ +export function assessPlatformSupport( + host: PlatformProfile = identifyPlatform(), + suppliedEvidence: Partial = {} +): PlatformSupportAssessment { + const evidence: PlatformSupportEvidence = { + nativeRuntimeVerified: suppliedEvidence.nativeRuntimeVerified === true, + packedInstallVerified: suppliedEvidence.packedInstallVerified === true, + playbackVerified: suppliedEvidence.playbackVerified === true, + requiredCi: suppliedEvidence.requiredCi === true + }; + const runtime = assessRuntime(host); + const reasons = [...runtime.reasons]; + let tier: SupportTier = 'experimental'; + + if (runtime.status === 'unsupported') { + tier = 'unsupported'; + } else if (evidence.nativeRuntimeVerified && evidence.packedInstallVerified && evidence.playbackVerified) { + tier = evidence.requiredCi ? 'first-class' : 'supported'; + reasons.push(evidence.requiredCi + ? 'Native runtime, packed installation, and playback are verified with required CI coverage.' + : 'Native runtime, packed installation, and playback are verified; required CI coverage is not established.'); + } else { + if (!evidence.nativeRuntimeVerified) reasons.push('Native runtime execution has not been verified.'); + if (!evidence.packedInstallVerified) reasons.push('Packed npm installation has not been verified.'); + if (!evidence.playbackVerified) reasons.push('Playback has not been verified.'); + } + + return {tier, runtime, reasons, evidence}; +} + +function assessRuntime(host: PlatformProfile): RuntimeAssessment { + const node = /^(\d+)\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.exec(host.nodeVersion); + if (!node) return assessment('unverified', 'unverified', `Cannot assess the Node.js version ${JSON.stringify(host.nodeVersion)}.`); + const major = Number(node[1]); + if (!Number.isSafeInteger(major)) return assessment('unverified', 'unverified', 'The Node.js major version is not recognized.'); + if (major < 22) return assessment('unsupported', 'unsupported', `RadioCLI requires Node.js 22 or newer; this runtime is ${host.nodeVersion}.`); + if (major !== 22 && major !== 24) { + return assessment('unverified', 'unverified', `Node.js ${major} meets the package minimum, but its platform requirements are outside the verified Node.js 22/24 reference matrix.`); + } + + const arch = host.arch === 'arm' && host.armVersion === 7 ? 'armv7' : architecture(host.arch); + if (host.id === 'darwin' && arch === 'ia32') return assessment('unsupported', 'unsupported', 'Node.js 22/24 do not provide a 32-bit macOS runtime; use Intel x64 or Apple Silicon arm64.'); + if (host.id === 'win32' && major === 24 && arch === 'ia32') return assessment('unsupported', 'unsupported', 'Node.js 24 has no Windows x86 runtime; use Node.js 22 for ia32 or a native x64/arm64 runtime.'); + if (host.id === 'aix' && (arch === 'ppc64' || arch === 'ppc64le') && (host.endianness !== 'BE' || arch === 'ppc64le')) { + return assessment('unsupported', 'unsupported', 'The AIX Node.js runtime requires big-endian ppc64; a little-endian POWER profile is incompatible.'); + } + const version = releaseVersion(host.release); + if (!version) { + return assessment('unverified', 'unverified', `${host.id === 'darwin' ? 'Darwin' : host.platform} release is unavailable or unrecognized; runtime eligibility needs the OS version.`); + } + + switch (host.id) { + case 'darwin': + if (!['x64', 'arm64'].includes(arch)) return unknownArchitecture(host); + // os.release() is the Darwin kernel: Darwin 20 = macOS 11, + // Darwin 22.6 = macOS 13.5. It is not a macOS product-version string. + if (version.length < 2) return assessment('unverified', 'tier-1', 'A complete Darwin kernel release is needed to check the macOS runtime minimum.'); + if (olderThan(version, major === 22 ? [20, 0] : [22, 6])) { + return assessment('unsupported', 'unsupported', `Node.js ${major} requires ${major === 22 ? 'macOS 11 (Darwin 20)' : 'macOS 13.5 (Darwin 22.6)'} or newer.`); + } + return assessment('eligible', 'tier-1', `The Darwin kernel meets the Node.js ${major} macOS binary minimum for ${arch}.`); + case 'win32': + if (!['x64', 'arm64', 'ia32'].includes(arch)) return unknownArchitecture(host); + if (version.length < 2) return assessment('unverified', 'unverified', 'A complete Windows kernel version is needed to check the Windows 10 minimum.'); + if (olderThan(version, [10, 0])) return assessment('unsupported', 'unsupported', 'The standard Node.js 22/24 Windows runtime requires Windows 10 / Server 2016 or newer.'); + return assessment('eligible', arch === 'arm64' ? 'tier-2' : 'tier-1', arch === 'ia32' + ? 'Node.js 22 provides Windows x86 binaries; upstream tests run under WoW64, which does not establish native 32-bit Windows verification.' + : `Node.js ${major} provides a Windows ${arch} runtime; the kernel version alone does not distinguish Windows desktop and Server editions.`); + case 'linux': + return linuxRuntime(host, arch, major, version); + case 'freebsd': + // Port allowlists are not evidence that every release/CPU binary exists. + // https://github.com/freebsd/freebsd-ports/blob/main/www/node22/Makefile + // https://github.com/freebsd/freebsd-ports/blob/main/www/node24/Makefile + if (!['x64', 'arm64', 'ia32', 'armv7', 'ppc64', 'ppc64le'].includes(arch)) return unknownArchitecture(host); + if (olderThan(version, [13, 2])) return assessment('unverified', 'experimental', 'FreeBSD is below the upstream experimental 13.2 baseline; a compatible Node.js port needs separate verification.'); + return assessment('eligible', arch === 'x64' ? 'experimental' : 'community', 'FreeBSD has Node.js 22/24 ports; use the matching npm-node package and verify availability for this release and architecture.'); + case 'openbsd': + // https://github.com/openbsd/ports/blob/master/lang/node/Makefile + if (!['x64', 'arm64', 'ia32', 'ppc64', 'riscv64'].includes(arch)) return unknownArchitecture(host); + return assessment('eligible', 'community', 'OpenBSD uses its community Node.js port; the selected OS release and architecture must provide Node.js 22 or newer.'); + case 'netbsd': + // https://github.com/NetBSD/pkgsrc/tree/trunk/lang/nodejs22 + if (!['x64', 'arm64'].includes(arch)) return unknownArchitecture(host); + return assessment('eligible', 'community', 'NetBSD has pkgsrc Node.js 22/24 ports; binary packages are named nodejs and depend on the repository release and architecture.'); + case 'termux': + // https://github.com/termux/termux-packages/blob/master/packages/nodejs-lts/build.sh + if (!['x64', 'arm64', 'ia32', 'arm', 'armv7'].includes(arch)) return unknownArchitecture(host); + return assessment('eligible', 'community', 'Termux needs its Android Bionic Node.js package and Android 7 or newer; the Linux kernel release does not establish the Android API version.'); + case 'android': + return assessment('unverified', 'unverified', 'Android outside Termux has no established RadioCLI runtime installation route; verify a compatible Bionic Node.js build.'); + case 'haiku': + // https://github.com/haikuports/haikuports/tree/master/net-libs/nodejs + return assessment('unverified', 'community', 'HaikuPorts currently provides Node.js 20; this newer runtime requires a separately verified custom Node.js port.'); + case 'sunos': + return assessment('unverified', 'community', 'SmartOS x64 has upstream Tier 2 status; generic SunOS kernel 5.11 does not identify SmartOS, illumos distribution, or its version. Verify the selected Node.js port and playback backend.'); + case 'aix': + if (arch !== 'ppc64') return unknownArchitecture(host); + return assessment('eligible', 'tier-2', `AIX requires big-endian POWER8 or newer and AIX 7.2 TL04 or newer${major === 24 ? ', plus libstdc++12' : ''}; uname release alone does not establish these prerequisites. Playback backend availability needs separate verification.`); + case 'unknown': + return unknownArchitecture(host); + } +} + +function linuxRuntime(host: PlatformProfile, arch: string, major: number, version: number[]): RuntimeAssessment { + if (host.isWsl) return assessment('unverified', 'community', 'WSL uses a Linux Node.js runtime, but upstream support requires reproducing issues on native Linux; verify this WSL environment separately.'); + if (arch === 'arm') return assessment('unverified', 'unverified', 'The generic arm architecture does not identify its ISA revision. Node.js 22 Tier 1 applies to ARMv7; older ARM and Node.js 24 ARMv7 need separate community verification.'); + if ((arch === 'ppc64' || arch === 'ppc64le') && host.endianness !== 'LE') { + return assessment('unverified', 'unverified', 'Upstream Linux POWER support applies to ppc64le, not big-endian ppc64. A compatible big-endian Node.js 22+ community port must be verified separately.'); + } + if (host.libc === 'musl') { + // Node 24 now publishes x64-musl files while its matrix remains Experimental. + // Other known routes are distributor builds, not glibc binary compatibility. + // https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt + // https://github.com/nodejs/docker-node/blob/main/versions.json + const knownRoute = ['x64', 'arm64', 's390x'].includes(arch) || (arch === 'armv7' && major === 22); + if (!knownRoute) return assessment('unverified', 'community', `A Node.js ${major} musl build for ${host.arch} requires separate package and runtime verification.`); + if (olderThan(version, [3, 10])) return assessment('unverified', 'experimental', 'This kernel is below the Node.js musl reference baseline; compatibility is unverified.'); + return assessment('eligible', arch === 'x64' ? 'experimental' : 'community', `Use a musl-specific Node.js ${major} build and its libstdc++ dependencies; libc family detection does not verify the musl version.`); + } + if (host.libc !== 'glibc') return assessment('unverified', 'unverified', `Linux libc is ${host.libc}; select a compatible Node.js build before declaring runtime eligibility.`); + + let upstream: UpstreamSupport; + let minimumKernel = [4, 18]; + let minimumGlibc = '2.28'; + let detail = ''; + if (arch === 'x64' || arch === 'arm64') upstream = 'tier-1'; + else if (arch === 'armv7') { + upstream = major === 22 ? 'tier-1' : 'experimental'; + detail = major === 22 ? ' ARMv7 binaries also require GLIBCXX_3.4.28.' : ' Node.js 24 ARMv7 needs a community build; no upstream release binary is provided.'; + } else if (arch === 'ppc64' || arch === 'ppc64le' || arch === 's390x') { + upstream = 'tier-2'; + if (arch !== 's390x') detail = ' POWER8 or newer is required.'; + } else if (arch === 'riscv64' && major === 24) { + upstream = 'experimental'; + minimumKernel = [5, 19]; + minimumGlibc = '2.36'; + } else if (arch === 'ia32') { + upstream = 'experimental'; + minimumKernel = [3, 10]; + minimumGlibc = '2.17'; + } else return unknownArchitecture(host); + + if (version.length < 2 || olderThan(version, minimumKernel)) { + return assessment('unverified', upstream, `The glibc Linux kernel does not establish the ${minimumKernel.join('.')} reference minimum; older kernels may work but require separate verification.`); + } + return assessment('eligible', upstream, `Node.js ${major} requires glibc >= ${minimumGlibc} and a compatible libstdc++; the profile records only the libc family.${detail}`); +} + +function assessment(status: RuntimeStatus, upstream: UpstreamSupport, ...reasons: string[]): RuntimeAssessment { + return {status, upstream, reasons}; +} + +function unknownArchitecture(host: PlatformProfile): RuntimeAssessment { + return assessment('unverified', 'unverified', `No runtime eligibility is established for ${host.platform}/${host.arch}; verify a compatible Node.js port instead of inferring support from another target.`); +} + +function architecture(arch: string): string { + if (['amd64', 'x86_64'].includes(arch)) return 'x64'; + if (arch === 'aarch64') return 'arm64'; + if (['x86', 'i386', 'i686'].includes(arch)) return 'ia32'; + if (arch === 'armv7l') return 'armv7'; + return arch; +} + +function releaseVersion(release: string): number[] | null { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[.+_-][\w.+-]+)?$/.exec(release); + if (!match) return null; + const parts = match.slice(1).filter(part => part !== undefined).map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function olderThan(actual: number[], minimum: number[]): boolean { + for (let index = 0; index < minimum.length; index++) { + const difference = (actual[index] ?? 0) - minimum[index]!; + if (difference !== 0) return difference < 0; + } + return false; +} diff --git a/src/platform/terminal.test.ts b/src/platform/terminal.test.ts new file mode 100644 index 0000000..096d57d --- /dev/null +++ b/src/platform/terminal.test.ts @@ -0,0 +1,141 @@ +import {describe, expect, it} from 'vitest'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {resolveTerminalCapabilities} from './terminal.js'; + +describe('terminal capabilities', () => { + it('preserves the existing Unicode and truecolor default when no constraint is known', () => { + expect(resolveTerminalCapabilities({})).toEqual({ + unicode: true, colorLevel: 3, screenReader: false, reduceMotion: false, interactive: true + }); + }); + + it.each(['C', 'POSIX'])('uses ASCII decoration for the %s locale', locale => { + expect(resolveTerminalCapabilities({LANG: locale}).unicode).toBe(false); + }); + + it.each(['C.UTF-8', 'en_US.UTF8', 'ja_JP.UTF-8'])('preserves Unicode for %s', locale => { + expect(resolveTerminalCapabilities({LANG: locale}).unicode).toBe(true); + }); + + it('follows LC_ALL, LC_CTYPE, then LANG precedence and ignores empty locale variables', () => { + expect(resolveTerminalCapabilities({LC_ALL: 'C', LC_CTYPE: 'ja_JP.UTF-8', LANG: 'en_US.UTF-8'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({LC_ALL: '', LC_CTYPE: 'C', LANG: 'ja_JP.UTF-8'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({LC_ALL: 'C.UTF-8', LC_CTYPE: 'POSIX'}).unicode).toBe(true); + }); + + it('allows explicit glyph overrides without using SSH as an ASCII signal', () => { + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: '1', LANG: 'en_US.UTF-8'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({RADIOCLI_UNICODE: '1', LC_ALL: 'C'}, {asciiMode: true}).unicode).toBe(true); + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: '0'}, {asciiMode: true}).unicode).toBe(true); + expect(resolveTerminalCapabilities({RADIOCLI_UNICODE: '0'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: '1', RADIOCLI_UNICODE: '1'}).unicode).toBe(false); + expect(resolveTerminalCapabilities({SSH_TTY: '/dev/pts/2', SSH_CONNECTION: 'host 1 host 2', LANG: 'C.UTF-8'}).unicode).toBe(true); + }); + + it('does not treat malformed glyph overrides as enabling a mode', () => { + expect(resolveTerminalCapabilities({RADIOCLI_ASCII: 'maybe', RADIOCLI_UNICODE: 'later'}, {asciiMode: true}).unicode).toBe(false); + }); + + it('makes dumb terminals ASCII, colorless and static even when color and Unicode are forced', () => { + expect(resolveTerminalCapabilities({TERM: 'dumb', FORCE_COLOR: '3', RADIOCLI_UNICODE: '1'})).toMatchObject({ + unicode: false, colorLevel: 0, reduceMotion: true, interactive: false + }); + }); + + it('honors nonempty NO_COLOR over FORCE_COLOR and keeps an empty value inactive', () => { + expect(resolveTerminalCapabilities({NO_COLOR: '0', FORCE_COLOR: '3'}).colorLevel).toBe(0); + expect(resolveTerminalCapabilities({NO_COLOR: '', FORCE_COLOR: '3'}).colorLevel).toBe(3); + }); + + it.each([ + [{TERM: 'xterm'}, 1], + [{TERM: 'screen-256color'}, 2], + [{TERM: 'xterm-256color', COLORTERM: 'truecolor'}, 3], + [{TERM: 'xterm', COLORTERM: '24bit'}, 3], + [{FORCE_COLOR: '0'}, 0], + [{FORCE_COLOR: '1'}, 1], + [{FORCE_COLOR: '2'}, 2], + [{FORCE_COLOR: '3'}, 3] + ] as const)('selects the supported color level for %j', (env, colorLevel) => { + expect(resolveTerminalCapabilities(env).colorLevel).toBe(colorLevel); + }); + + it.each([[1, 0], [4, 1], [8, 2], [24, 3]])('uses a detected %i-bit color depth', (colorDepth, colorLevel) => { + expect(resolveTerminalCapabilities({}, {colorDepth}).colorLevel).toBe(colorLevel); + }); + + it.each([ + [{COLORTERM: 'truecolor'}, {colorDepth: 8}, 3], + [{COLORTERM: '24bit'}, {colorDepth: 4}, 3], + [{TERM: 'xterm-direct'}, {colorDepth: 8}, 3], + [{TERM: 'xterm-256color'}, {colorDepth: 8}, 2], + [{TERM: 'xterm'}, {colorDepth: 4}, 1] + ] as const)('honors explicit color capabilities for %j with stream evidence %j', (env, evidence, colorLevel) => { + expect(resolveTerminalCapabilities(env, evidence).colorLevel).toBe(colorLevel); + }); + + it('keeps explicit user color overrides above terminal capability declarations', () => { + expect(resolveTerminalCapabilities({FORCE_COLOR: '2', COLORTERM: 'truecolor'}, {colorDepth: 24}).colorLevel).toBe(2); + expect(resolveTerminalCapabilities({NO_COLOR: '1', COLORTERM: 'truecolor'}, {colorDepth: 24}).colorLevel).toBe(0); + }); + + it('keeps piped output plain and static unless color is explicitly requested', () => { + expect(resolveTerminalCapabilities({}, {isTTY: false})).toMatchObject({colorLevel: 0, interactive: false, reduceMotion: true}); + expect(resolveTerminalCapabilities({FORCE_COLOR: '2'}, {isTTY: false}).colorLevel).toBe(2); + }); + + it.each([{INK_SCREEN_READER: 'true'}, {RADIOCLI_SCREEN_READER: '1'}])('disables decoration animation in accessible mode %j', env => { + expect(resolveTerminalCapabilities(env)).toMatchObject({screenReader: true, reduceMotion: true, colorLevel: 0}); + }); + + it('accepts the actual Ink accessibility state and legacy animation overrides', () => { + expect(resolveTerminalCapabilities({}, {screenReader: true})).toMatchObject({screenReader: true, reduceMotion: true}); + expect(resolveTerminalCapabilities({RADIOCLI_DISABLE_ANIMATION: '1'}).reduceMotion).toBe(true); + expect(resolveTerminalCapabilities({RADIO_ATLAS_DISABLE_ANIMATION: '1'}).reduceMotion).toBe(true); + }); + + it('does not mutate the supplied environment', () => { + const env = Object.freeze({NO_COLOR: '1', FORCE_COLOR: '3', RADIOCLI_ASCII: '1'}); + const capabilities = resolveTerminalCapabilities(env); + expect(capabilities.colorLevel).toBe(0); + expect(env).toEqual({NO_COLOR: '1', FORCE_COLOR: '3', RADIOCLI_ASCII: '1'}); + }); +}); + +describe('terminal color startup', () => { + it.each([ + {env: {NO_COLOR: '1', FORCE_COLOR: '3'}, evidence: {}, expected: 0}, + {env: {TERM: 'dumb', FORCE_COLOR: '3'}, evidence: {}, expected: 0}, + {env: {TERM: 'xterm'}, evidence: {}, expected: 1}, + {env: {TERM: 'xterm-256color'}, evidence: {}, expected: 2}, + {env: {TERM: 'xterm-256color', COLORTERM: 'truecolor'}, evidence: {}, expected: 3}, + {env: {TERM: 'xterm-256color', COLORTERM: 'truecolor'}, evidence: {isTTY: true, colorDepth: 8}, expected: 3}, + {env: {FORCE_COLOR: '1', COLORTERM: 'truecolor'}, evidence: {}, expected: 1}, + {env: {FORCE_COLOR: '2', GITHUB_ACTIONS: 'true', CI: 'true'}, evidence: {}, expected: 2} + ])('configures real Ink before its color dependency loads for $env with $evidence', ({env, evidence, expected}) => { + const output = execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', ` + import {configureTerminalRenderer} from './src/ui/terminal-renderer.ts'; + const evidence = ${JSON.stringify(evidence)}; + const terminal = await configureTerminalRenderer(process.env, evidence); + const [{createElement}, {Box, Text, renderToString}, {resolveDisplayMode}] = await Promise.all([ + import('react'), import('ink'), import('./src/ui/display-context.ts') + ]); + const display = resolveDisplayMode({}, process.env, evidence); + const frame = renderToString(createElement(Box, {backgroundColor: display.app}, + createElement(Text, {color: '#74f28a', backgroundColor: display.panel}, '東京 / Café'))); + process.stdout.write(JSON.stringify({terminal, frame})); + `], { + cwd: fileURLToPath(new URL('../../', import.meta.url)), encoding: 'utf8', stdio: 'pipe', + env: {...process.env, TERM: undefined, COLORTERM: undefined, FORCE_COLOR: undefined, NO_COLOR: undefined, INK_SCREEN_READER: undefined, RADIOCLI_SCREEN_READER: undefined, ...env} + }); + const result = JSON.parse(output) as {terminal: {colorLevel: number}; frame: string}; + expect(result.terminal.colorLevel).toBe(expected); + expect(result.frame).toContain('東京 / Café'); + if (expected === 0) expect(result.frame).not.toContain('\u001B['); + else expect(result.frame).toContain('\u001B['); + if (expected < 3) expect(result.frame).not.toMatch(/\u001B\[(?:38|48);2;/u); + if (expected < 2) expect(result.frame).not.toMatch(/\u001B\[(?:38|48);5;/u); + if (expected === 3) expect(result.frame).toMatch(/\u001B\[(?:38|48);2;/u); + }); +}); diff --git a/src/platform/terminal.ts b/src/platform/terminal.ts new file mode 100644 index 0000000..831d2de --- /dev/null +++ b/src/platform/terminal.ts @@ -0,0 +1,72 @@ +export type TerminalColorLevel = 0 | 1 | 2 | 3; + +export type TerminalEvidence = { + isTTY?: boolean; + colorDepth?: number; + screenReader?: boolean; + asciiMode?: boolean; + reduceMotion?: boolean; +}; + +export type TerminalCapabilities = { + unicode: boolean; + colorLevel: TerminalColorLevel; + screenReader: boolean; + reduceMotion: boolean; + interactive: boolean; +}; + +/** Environment overrides affect this invocation, never the saved settings. */ +export function resolveTerminalCapabilities(env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): TerminalCapabilities { + const dumb = env.TERM?.toLowerCase() === 'dumb'; + const screenReader = evidence.screenReader === true || env.INK_SCREEN_READER === 'true' || flag(env.RADIOCLI_SCREEN_READER) === true; + const interactive = evidence.isTTY !== false && !dumb; + const asciiOverride = flag(env.RADIOCLI_ASCII); + const unicodeOverride = flag(env.RADIOCLI_UNICODE); + const locale = [env.LC_ALL, env.LC_CTYPE, env.LANG].find(value => value?.trim())?.trim(); + // An explicit ASCII request wins when both overrides are enabled. A bare + // C/POSIX locale is a known constraint; SSH and missing locale data are not. + const unicode = dumb ? false + : asciiOverride === true ? false + : unicodeOverride === true ? true + : asciiOverride === false ? true + : unicodeOverride === false ? false + : locale === 'C' || locale === 'POSIX' ? false : !evidence.asciiMode; + const colorLevel = dumb || screenReader || Boolean(env.NO_COLOR) ? 0 : resolveColorLevel(env, evidence); + return { + unicode, + colorLevel, + screenReader, + reduceMotion: Boolean(evidence.reduceMotion) || !interactive || screenReader || env.RADIOCLI_DISABLE_ANIMATION === '1' || env.RADIO_ATLAS_DISABLE_ANIMATION === '1', + interactive + }; +} + +function resolveColorLevel(env: NodeJS.ProcessEnv, evidence: TerminalEvidence): TerminalColorLevel { + const forced = env.FORCE_COLOR; + if (forced === '0' || forced === 'false') return 0; + if (forced === '2') return 2; + if (forced === '3') return 3; + if (forced === '' || forced === '1' || forced === 'true') return 1; + if (evidence.isTTY === false) return 0; + // COLORTERM=truecolor/24bit and direct-color TERM values are explicit + // capability declarations. Some terminals expose those declarations while + // Node's getColorDepth() still reports 8 bits, so honor them before the + // conservative stream probe to avoid silently quantizing RGB themes. + if (/^(truecolor|24bit)$/i.test(env.COLORTERM ?? '') || /(?:direct|truecolor)/i.test(env.TERM ?? '')) return 3; + if (evidence.colorDepth !== undefined) { + return evidence.colorDepth <= 1 ? 0 : evidence.colorDepth <= 4 ? 1 : evidence.colorDepth <= 8 ? 2 : 3; + } + if (/256color/i.test(env.TERM ?? '')) return 2; + if (env.TERM) return 1; + // Preserve the existing rich display unless the environment supplies a + // concrete constraint. Native callers can pass stdout.getColorDepth(). + return 3; +} + +function flag(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + if (/^(1|true|yes|on)$/i.test(value)) return true; + if (/^(0|false|no|off)$/i.test(value)) return false; + return undefined; +} diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts new file mode 100644 index 0000000..1561e36 --- /dev/null +++ b/src/platform/terminals.test.ts @@ -0,0 +1,231 @@ +import {execFileSync,spawn,spawnSync,type ChildProcess} from 'node:child_process'; +import {EventEmitter} from 'node:events'; +import {existsSync,mkdtempSync,mkdirSync,readFileSync,rmSync,symlinkSync,writeFileSync} from 'node:fs'; +import {basename,join,posix} from 'node:path'; +import {afterEach,describe,expect,it,vi} from 'vitest'; +import {createTerminalLaunch,detectGraphicalTerminal,launchTerminalCommand} from './terminals.js'; +import {waitForLaunch} from './launch-command.js'; + +const roots:string[]=[]; +afterEach(()=>{vi.useRealTimers();for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true,maxRetries:5,retryDelay:50});}); +const resolve=(command:string)=>command; +function fixture(){const root=mkdtempSync(join(process.cwd(),'.radiocli-terminals-'));roots.push(root);return root;} +function terminalPath(root:string,name:string){return posix.join(basename(root),name);} +function nodeInvocation(args: readonly string[], environment: NodeJS.ProcessEnv = {}): {command: string; args: string[]} { + const index = args.indexOf('-EncodedCommand'); + expect(index).toBeGreaterThanOrEqual(0); + const script = Buffer.from(args[index + 1]!, 'base64').toString('utf16le'); + const encoded = /FromBase64String\('([^']+)'\)/.exec(script)?.[1]; + const key = /GetEnvironmentVariable\('([^']+)'/.exec(script)?.[1]; + const source = encoded ? Buffer.from(encoded, 'base64').toString('utf8') : environment[key!]; + expect(source).toBeDefined(); + const invocation = JSON.parse(source!) as {command: string; args: string[]}; + return invocation.args.includes('-EncodedCommand') ? nodeInvocation(invocation.args) : invocation; +} + +describe('shared graphical terminal discovery',()=>{ + it('ignores a malformed saved descriptor and continues normal terminal detection',()=>{expect(detectGraphicalTerminal('linux',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linuxX'},command=>command==='kitty'?'/usr/bin/kitty':undefined)).toBe('linux:/usr/bin/kitty');}); + it.each(['linux','freebsd','openbsd','netbsd'] as const)('requires the central Unix adapter policy and an installed terminal on %s',platform=>{const env={DISPLAY:':0',TERMINAL:'xterm'};expect(detectGraphicalTerminal(platform,env,command=>command==='xterm'?'/usr/local/bin/xterm':undefined)).toBe(`${platform}:/usr/local/bin/xterm`);expect(detectGraphicalTerminal(platform,env,()=>undefined)).toBe(`${platform}:unsupported`);expect(detectGraphicalTerminal(platform,{...env,DISPLAY:''},resolve)).toBe(`${platform}:unsupported`);}); + it.each(['freebsd','openbsd','netbsd'] as const)('accepts a legacy Linux descriptor after revalidating the saved Unix terminal on %s',platform=>{expect(detectGraphicalTerminal(platform,{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/local/bin/xterm'},resolve)).toBe('linux:/usr/local/bin/xterm');expect(detectGraphicalTerminal(platform,{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/local/bin/xterm'},()=>undefined)).toBe(`${platform}:unsupported`);}); + it('requires an X11 or Wayland session before selecting a Unix terminal',()=>{expect(detectGraphicalTerminal('linux',{RADIOCLI_ALARM_TERMINAL:'linux:/bin/kitty'},resolve)).toBe('linux:unsupported');}); + it('revalidates saved executable paths and refuses stale files and directories',()=>{const root=fixture();const kitty=join(root,'kitty');mkdirSync(kitty);const env={DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:`linux:${terminalPath(root,'kitty')}`};expect(detectGraphicalTerminal('linux',env)).toBe('linux:unsupported');rmSync(kitty,{recursive:true});expect(detectGraphicalTerminal('linux',env)).toBe('linux:unsupported');}); + it.skipIf(process.platform==='win32')('refuses a saved Unix terminal without executable permission',()=>{const root=fixture();writeFileSync(join(root,'kitty'),'stub',{mode:0o600});expect(detectGraphicalTerminal('linux',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:`linux:${terminalPath(root,'kitty')}`})).toBe('linux:unsupported');}); + it('resolves installed terminals from the supplied PATH',()=>{const root=fixture();writeFileSync(join(root,'kitty'),'stub',{mode:0o700});expect(detectGraphicalTerminal('linux',{DISPLAY:':0',PATH:basename(root),TERMINAL:'kitty'})).toBe(`linux:${terminalPath(root,'kitty')}`);}); + it('does not select an X11-only terminal for a Wayland-only session',()=>{expect(detectGraphicalTerminal('linux',{WAYLAND_DISPLAY:'wayland-0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/xterm'},resolve)).toBe('linux:unsupported');}); + it('does not select a Wayland-only terminal for an X11-only session',()=>{expect(detectGraphicalTerminal('linux',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/foot'},resolve)).toBe('linux:unsupported');}); + it('does not accept a saved Linux descriptor on unsupported runtimes',()=>{expect(detectGraphicalTerminal('linux',{TERMUX_VERSION:'0.118.3',DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},resolve)).toBe('termux:unsupported');expect(detectGraphicalTerminal('haiku',{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/usr/bin/kitty'},resolve)).toBe('haiku:unsupported');}); +}); + +describe('graphical terminal invocation plans',()=>{ + it.each([ + ['darwin','apple-terminal'],['darwin','iterm'],['darwin','wezterm'],['darwin','ghostty'],['darwin','kitty'], + ['linux','/tools/kitty'],['win32','console'],['win32','windows-terminal'] + ] as const)('restores the saved identity through %s:%s despite an unrelated terminal environment', (platform,name) => { + if(platform==='darwin'&&(name==='apple-terminal'||name==='iterm')&&process.platform==='win32')return; + const saved=platform==='win32' + ?{HOME:"C:\\home\\Radio ' \" $() & 单播",RADIO_ATLAS_HOME:"C:\\legacy\\Radio ' \" $() & 单播",XDG_CACHE_HOME:'C:\\cache\\saved',XDG_RUNTIME_DIR:'C:\\run\\saved'} + :{HOME:"/home/Radio ' \" $() & 单播",RADIO_ATLAS_HOME:"/legacy/Radio ' \" $() & 单播",XDG_CACHE_HOME:'/cache/saved',XDG_RUNTIME_DIR:'/run/saved'}; + const probe="process.stdout.write(JSON.stringify({home:process.env.HOME,legacy:process.env.RADIO_ATLAS_HOME,current:process.env.RADIOCLI_HOME??null,cache:process.env.XDG_CACHE_HOME,runtime:process.env.XDG_RUNTIME_DIR,args:process.argv.slice(1)}))"; + const values=['--flag',"literal ' \" $() ; & 单播",'']; + const plan=createTerminalLaunch({platform,env:{...saved,DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:`${platform}:${name}`},nodePath:process.execPath,args:['-e',probe,'--',...values],resolve}); + let command:readonly string[]; + if(plan.command==='/usr/bin/osascript')command=['/bin/sh','-c',plan.args.at(-1)!]; + else if(platform==='win32'){const invocation=nodeInvocation(plan.args,plan.environment);command=[invocation.command,...invocation.args];} + else command=plan.args.slice(plan.args.indexOf(process.execPath)); + const output=execFileSync(command[0]!,command.slice(1),{env:{RADIOCLI_HOME:'/unrelated/current',HOME:'/unrelated/home',RADIO_ATLAS_HOME:'/unrelated/legacy',XDG_CACHE_HOME:'/unrelated/cache',XDG_RUNTIME_DIR:'/unrelated/runtime'},encoding:'utf8'}); + expect(JSON.parse(output)).toEqual({home:saved.HOME,legacy:saved.RADIO_ATLAS_HOME,current:null,cache:saved.XDG_CACHE_HOME,runtime:saved.XDG_RUNTIME_DIR,args:values}); + }); + + it('clears an inherited path override even when a Unix launch has no saved settings', () => { + const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',TERMINAL:'kitty'},nodePath:process.execPath,args:['-e',"process.stdout.write(process.env.RADIOCLI_HOME??'absent')"],resolve}); + expect(execFileSync(plan.args[1]!,plan.args.slice(2),{env:{RADIOCLI_HOME:'/unrelated'},encoding:'utf8'})).toBe('absent'); + }); + + it.skipIf(process.platform==='win32').each([ + ['qterminal','requested'],['x-terminal-emulator','requested'],['x-terminal-emulator','automatic'],['x-terminal-emulator','saved'] + ] as const)('preserves exact argv when %s (%s) reparses only its first -e argument', (name,selection) => { + const root=fixture();const nodeDirectory=join(root,"Radio Tools ' 单播");const nodePath=join(nodeDirectory,'node');mkdirSync(nodeDirectory);symlinkSync(process.execPath,nodePath); + const qterminal=join(root,'qterminal');const alias=join(root,'x-terminal-emulator'); + // QTerminal parses optarg, then appends every remaining argv literally: + // https://github.com/lxqt/qterminal/blob/master/src/main.cpp#L99-L107 + // This fake implements the unquoted whitespace case used by this fixture. + writeFileSync(qterminal,`import{spawnSync}from'node:child_process';const argv=process.argv.slice(2);const start=argv.indexOf('-e');const command=[...argv[start+1].split(/\\s+/),...argv.slice(start+2)];const child=spawnSync(command[0],command.slice(1),{stdio:'inherit'});if(child.error)console.error(child.error.message);process.exit(child.status??1);`); + symlinkSync(qterminal,alias); + const values=['--flag','--option=two words','a b',"apostrophe's",'"double quotes"','$(false); & | < > %PATH%!','单播','']; + const env={DISPLAY:':0',...(selection==='requested'?{TERMINAL:name}:selection==='saved'?{RADIOCLI_ALARM_TERMINAL:`linux:${alias}`}:{})}; + const plan=createTerminalLaunch({platform:'linux',env,nodePath,args:['-e','process.stdout.write(JSON.stringify(process.argv.slice(1)))','--',...values],resolve:command=>command==='/bin/sh'?'/bin/sh':command===alias||command==='x-terminal-emulator'?alias:command==='qterminal'?qterminal:undefined}); + expect(plan.command).toBe(join(root,name)); + const result=spawnSync(process.execPath,[plan.command,...plan.args],{env:{},encoding:'utf8'}); + expect(result.status,result.stderr).toBe(0);expect(JSON.parse(result.stdout)).toEqual(values); + }); + it.each([ + ['gnome-terminal',['--']],['mate-terminal',['-x']],['xfce4-terminal',['-x']],['terminator',['-x']], + ['wezterm',['start','--always-new-process','--']],['kitty',['-e']],['alacritty',['-e']],['konsole',['-e']],['ghostty',['-e']],['tilix',['-e']],['xterm',['-e']],['uxterm',['-e']],['foot',['--']] + ] as const)('uses %s argument boundaries with an encoded application invocation', (name,prefix)=>{ + const nodePath='/Node A/单播/node';const args=['/Radio A/cli.js','agent-ui','a;& quoted "value"']; + const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',WAYLAND_DISPLAY:'wayland-0',RADIOCLI_ALARM_TERMINAL:`linux:/tools/${name}`},nodePath,args,resolve}); + expect(plan.command).toBe(`/tools/${name}`);expect(plan.args.slice(0,prefix.length+2)).toEqual([...prefix,nodePath,'-e']); + expect(JSON.parse(Buffer.from(plan.args.at(-1)!,'base64url').toString('utf8'))).toEqual({args,environment:{}}); + }); + it.skipIf(process.platform==='win32')('uses a literal-safe shell boundary for qterminal command parsing',()=>{const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/tools/qterminal'},nodePath:process.execPath,args:['-e','process.stdout.write(process.argv[1])','--',"Data ' \" ; $() 单播"],resolve});expect(plan.args.slice(0,3)).toEqual(['-e','/bin/sh','-c']);expect(execFileSync('/bin/sh',plan.args.slice(2),{encoding:'utf8'})).toBe("Data ' \" ; $() 单播");}); + it('preserves a special data home and every argument through the Windows Node bootstrap',()=>{ + const home='C:\\Data %PATH%! & "quote" \' 单播';const values=['space value','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; + const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home},nodePath:process.execPath,args:['-e',"process.stdout.write(JSON.stringify({args:process.argv.slice(1),home:process.env.RADIOCLI_HOME}))",'--',...values],resolve,closeOnExit:true}); + const payload=nodeInvocation(plan.args,plan.environment); + expect(payload.args[1]).not.toContain('"');const output=execFileSync(payload.command,payload.args,{encoding:'utf8'});expect(JSON.parse(output)).toEqual({args:values,home});expect(plan.command).toBe('powershell.exe'); + }); + it('requests a separate Windows console without inheriting detached launcher stdio',()=>{ + const plan=createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve,closeOnExit:true}); + const script=Buffer.from(plan.args.at(-1)!,'base64').toString('utf16le'); + expect(script).toMatch(/CreateProcessW\(application, commandLine, IntPtr.Zero, IntPtr.Zero, false,\s*0x00000010, IntPtr.Zero, null, ref startup, out process\)/); + expect(script).toContain('startup.dwFlags = 0x00000001;');expect(script).not.toMatch(/GetStdHandle|Start-Process|startup\.hStd\w+\s*=/); + expect(script).toContain('CloseHandle(process.hThread)');expect(script).toContain('CloseHandle(process.hProcess)'); + expect(script).toMatch(/SetEnvironmentVariable\('[^']+',\$null,'Process'\);Add-Type/); + }); + it('keeps a console launch with ordinary deep install paths below the Windows command-line limit',()=>{ + const nodePath=`C:\\Program Files\\${'Runtime\\'.repeat(19)}node.exe`; + const cliPath=`C:\\${'RadioCLI\\'.repeat(22)}dist\\cli.js`; + const dataRoot=`C:\\${'Radio Data\\'.repeat(18)}`; + const env={RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:`${dataRoot}Home`,RADIOCLI_MPV_PATH:`${dataRoot}mpv.exe`,RADIOCLI_FFPLAY_PATH:`${dataRoot}ffplay.exe`,RADIOCLI_VLC_PATH:`${dataRoot}vlc.exe`,RADIOCLI_FFMPEG_PATH:`${dataRoot}ffmpeg.exe`}; + const plan=createTerminalLaunch({platform:'win32',env,nodePath,args:[cliPath],resolve}); + expect([nodePath,cliPath,...Object.values(env)].every(value=>value.length<260)).toBe(true); + // Include quotes, separators, and NUL in the CreateProcessW limit. + expect([plan.command,...plan.args].reduce((length,value)=>length+value.length+3,1)).toBeLessThan(32_767); + const handoff=Object.values(plan.environment!);expect(handoff).toHaveLength(1); + expect(handoff[0]!.length+1).toBeLessThan(32_767); + const inner=JSON.parse(handoff[0]!) as {command:string;args:string[]}; + expect([inner.command,...inner.args].reduce((length,value)=>length+value.length+3,1)).toBeLessThan(32_767); + }); + it.skipIf(process.platform!=='win32')('opens real Windows console TTY handles and preserves literal argv and environment',async()=>{ + const root=fixture();const cli=join(root,"Radio %PATH%! & ' 单播.cjs");const output=join(root,'console-result.json'); + const preload=join(root,'console-preload.cjs');const stages=join(root,'console-stages.jsonl');const powershellStages=join(root,'powershell-stages.log'); + // Observe both the Node bootstrap and CLI without touching standard handles + // or logging argv contents. Monitoring leaves normal exception handling intact. + writeFileSync(preload,[ + "const fs=require('node:fs')", + `const stage=process.argv[1]===${JSON.stringify(cli)}?'cli':'bootstrap'`, + `function record(event){try{fs.appendFileSync(${JSON.stringify(stages)},JSON.stringify({...event,pid:process.pid,stage})+'\\n','utf8')}catch{}}`, + "record({event:'start',argumentLengths:process.argv.map(value=>value.length)})", + "process.once('exit',code=>record({event:'exit',code}))", + "process.on('uncaughtExceptionMonitor',(error,origin)=>record({event:'uncaught',origin,code:error.code??null,message:String(error.message).slice(0,2_000)}))" + ].join(';')); + const nodeOptions=[process.env.NODE_OPTIONS,`--require ${JSON.stringify(preload)}`].filter(Boolean).join(' '); + // File output observes the actual TTY child without redirecting its stdio. + writeFileSync(cli,[ + "const fs=require('node:fs');const output=process.argv[2]", + "fs.writeFileSync(output+'.started','started','utf8')", + 'const result={args:process.argv.slice(3),home:process.env.RADIOCLI_HOME,mpv:process.env.RADIOCLI_MPV_PATH}', + "for(const name of ['stdin','stdout','stderr']){try{result[name]=process[name].isTTY===true}catch(error){result[name]={code:error.code??null,message:error.message}}}", + "fs.writeFileSync(output+'.tmp',JSON.stringify(result),'utf8');fs.renameSync(output+'.tmp',output)" + ].join(';')); + const home='C:\\Data %PATH%! & "quote" \' 单播';const mpv="C:\\Players %PATH%! & ' 单播\\mpv.exe";const args=['spaces here','%PATH%!','a&b','"quoted"',"'quoted'",'单播','']; + const diagnostic:{commandCharacters:number;detached:boolean;spawned:boolean;code:number|null;signal:NodeJS.Signals|null;error?:string;stdout:string;stderr:string}={commandCharacters:0,detached:false,spawned:false,code:null,signal:null,stdout:'',stderr:''}; + await launchTerminalCommand({platform:'win32',env:{...process.env,NODE_OPTIONS:nodeOptions,RADIOCLI_ALARM_TERMINAL:'win32:console',RADIOCLI_HOME:home,RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:[cli,output,...args],closeOnExit:true,spawn:(command,args,options)=>{ + diagnostic.detached=Boolean(options?.detached); + const environment={...options?.env};const handoff=environment.RADIOCLI_WINDOWS_CONSOLE_COMMAND;expect(handoff).toBeDefined(); + const inner=JSON.parse(handoff!) as {command:string;args:string[]};const index=inner.args.indexOf('-EncodedCommand')+1;expect(index).toBeGreaterThan(0); + const script=Buffer.from(inner.args[index]!,'base64').toString('utf16le'); + // Observe PowerShell startup without waiting in the outer launcher or + // redirecting the console. Preserve the invocation's existing exit logic. + const path=Buffer.from(powershellStages,'utf8').toString('base64'); + const prefix=`$radiocliProbe=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${path}'));[IO.File]::AppendAllText($radiocliProbe,"powershell-start $PID\n");trap{[IO.File]::AppendAllText($radiocliProbe,([string]$_.Exception.Message)+"\n");break};`; + const observed=prefix+script.replace('exit $LASTEXITCODE','[IO.File]::AppendAllText($radiocliProbe,"native-exit $LASTEXITCODE\n");exit $LASTEXITCODE'); + inner.args[index]=Buffer.from(observed,'utf16le').toString('base64');environment.RADIOCLI_WINDOWS_CONSOLE_COMMAND=JSON.stringify(inner); + const outerArgs=[...args];const outerIndex=outerArgs.indexOf('-EncodedCommand')+1;expect(outerIndex).toBeGreaterThan(0); + const outer=Buffer.from(outerArgs[outerIndex]!,'base64').toString('utf16le'); + const outerMarker=`[IO.File]::AppendAllText([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${path}')),"powershell-outer-start $PID\n");`; + outerArgs[outerIndex]=Buffer.from(outerMarker+outer,'utf16le').toString('base64'); + diagnostic.commandCharacters=[command,...outerArgs].reduce((length,value)=>length+value.length+3,1); + // Capture only the outer launcher; CreateProcessW still creates the child + // with its own unredirected console handles. Never log inherited env. + const child=spawn(command,outerArgs,{...options,env:environment,stdio:['ignore','pipe','pipe']}); + child.stdout?.on('data',chunk=>diagnostic.stdout=(diagnostic.stdout+String(chunk)).slice(-4_000)); + child.stderr?.on('data',chunk=>diagnostic.stderr=(diagnostic.stderr+String(chunk)).slice(-4_000)); + child.once('spawn',()=>diagnostic.spawned=true); + child.once('error',error=>diagnostic.error=error.message); + child.once('close',(code,signal)=>{diagnostic.code=code;diagnostic.signal=signal;}); + return child; + }}).catch(error=>{throw new Error(`Windows console launcher failed: ${JSON.stringify(diagnostic)}`,{cause:error});}); + const deadline=Date.now()+10_000; + // The result precedes Node's exit hooks and the final PowerShell diagnostic. + // Wait for both writers before removing the fixture on Windows. + const completed=()=>existsSync(output)&&existsSync(powershellStages)&&/native-exit 0\r?\n/.test(readFileSync(powershellStages,'utf8')); + while(!completed()&&Date.now()setTimeout(resolve,25)); + const nodeStages=existsSync(stages)?readFileSync(stages,'utf8').slice(-8_000):''; + const powershellStage=existsSync(powershellStages)?readFileSync(powershellStages,'utf8').slice(-4_000):''; + expect(completed(),`Windows console probe did not finish: ${JSON.stringify({...diagnostic,nodeStarted:existsSync(output+'.started'),nodeStages,powershellStage})}`).toBe(true); + expect(JSON.parse(readFileSync(output,'utf8'))).toEqual({stdin:true,stdout:true,stderr:true,args,home,mpv}); + },15_000); + it.each([{platform:'linux' as const,terminal:'linux:/tools/kitty'},{platform:'darwin' as const,terminal:'darwin:wezterm'}])('preserves configured players and network policy through an existing $terminal server',({platform,terminal})=>{ + const configured={RADIOCLI_MPV_PATH:"/Players % ! & ' 单播/mpv",RADIOCLI_FFPLAY_PATH:'/Players A/ffplay',RADIOCLI_VLC_PATH:'/Players A/vlc',RADIOCLI_FFMPEG_PATH:'/Players A/ffmpeg',RADIOCLI_OFFLINE:'0',RADIOCLI_LOW_BANDWIDTH:'true'}; + const script=`process.stdout.write(JSON.stringify(Object.fromEntries(${JSON.stringify(Object.keys(configured))}.map(key=>[key,process.env[key]]))))`; + const plan=createTerminalLaunch({platform,env:{...configured,DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:terminal,HTTPS_PROXY:'https://private-password@proxy.invalid'},nodePath:process.execPath,args:['-e',script],resolve}); + const args=plan.args.slice(plan.args.indexOf(process.execPath)+1); + const output=execFileSync(process.execPath,args,{encoding:'utf8',env:{...process.env,...Object.fromEntries(Object.keys(configured).map(key=>[key,'stale-server-value']))}}); + expect(JSON.parse(output)).toEqual(configured); + expect(JSON.parse(Buffer.from(args.at(-1)!,'base64url').toString('utf8')).environment).toEqual(configured); + }); + it.skipIf(process.platform==='win32')('passes configured player paths literally through Apple Terminal shell commands',()=>{ + const mpv="/Players $ % ! & ' 单播/mpv"; + const plan=createTerminalLaunch({platform:'darwin',env:{RADIOCLI_MPV_PATH:mpv},nodePath:process.execPath,args:['-e','process.stdout.write(process.env.RADIOCLI_MPV_PATH)'],resolve}); + expect(execFileSync('/bin/sh',['-c',plan.args.at(-1)!],{encoding:'utf8',env:{...process.env,RADIOCLI_MPV_PATH:'stale'}})).toBe(mpv); + }); + it.each(['darwin','linux','win32'] as const)('rejects control characters in approved %s terminal environment values',platform=>{expect(()=>createTerminalLaunch({platform,env:{DISPLAY:':0',RADIOCLI_MPV_PATH:'/tools/mpv\nInjected=value'},nodePath:'/node',args:['/cli.js'],resolve})).toThrow(/RADIOCLI_MPV_PATH.*control/i);}); + it('uses the same data-home bootstrap without assuming /usr/bin/env exists',()=>{const home="/Data A/O'Brien 单播";const plan=createTerminalLaunch({platform:'linux',env:{DISPLAY:':0',RADIOCLI_ALARM_TERMINAL:'linux:/tools/kitty',RADIOCLI_HOME:home},nodePath:process.execPath,args:['-e','process.stdout.write(process.env.RADIOCLI_HOME)'],resolve});expect(plan.args).not.toContain('/usr/bin/env');expect(execFileSync(process.execPath,plan.args.slice(2),{encoding:'utf8'})).toBe(home);}); + it('fails before spawning when Windows has no runnable PowerShell',()=>{expect(()=>createTerminalLaunch({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve:()=>undefined})).toThrow(/PowerShell.*unavailable/i);}); +}); + +describe('terminal launcher acceptance',()=>{ + it('rejects a launcher that exits unsuccessfully immediately after spawn',async()=>{const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();const result=waitForLaunch(child);child.emit('spawn');child.emit('close',1);await expect(result).rejects.toThrow(/launcher.*exit.*1/i);}); + it('accepts a live launcher without claiming application readiness',async()=>{vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();const result=waitForLaunch(child);child.emit('spawn');await vi.advanceTimersByTimeAsync(100);await expect(result).resolves.toBeUndefined();expect(child.unref).toHaveBeenCalledOnce();}); + it('bounds a launcher that never reports process startup',async()=>{vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;const result=expect(waitForLaunch(child)).rejects.toThrow(/did not report process startup/i);await vi.advanceTimersByTimeAsync(3_000);await result;}); + it('keeps a transient console bootstrap referenced until it exits',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const launch=vi.fn(()=>child); + const result=launchTerminalCommand({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve,spawn:launch});let settled=false;void result.then(()=>settled=true); + expect(launch).toHaveBeenCalledWith('powershell.exe',expect.any(Array),expect.objectContaining({detached:false,stdio:'ignore',windowsHide:true})); + child.emit('spawn');await vi.advanceTimersByTimeAsync(100); + expect(settled).toBe(false);expect(child.unref).not.toHaveBeenCalled(); + child.emit('close',0);await expect(result).resolves.toBe('win32:console');expect(child.kill).not.toHaveBeenCalled(); + }); + it('rejects a transient bootstrap failure after the ordinary launcher grace period',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const result=waitForLaunch(child,{waitForExit:true}).then(()=>undefined,error=>error as Error); + child.emit('spawn');await vi.advanceTimersByTimeAsync(250);child.emit('close',7); + expect(await result).toMatchObject({message:expect.stringMatching(/launcher.*exit.*7/i)});expect(child.kill).not.toHaveBeenCalled(); + }); + it('bounds cleanup when a transient bootstrap never confirms its exit',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const result=waitForLaunch(child,{waitForExit:true}).then(()=>undefined,error=>error as Error); + child.emit('spawn');await vi.advanceTimersByTimeAsync(9_999);expect(child.kill).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(child.kill).toHaveBeenCalledWith('SIGTERM');expect(child.unref).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_000); + expect(await result).toMatchObject({message:expect.stringMatching(/bootstrap.*did not complete/i)});expect(child.kill).toHaveBeenCalledTimes(2);expect(child.kill).toHaveBeenLastCalledWith('SIGKILL');expect(child.unref).toHaveBeenCalledOnce(); + }); + it('absorbs late native errors after accepting a detached application',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn(); + const result=waitForLaunch(child);child.emit('spawn');await vi.advanceTimersByTimeAsync(100);await result; + expect(()=>{child.emit('error',new Error('late native error'));child.emit('error',new Error('another native error'));}).not.toThrow(); + }); +}); diff --git a/src/platform/terminals.ts b/src/platform/terminals.ts new file mode 100644 index 0000000..a68fa01 --- /dev/null +++ b/src/platform/terminals.ts @@ -0,0 +1,182 @@ +import {spawn,type ChildProcess,type SpawnOptions} from 'node:child_process'; +import {posix,win32} from 'node:path'; +import {resolveCommandDetails} from './executables.js'; +import {identifyPlatform,nativeAdapters} from './runtime.js'; +import {powershellCommand} from './shell.js'; +import {launchEnvironment, nodeLaunchCommand, waitForLaunch} from './launch-command.js'; + +export type TerminalResolver=(command:string)=>string|undefined; +type TerminalSpawn=(command:string,args:readonly string[],options?:SpawnOptions)=>ChildProcess; +export type TerminalOptions={platform?:NodeJS.Platform;env?:NodeJS.ProcessEnv;resolve?:TerminalResolver;spawn?:TerminalSpawn}; +type TerminalCommand=TerminalOptions&{nodePath:string;args:readonly string[];title?:string;closeOnExit?:boolean}; +type TerminalLaunch={terminal:string;command:string;args:string[];environment?:Record}; + +type UnixTerminal={name:string;args:string[];display?:'x11'|'wayland';shell?:boolean}; +const unixTerminals:UnixTerminal[]=[ + {name:'ghostty',args:['-e']},{name:'wezterm',args:['start','--always-new-process','--']}, + {name:'kitty',args:['-e']},{name:'gnome-terminal',args:['--']},{name:'konsole',args:['-e']}, + {name:'xfce4-terminal',args:['-x']},{name:'x-terminal-emulator',args:['-e'],shell:true}, + {name:'alacritty',args:['-e']},{name:'foot',args:['--'],display:'wayland'}, + {name:'mate-terminal',args:['-x']},{name:'qterminal',args:['-e'],shell:true}, + {name:'terminator',args:['-x']},{name:'tilix',args:['-e']}, + {name:'xterm',args:['-e'],display:'x11'},{name:'uxterm',args:['-e'],display:'x11'} +]; +const macTerminals=['darwin:apple-terminal','darwin:iterm','darwin:wezterm','darwin:ghostty','darwin:kitty']; + +export function detectGraphicalTerminal(platform:NodeJS.Platform=process.platform,env:NodeJS.ProcessEnv=process.env,resolve:TerminalResolver=resolver(platform,env)):string{ + const host=identifyPlatform({platform,env});const adapter=nativeAdapters(host).terminal; + const unsupported=`${host.id==='unknown'?platform:host.id}:unsupported`; + const configured=env.RADIOCLI_ALARM_TERMINAL?.trim(); + if(adapter==='macos'){ + if(configured&&macTerminals.includes(configured))return configured; + const program=`${env.TERM_PROGRAM??''} ${env.__CFBundleIdentifier??''}`.toLowerCase(); + for(const name of ['iterm','wezterm','ghostty','kitty'])if(program.includes(name))return`darwin:${name}`; + return'darwin:apple-terminal'; + } + if(adapter==='windows'){ + if(configured==='win32:windows-terminal'||configured==='win32:console')return configured; + return env.WT_SESSION?'win32:windows-terminal':'win32:console'; + } + if(adapter!=='unix'||!hasDisplay(env))return unsupported; + if(configured){ + const separator=configured.indexOf(':');const prefix=separator<0?'':configured.slice(0,separator); + if(prefix===host.id||prefix==='linux'){ + const path=resolveUnixTerminal(configured.slice(separator+1),env,resolve); + return path?`${prefix}:${path}`:unsupported; + } + } + const requested=env.TERMINAL?.trim()||env.TERM_PROGRAM?.trim(); + const selected=requested&&resolveUnixTerminal(requested,env,resolve); + if(selected)return`${host.id}:${selected}`; + for(const terminal of unixTerminals){const path=resolveUnixTerminal(terminal.name,env,resolve);if(path)return`${host.id}:${path}`;} + return unsupported; +} + +export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ + const platform=options.platform??process.platform;const env=options.env??process.env;const resolve=options.resolve??resolver(platform,env); + const terminal=detectGraphicalTerminal(platform,env,resolve);const values=[options.nodePath,...options.args]; + if(values.some(value=>value.includes('\0')))throw new Error('Terminal command values cannot contain NUL bytes.'); + const environment=launchEnvironment(env,{platform}); + const direct=nodeLaunchCommand(options.nodePath,options.args,environment); + if(terminal.endsWith(':unsupported')){ + if(nativeAdapters(identifyPlatform({platform,env})).terminal==='unix'&&!hasDisplay(env))throw new Error('No graphical desktop session is available: DISPLAY and WAYLAND_DISPLAY are unset. Open radiocli manually for controls.'); + throw new Error('No supported installed graphical terminal was found. Open radiocli manually for controls.'); + } + if(terminal==='darwin:apple-terminal'||terminal==='darwin:iterm'){ + const command=`${direct.map(shellQuote).join(' ')}${options.closeOnExit?'; exit':''}`; + return{terminal,command:'/usr/bin/osascript',args:terminal==='darwin:apple-terminal'?appleTerminalScript(command):iTermScript(command)}; + } + if(terminal==='darwin:wezterm')return{terminal,command:'/usr/bin/open',args:['-na','WezTerm','--args','start','--always-new-process','--',...direct]}; + if(terminal==='darwin:ghostty')return{terminal,command:'/usr/bin/open',args:['-na','Ghostty','--args','-e',...direct]}; + if(terminal==='darwin:kitty')return{terminal,command:'/usr/bin/open',args:['-na','kitty','--args','--detach',...direct]}; + if(terminal.startsWith('win32:')){ + const systemRoot=env.SystemRoot??env.WINDIR; + const powershell=resolve('powershell.exe')??(systemRoot?resolve(win32.join(systemRoot,'System32','WindowsPowerShell','v1.0','powershell.exe')):undefined)??resolve('pwsh.exe'); + if(!powershell)throw new Error('PowerShell is unavailable; a RadioCLI terminal cannot be requested.'); + // PowerShell 5 does not preserve arbitrary native argv quoting. Only a + // fixed quote-free Node program and encoded JSON cross that boundary. + const args=powershellCommand(direct,{}, {keepOpen:!options.closeOnExit}); + if(terminal==='win32:console')return{terminal,command:powershell,...newWindowsConsole(powershell,args)}; + const wt=resolve('wt.exe')??(env.LOCALAPPDATA?resolve(win32.join(env.LOCALAPPDATA,'Microsoft','WindowsApps','wt.exe')):undefined); + if(!wt)throw new Error('Windows Terminal is unavailable; the saved terminal cannot be requested.'); + return{terminal,command:wt,args:['-w','new','new-tab','--title',options.title??'RadioCLI',powershell,...args]}; + } + const executable=terminal.slice(terminal.indexOf(':')+1);const spec=unixTerminals.find(item=>item.name===posix.basename(executable)); + if(!spec)throw new Error('The saved graphical terminal is unsupported.'); + // QTerminal reparses only its first -e argument, then appends argv literally. + // The alternatives alias can select QTerminal too, so both use the fixed + // POSIX shell boundary. This also preserves argv for ordinary -e terminals. + if(spec.shell){const sh=resolve('/bin/sh');if(!sh)throw new Error('The POSIX shell required by this terminal is unavailable.');return{terminal,command:executable,args:[...spec.args,sh,'-c',direct.map(shellQuote).join(' ')]};} + return{terminal,command:executable,args:[...spec.args,...direct]}; +} + +/** Request acceptance is separate from the caller's TUI/session verification. */ +export async function launchTerminalCommand(options:TerminalCommand):Promise{ + const plan=createTerminalLaunch(options); + // PowerShell can exit without executing its script under DETACHED_PROCESS. + // Keep this short-lived bootstrap attached until it creates the independent + // console. The new console, not its bootstrap, owns the interactive handles. + // https://github.com/nodejs/node/issues/51018 + const consoleBootstrap=plan.terminal==='win32:console'; + const child=(options.spawn??spawn)(plan.command,plan.args,{env:{...(options.env??process.env),...plan.environment},detached:!consoleBootstrap,stdio:'ignore',windowsHide:consoleBootstrap}); + await waitForLaunch(child,{waitForExit:consoleBootstrap});return plan.terminal; +} + +function resolver(platform:NodeJS.Platform,env:NodeJS.ProcessEnv):TerminalResolver{return command=>resolveCommandDetails(command,{platform,env}).path??undefined;} +function hasDisplay(env:NodeJS.ProcessEnv){return Boolean(env.DISPLAY?.trim()||env.WAYLAND_DISPLAY?.trim());} +function resolveUnixTerminal(input:string,env:NodeJS.ProcessEnv,resolve:TerminalResolver):string|undefined{ + const spec=unixTerminals.find(item=>item.name===posix.basename(input).toLowerCase()); + if(!spec||spec.display==='x11'&&!env.DISPLAY?.trim()||spec.display==='wayland'&&!env.WAYLAND_DISPLAY?.trim())return undefined; + const path=resolve(input);return path&&unixTerminals.some(item=>item.name===posix.basename(path))?path:undefined; +} +function shellQuote(value:string):string{return`'${value.replaceAll("'",`'\\''`)}'`;} +function newWindowsConsole(powershell: string, args: readonly string[]): {args: string[]; environment: Record} { + const key = 'RADIOCLI_WINDOWS_CONSOLE_COMMAND'; + // The transient launcher has NUL stdio. CreateProcessW explicitly creates + // new console buffers without inheriting those handles; STARTF_USESTDHANDLES + // must stay unset. ShellExecute-based Start-Process does not provide this + // handle contract. Only fixed flags and encoded data enter the argument string. + // https://learn.microsoft.com/en-us/windows/console/creation-of-a-console + // https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw + const native = String.raw` +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +public static class RadioCliConsole { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo { + public uint cb; + public string lpReserved, lpDesktop, lpTitle; + public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags; + public short wShowWindow, cbReserved2; + public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError; + } + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation { + public IntPtr hProcess, hThread; + public uint dwProcessId, dwThreadId; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW(string application, StringBuilder commandLine, + IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startup, + out ProcessInformation process); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + public static void Launch(string application, string arguments) { + var commandLine = new StringBuilder("\"" + application + "\" " + arguments); + if (commandLine.Length >= 32767) throw new ArgumentException("The Windows console command exceeds the native command-line limit."); + var startup = new StartupInfo(); + startup.cb = (uint)Marshal.SizeOf(typeof(StartupInfo)); + startup.dwFlags = 0x00000001; // STARTF_USESHOWWINDOW only; no inherited standard handles. + startup.wShowWindow = 1; // SW_SHOWNORMAL + ProcessInformation process; + if (!CreateProcessW(application, commandLine, IntPtr.Zero, IntPtr.Zero, false, + 0x00000010, IntPtr.Zero, null, ref startup, out process)) { // CREATE_NEW_CONSOLE + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + } +}`; + const script = [ + "$ErrorActionPreference='Stop'", + "$ProgressPreference='SilentlyContinue'", + `$radiocliConsole=[Environment]::GetEnvironmentVariable('${key}','Process') | ConvertFrom-Json`, + `[Environment]::SetEnvironmentVariable('${key}',$null,'Process')`, + `Add-Type -TypeDefinition '${native.replaceAll("'", "''")}'`, + "[RadioCliConsole]::Launch([string]$radiocliConsole.command, [string]::Join(' ', [string[]]$radiocliConsole.args))" + ].join(';'); + // Only this short-lived outer process needs the handoff. Encoding the inner + // PowerShell command again exceeds Windows' command-line limit for ordinary + // deep install paths. Clear the private variable before creating the console. + return { + args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], + environment: {[key]: JSON.stringify({command: powershell, args})} + }; +} +function appleTerminalScript(command:string):string[]{return['-e','on run argv','-e','tell application "Terminal"','-e','activate','-e','do script (item 1 of argv)','-e','end tell','-e','end run',command];} +function iTermScript(command:string):string[]{return['-e','on run argv','-e','tell application "iTerm"','-e','activate','-e','set w to (create window with default profile)','-e','tell current session of w to write text (item 1 of argv)','-e','end tell','-e','end run',command];} diff --git a/src/player/airplay-discovery.test.ts b/src/player/airplay-discovery.test.ts index 01e4c56..e533728 100644 --- a/src/player/airplay-discovery.test.ts +++ b/src/player/airplay-discovery.test.ts @@ -1,7 +1,21 @@ -import {describe, expect, it} from 'vitest'; -import {parseRaopBrowseOutput, parseRaopLookupOutput} from './airplay-discovery.js'; +import * as childProcess from 'node:child_process'; +import {describe, expect, it, vi} from 'vitest'; +import * as commands from '../platform/executables.js'; +import {discoverAirPlayDevices, parseRaopBrowseOutput, parseRaopLookupOutput} from './airplay-discovery.js'; + +vi.mock('node:child_process', () => ({spawn: vi.fn(() => {throw new Error('discovery must remain offline');})})); describe('AirPlay RAOP discovery parsing', () => { + it('does not spawn multicast discovery in offline mode even when dns-sd exists', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const command = vi.spyOn(commands, 'commandExists').mockReturnValue(true); + const spawn = vi.mocked(childProcess.spawn); + try { + expect(await discoverAirPlayDevices({platform: 'darwin'})).toEqual([]); + expect(spawn).not.toHaveBeenCalled(); + } finally {spawn.mockClear(); command.mockRestore(); vi.unstubAllEnvs();} + }); + it('extracts RAOP instance names from dns-sd browse output', () => { expect( parseRaopBrowseOutput(` diff --git a/src/player/airplay-discovery.ts b/src/player/airplay-discovery.ts index 1e65a83..9d14005 100644 --- a/src/player/airplay-discovery.ts +++ b/src/player/airplay-discovery.ts @@ -2,7 +2,9 @@ import {spawn} from 'node:child_process'; import {lookup} from 'node:dns/promises'; import {networkInterfaces} from 'node:os'; import type {AirPlayDevice} from '../types.js'; -import {commandExists} from './command.js'; +import {commandExists} from '../platform/executables.js'; +import {networkPolicy} from '../platform/network.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; export type AirPlayDiscoveryOptions = { platform?: NodeJS.Platform; @@ -24,7 +26,7 @@ export async function discoverAirPlayDevices({ lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes }: AirPlayDiscoveryOptions = {}): Promise { - if (platform !== 'darwin' || !commandExists('dns-sd')) { + if (networkPolicy().offline || !nativeAdapters(identifyPlatform({platform})).airPlay || !commandExists('dns-sd')) { return []; } diff --git a/src/player/backend-install.test.ts b/src/player/backend-install.test.ts index cfeb095..3873ba4 100644 --- a/src/player/backend-install.test.ts +++ b/src/player/backend-install.test.ts @@ -1,25 +1,40 @@ import {describe, expect, it} from 'vitest'; -import {detectPlaybackBackends, mpvInstallCommand, playbackBackendCapabilities, playbackBackendLabel, playbackBackendStatusLines} from './backend-install.js'; +import {detectPlaybackBackends, isAirPlayPlatformSupported, playbackBackendInstallHint, playbackBackendCapabilities, playbackBackendLabel, playbackBackendStatusLines} from './backend-install.js'; +import {mpvInstallCommand} from '../platform/packages.js'; + +const hintOptions = {hasCommand: () => true, getUid: () => 1000}; describe('playback backend install guidance', () => { + it('limits AirPlay output to macOS across every supported platform family', () => { + expect(isAirPlayPlatformSupported('darwin')).toBe(true); + for (const platform of ['win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const) { + expect(isAirPlayPlatformSupported(platform)).toBe(false); + } + }); it('uses Homebrew for macOS mpv guidance', () => { - expect(mpvInstallCommand('darwin')).toBe('brew install mpv'); + expect(mpvInstallCommand('darwin', '', {}, hintOptions)).toBe('brew install mpv'); }); it('uses apt for Debian-like Linux mpv guidance', () => { - expect(mpvInstallCommand('linux', 'ID=ubuntu\nID_LIKE=debian\n')).toBe('sudo apt install mpv'); + expect(mpvInstallCommand('linux', 'ID=ubuntu\nID_LIKE=debian\n', {}, hintOptions)).toBe('sudo apt-get install -y mpv'); }); it('uses dnf for Fedora-like Linux mpv guidance', () => { - expect(mpvInstallCommand('linux', 'ID=fedora\n')).toBe('sudo dnf install mpv'); + expect(mpvInstallCommand('linux', 'ID=fedora\n', {}, hintOptions)).toBe('sudo dnf install -y mpv'); }); it('uses winget for native Windows mpv guidance', () => { - expect(mpvInstallCommand('win32')).toBe('winget install --id shinchiro.mpv -e'); + expect(mpvInstallCommand('win32', '', {}, hintOptions)).toBe('winget install --id shinchiro.mpv -e --accept-package-agreements --accept-source-agreements'); + }); + + it.each([ + ['freebsd', 'pkg install -y mpv'], ['openbsd', 'sudo pkg_add -I mpv'], ['netbsd', 'pkgin -y install mpv'] + ] as const)('describes the explicit %s mpv installation path', (platform, command) => { + expect(mpvInstallCommand(platform, '', {}, hintOptions)).toContain(command); }); it('reports npm and native playback responsibilities separately', () => { - expect(playbackBackendStatusLines([], 'darwin')).toEqual([ + expect(playbackBackendStatusLines([], 'darwin', '', hintOptions)).toEqual([ 'playback=missing', 'playback_backend=none', 'controls=missing', @@ -32,20 +47,29 @@ describe('playback backend install guidance', () => { }); it('reports native Windows playback setup with winget commands', () => { - expect(playbackBackendStatusLines([], 'win32')).toEqual([ + expect(playbackBackendStatusLines([], 'win32', '', hintOptions)).toEqual([ 'playback=missing', 'playback_backend=none', 'controls=missing', 'controls_hint=install mpv for playback and controls', 'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg', 'guided_setup=radiocli setup', - 'install_mpv=winget install --id shinchiro.mpv -e', - 'optional_ffplay=winget install --id Gyan.FFmpeg -e' + 'install_mpv=winget install --id shinchiro.mpv -e --accept-package-agreements --accept-source-agreements', + 'optional_ffplay=winget install --id Gyan.FFmpeg -e --accept-package-agreements --accept-source-agreements' ]); }); + it.each([{backends: []}, {backends: ['ffplay']}, {backends: ['vlc']}, {backends: ['mpv']}])('keeps diagnostics on the available Scoop route for backends $backends', ({backends}) => { + const options = {hasCommand: (command: string) => command === 'scoop', getUid: () => undefined}; + const lines = playbackBackendStatusLines(backends, 'win32', '', options); + expect(lines).toContain('install_mpv=scoop install mpv (first run scoop bucket add extras)'); + expect(lines).toContain('optional_ffplay=scoop install ffmpeg'); + expect(lines.join(' ')).not.toContain('winget'); + expect(playbackBackendInstallHint('win32', '', options)).toContain('scoop install mpv'); + }); + it('prefers mpv when available', () => { - expect(playbackBackendStatusLines(['ffplay', 'mpv'], 'darwin')).toEqual([ + expect(playbackBackendStatusLines(['ffplay', 'mpv'], 'darwin', '', hintOptions)).toEqual([ 'playback=ready', 'playback_backend=mpv', 'controls=full', @@ -65,7 +89,7 @@ describe('playback backend install guidance', () => { }); it('reports AirPlay as limited output-only playback when it is the only backend', () => { - expect(playbackBackendStatusLines(['airplay'], 'darwin')).toEqual([ + expect(playbackBackendStatusLines(['airplay'], 'darwin', '', hintOptions)).toEqual([ 'playback=airplay-only', 'playback_backend=airplay', 'controls=airplay-limited', @@ -85,7 +109,7 @@ describe('playback backend install guidance', () => { }); it('reports ffplay as fallback playback with limited controls', () => { - expect(playbackBackendStatusLines(['ffplay'], 'darwin')).toEqual([ + expect(playbackBackendStatusLines(['ffplay'], 'darwin', '', hintOptions)).toEqual([ 'playback=fallback-only', 'playback_backend=ffplay', 'controls=limited', diff --git a/src/player/backend-install.ts b/src/player/backend-install.ts index 2163333..7a4e34b 100644 --- a/src/player/backend-install.ts +++ b/src/player/backend-install.ts @@ -1,5 +1,6 @@ -import {existsSync, readFileSync} from 'node:fs'; -import {commandExists} from './command.js'; +import {ffplayInstallCommand, mpvInstallCommand, type PackageHintOptions} from '../platform/packages.js'; +import {identifyPlatform, nativeAdapters, readLinuxOsRelease} from '../platform/runtime.js'; +import {commandExists} from '../platform/executables.js'; import {airPlaySenderHealth} from './airplay-sender-health.js'; type PlaybackBackendDetectionOptions = { @@ -10,6 +11,11 @@ type PlaybackBackendDetectionOptions = { export const ffplayLimitedControlsMessage = 'ffplay fallback has limited controls. Install mpv for pause, mute, volume, and media keys.'; export const vlcLimitedControlsMessage = 'VLC fallback has limited controls. Install mpv for pause, mute, volume, and media keys.'; +export const airPlayMacOSOnlyMessage = 'AirPlay output is available only on macOS and is not supported on this operating system.'; + +export function isAirPlayPlatformSupported(platform: NodeJS.Platform = process.platform): boolean { + return nativeAdapters(identifyPlatform({platform})).airPlay; +} export type PlaybackBackendCapabilities = { backend: string; @@ -30,7 +36,7 @@ export function detectPlaybackBackends({ backends.push('vlc'); } - if (platform === 'darwin' && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) { + if (isAirPlayPlatformSupported(platform) && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) { backends.push('airplay'); } @@ -102,22 +108,24 @@ export function playbackBackendLabel(backend: string | null | undefined): string export function playbackBackendInstallHint( platform: NodeJS.Platform = process.platform, - osRelease = readLinuxOsRelease() + osRelease = readLinuxOsRelease(platform), + options: PackageHintOptions = {} ): string { - return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease)}), then run radiocli doctor.`; + return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease, process.env, options)}), then run radiocli doctor.`; } export function playbackBackendStatusLines( backends: string[], platform: NodeJS.Platform = process.platform, - osRelease = readLinuxOsRelease() + osRelease = readLinuxOsRelease(platform), + options: PackageHintOptions = {} ): string[] { const backendSet = new Set(backends); const lines = [ 'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg', 'guided_setup=radiocli setup', - `install_mpv=${mpvInstallCommand(platform, osRelease)}`, - `optional_ffplay=${ffplayInstallCommand(platform, osRelease)}` + `install_mpv=${mpvInstallCommand(platform, osRelease, process.env, options)}`, + `optional_ffplay=${ffplayInstallCommand(platform, osRelease, process.env, options)}` ]; if (backendSet.has('mpv')) { @@ -167,112 +175,3 @@ export function playbackBackendStatusLines( ...lines ]; } - -export function mpvInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { - if (platform === 'darwin') { - return 'brew install mpv'; - } - - if (platform === 'win32') { - return 'winget install --id shinchiro.mpv -e'; - } - - if (platform !== 'linux') { - return 'install mpv with your system package manager'; - } - - const ids = linuxReleaseIds(osRelease); - if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { - return 'sudo apt install mpv'; - } - - if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { - return 'sudo dnf install mpv'; - } - - if (hasAny(ids, ['arch', 'manjaro'])) { - return 'sudo pacman -S mpv'; - } - - if (hasAny(ids, ['alpine'])) { - return 'sudo apk add mpv'; - } - - if (hasAny(ids, ['opensuse', 'suse'])) { - return 'sudo zypper install mpv'; - } - - return 'install mpv with your system package manager'; -} - -function ffplayInstallCommand(platform: NodeJS.Platform = process.platform, osRelease = readLinuxOsRelease()): string { - if (platform === 'darwin') { - return 'brew install ffmpeg'; - } - - if (platform === 'win32') { - return 'winget install --id Gyan.FFmpeg -e'; - } - - if (platform !== 'linux') { - return 'install FFmpeg with your system package manager'; - } - - const ids = linuxReleaseIds(osRelease); - if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) { - return 'sudo apt install ffmpeg'; - } - - if (hasAny(ids, ['fedora', 'rhel', 'centos'])) { - return 'sudo dnf install ffmpeg'; - } - - if (hasAny(ids, ['arch', 'manjaro'])) { - return 'sudo pacman -S ffmpeg'; - } - - if (hasAny(ids, ['alpine'])) { - return 'sudo apk add ffmpeg'; - } - - if (hasAny(ids, ['opensuse', 'suse'])) { - return 'sudo zypper install ffmpeg'; - } - - return 'install FFmpeg with your system package manager'; -} - -function readLinuxOsRelease(): string { - if (process.platform !== 'linux' || !existsSync('/etc/os-release')) { - return ''; - } - - try { - return readFileSync('/etc/os-release', 'utf8'); - } catch { - return ''; - } -} - -function linuxReleaseIds(osRelease: string): Set { - const ids = new Set(); - for (const line of osRelease.split('\n')) { - const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); - if (!match) { - continue; - } - - for (const value of match[2]!.replaceAll('"', '').split(/\s+/)) { - const normalized = value.trim().toLowerCase(); - if (normalized) { - ids.add(normalized); - } - } - } - - return ids; -} - -function hasAny(values: Set, candidates: string[]): boolean { - return candidates.some(candidate => values.has(candidate)); -} diff --git a/src/player/command-diagnostics.ts b/src/player/command-diagnostics.ts index 4e0ff10..97b9f1d 100644 --- a/src/player/command-diagnostics.ts +++ b/src/player/command-diagnostics.ts @@ -1,5 +1,5 @@ import {spawnSync} from 'node:child_process'; -import {resolveCommandDetails, type CommandDiscovery} from './command.js'; +import {resolveCommandDetails, type CommandDiscovery} from '../platform/executables.js'; export type CommandDiagnostic = { path: string | null; @@ -23,7 +23,7 @@ type CommandExecutor = ( export function diagnoseCommand(command: string, overrides: Partial = {}): CommandDiagnostic { const resolution = (overrides.resolve ?? resolveCommandDetails)(command); if (!resolution.path) { - return {...resolution, launchable: false, version: null, error: 'not found'}; + return {...resolution, launchable: false, version: null, error: resolution.error ?? 'not found'}; } const execute: CommandExecutor = overrides.execute ?? ((path, args, options) => spawnSync(path, args, options)); diff --git a/src/player/mpv-ipc-client.test.ts b/src/player/mpv-ipc-client.test.ts index 56c5ce0..7fa6240 100644 --- a/src/player/mpv-ipc-client.test.ts +++ b/src/player/mpv-ipc-client.test.ts @@ -12,6 +12,26 @@ afterEach(async () => { }); describe('MpvIpcClient', () => { + it('preserves UTF-8 metadata across native socket packets', async () => { + const path = testIpcPath(); + const sockets = new Set(); + const server = createServer(socket => { + sockets.add(socket); + socket.on('error', () => undefined); + socket.once('data', request => { + const {request_id} = JSON.parse(String(request)) as {request_id: number}; + const response = Buffer.from(`${JSON.stringify({request_id, error: 'success', data: '日本 Café'})}\n`); + const cut = response.indexOf(Buffer.from('日')) + 1; + socket.write(response.subarray(0, cut)); + setTimeout(() => { if (!socket.destroyed) socket.write(response.subarray(cut)); }, 20); + }); + }); + await listen(server, path); + const client = new MpvIpcClient(path, 500); + cleanupTasks.push(() => closeTestIpc(client, server, sockets, path)); + await expect(client.query({command: ['get_property', 'media-title']})).resolves.toBe('日本 Café'); + }); + it('multiplexes concurrent requests over one socket and reconnects after a drop', async () => { const path = testIpcPath(); const sockets = new Set(); diff --git a/src/player/mpv-ipc-client.ts b/src/player/mpv-ipc-client.ts index f007331..dee76d0 100644 --- a/src/player/mpv-ipc-client.ts +++ b/src/player/mpv-ipc-client.ts @@ -122,7 +122,8 @@ export class MpvIpcClient { this.socket = socket; this.connectingSocket = null; this.buffer = ''; - socket.on('data', chunk => this.consume(chunk.toString('utf8'))); + socket.setEncoding('utf8'); + socket.on('data', chunk => this.consume(String(chunk))); socket.on('error', error => this.dropSocket(socket, error)); socket.on('close', () => this.dropSocket(socket, new Error('mpv IPC connection closed.'))); resolve(socket); diff --git a/src/player/player-controller.test.ts b/src/player/player-controller.test.ts index 6c43b12..054f09c 100644 --- a/src/player/player-controller.test.ts +++ b/src/player/player-controller.test.ts @@ -3,16 +3,16 @@ import {spawn} from 'node:child_process'; import {existsSync, unlinkSync} from 'node:fs'; import {createServer, type Server} from 'node:net'; import {afterEach, describe, expect, it, vi} from 'vitest'; -import {commandExists} from './command.js'; +import {commandExists} from '../platform/executables.js'; import {discoverAirPlayDevices} from './airplay-discovery.js'; -import {createMpvIpcPath, extractMpvTitle, isPlaybackOutputError, PlayerController} from './player-controller.js'; +import {extractMpvTitle, isPlaybackOutputError, PlayerController} from './player-controller.js'; import type {AirPlayDevice, AppSettings, Station} from '../types.js'; vi.mock('node:child_process', () => ({ spawn: vi.fn() })); -vi.mock('./command.js', () => ({ +vi.mock('../platform/executables.js', () => ({ commandExists: vi.fn(), resolveCommand: vi.fn() })); @@ -64,16 +64,6 @@ describe('extractMpvTitle', () => { }); }); -describe('createMpvIpcPath', () => { - it('uses a Unix socket path on POSIX platforms', () => { - expect(createMpvIpcPath('linux', 123, 456)).toMatch(/radiocli-123-456\.sock$/); - }); - - it('uses a Windows named pipe path on native Windows', () => { - expect(createMpvIpcPath('win32', 123, 456)).toBe('\\\\.\\pipe\\radiocli-123-456'); - }); -}); - describe('PlayerController lifecycle', () => { it('throws before spawning when no playback backend is available', async () => { commandExistsMock.mockReturnValue(false); @@ -88,7 +78,10 @@ describe('PlayerController lifecycle', () => { it('explains when the preferred AirPlay backend is unavailable', async () => { commandExistsMock.mockImplementation(command => command === 'mpv'); - const controller = new PlayerController(() => settings({preferredBackend: 'airplay'})); + const controller = new PlayerController( + () => settings({preferredBackend: 'airplay'}), + {platform: 'darwin', arch: 'arm64', env: {}} + ); await expect(controller.play(station(), 'https://streams.example.com/live.mp3')).rejects.toThrow( 'AirPlay is not ready on this install. Run radiocli doctor.' @@ -96,6 +89,21 @@ describe('PlayerController lifecycle', () => { expect(spawnMock).not.toHaveBeenCalled(); }); + it.each(['win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const)( + 'explains that AirPlay is macOS-only on %s', + async platform => { + const controller = new PlayerController( + () => settings({preferredBackend: 'airplay'}), + {platform, arch: 'x64', env: {}} + ); + + await expect(controller.play(station(), 'https://streams.example.com/live.mp3')).rejects.toThrow( + 'AirPlay output is available only on macOS and is not supported on this operating system.' + ); + expect(spawnMock).not.toHaveBeenCalled(); + } + ); + it('does not use AirPlay as the automatic fallback backend', async () => { const controller = new PlayerController(() => settings({preferredBackend: 'auto'})); setDetectedBackends(controller, ['airplay']); @@ -514,6 +522,100 @@ describe('PlayerController lifecycle', () => { await mpvServer.close(); }); + it('recovers an ARM Linux mpv audio initialization failure by trying ALSA first', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const failedChild = fakeChildProcess(); + const recoveredChild = fakeChildProcess(); + const mpv = {current: null as FakeMpvIpc | null}; + spawnMock + .mockReturnValueOnce(failedChild as never) + .mockImplementationOnce((_command, args) => { + mpv.current = fakeMpvIpc(mpvIpcPath(args)); + return recoveredChild as never; + }); + const controller = new PlayerController( + () => settings({preferredBackend: 'mpv'}), + {platform: 'linux', arch: 'arm64', env: {}} + ); + + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + failedChild.stderr.emit('data', '[ao] Failed to initialize audio driver pipewire\nCould not open/initialize audio device -> no sound.\n'); + failedChild.emit('exit', 2, null); + await playing; + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--ao=alsa,'); + expect(spawnMock.mock.calls[1]?.[1]).toContain('--ao=alsa,'); + expect(controller.getState()).toMatchObject({backend: 'mpv', state: 'playing', ready: true}); + + await controller.stop(); + await expectFakeMpv(mpv.current).close(); + }); + + it.each([ + ['macOS', {platform: 'darwin' as const, arch: 'arm64', env: {}}], + ['Windows', {platform: 'win32' as const, arch: 'arm64', env: {}}], + ['Linux x64', {platform: 'linux' as const, arch: 'x64', env: {}}], + ['explicit ARM Linux output', {platform: 'linux' as const, arch: 'arm64', env: {RADIOCLI_MPV_AUDIO_OUTPUT: 'pipewire'}}] + ])('does not override mpv audio selection on %s', async (_label, runtime) => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'}), runtime); + + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.stderr.emit('data', '[ao] Failed to initialize audio driver pipewire\nCould not open/initialize audio device -> no sound.\n'); + child.emit('exit', 2, null); + + await expect(playing).rejects.toMatchObject({ + name: 'PlaybackOutputError', + message: expect.stringContaining('Could not open/initialize audio device') + }); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('keeps a station-specific mpv failure eligible for auto-skip without exposing its URL', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController( + () => settings({preferredBackend: 'mpv'}), + {platform: 'linux', arch: 'arm64', env: {}} + ); + + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.stderr.emit('data', 'Failed to open https://listener:secret@streams.example.com/private?token=secret\n'); + child.emit('exit', 2, null); + const error = await playing.then(() => null, (reason: unknown) => reason); + + expect(error).toBeInstanceOf(Error); + expect(isPlaybackOutputError(error)).toBe(false); + expect((error as Error).message).toContain('[stream URL]'); + expect((error as Error).message).not.toContain('secret'); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + it('reports an OS-level player launch failure immediately as a non-skippable output error', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv', tuneTimeoutSeconds: 30})); + + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.emit('error', new Error('spawn mpv EACCES')); + + await expect(playing).rejects.toMatchObject({ + name: 'PlaybackOutputError', + message: expect.stringContaining('spawn mpv EACCES') + }); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(controller.getState()).toMatchObject({backend: 'mpv', state: 'error', ready: false}); + }); + it('syncs external mpv pause changes from macOS media controls', async () => { commandExistsMock.mockImplementation(command => command === 'mpv'); const child = fakeChildProcess(); diff --git a/src/player/player-controller.ts b/src/player/player-controller.ts index 24600e9..6824286 100644 --- a/src/player/player-controller.ts +++ b/src/player/player-controller.ts @@ -1,16 +1,16 @@ import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; import {existsSync, unlinkSync} from 'node:fs'; -import {tmpdir} from 'node:os'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {AirPlayDevice, AppSettings, IcyNowPlaying, PlaybackDiagnostics, PlaybackState, Station} from '../types.js'; -import {detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage} from './backend-install.js'; -import {resolveCommand} from './command.js'; +import {airPlayMacOSOnlyMessage, detectPlaybackBackends, ffplayLimitedControlsMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, vlcLimitedControlsMessage} from './backend-install.js'; +import {resolveCommand} from '../platform/executables.js'; import {discoverAirPlayDevices} from './airplay-discovery.js'; import {airPlaySenderHealth} from './airplay-sender-health.js'; import {encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage, type AirPlayWorkerCommand, type AirPlayWorkerEvent} from './airplay-worker-protocol.js'; import {safeMediaTarget, sanitizeTerminalText} from '../safety.js'; import {MpvIpcClient} from './mpv-ipc-client.js'; +import {mpvIpcPath} from '../platform/ipc.js'; export type PlayerEvent = (state: PlaybackState) => void; export type MetadataEvent = (metadata: IcyNowPlaying) => void; @@ -20,6 +20,21 @@ export type PlaybackControlResult = { }; const minAirPlayTuneTimeoutSeconds = 30; +const maxPlayerDiagnosticCharacters = 4096; + +type PlayerRuntime = { + platform: NodeJS.Platform; + arch: string; + env: NodeJS.ProcessEnv; +}; + +type PlayerExit = { + backend: 'mpv' | 'ffplay' | 'vlc' | 'airplay'; + code: number | null; + signal: NodeJS.Signals | null; + diagnostic: string; + spawnError?: boolean; +}; export class PlaybackOutputError extends Error { constructor(message: string) { @@ -62,8 +77,12 @@ export class PlayerController { private confirmedMpvVolume = 70; private mpvSessionId = 0; private stopPromise: Promise | null = null; + private lastPlayerExit: PlayerExit | null = null; - constructor(private readonly getSettings: () => AppSettings) {} + constructor( + private readonly getSettings: () => AppSettings, + private readonly runtime: PlayerRuntime = {platform: process.platform, arch: process.arch, env: process.env} + ) {} onChange(listener: PlayerEvent): () => void { this.listeners.add(listener); @@ -100,7 +119,7 @@ export class PlayerController { } refreshDetectedBackends(): string[] { - this.availableBackends = detectPlaybackBackends(); + this.availableBackends = detectPlaybackBackends({platform: this.runtime.platform}); return this.detectedBackends(); } @@ -199,7 +218,25 @@ export class PlayerController { }); if (backend === 'mpv') { this.playWithMpv(url, station.name); - await this.waitForReady(backend); + try { + await this.waitForReady(backend); + } catch (error) { + if (!this.shouldRetryMpvWithAlsa(error)) throw error; + await this.stop(); + this.backend = backend; + this.setState({ + backend, + state: 'loading', + message: `Opening ${station.name}`, + volume: this.getSettings().volume, + muted: false, + stationName: station.name, + streamUrl: url, + ready: false + }); + this.playWithMpv(url, station.name, 'alsa,'); + await this.waitForReady(backend); + } } else if (backend === 'ffplay') { this.playWithFfplay(url); await this.waitForReady(backend); @@ -387,7 +424,7 @@ export class PlayerController { } async refreshAirPlayDevices(): Promise { - this.availableAirPlayDevices = await discoverAirPlayDevices(); + this.availableAirPlayDevices = await discoverAirPlayDevices({platform: this.runtime.platform}); return [...this.availableAirPlayDevices]; } @@ -432,6 +469,9 @@ export class PlayerController { private playbackUnavailableMessage(): string { const preferred = this.getSettings().preferredBackend; if (preferred === 'airplay') { + if (!isAirPlayPlatformSupported(this.runtime.platform)) { + return airPlayMacOSOnlyMessage; + } return `AirPlay is not ready on this install. Run radiocli doctor. ${airPlaySenderHealth().message}`; } @@ -442,8 +482,8 @@ export class PlayerController { return `No playback backend found. ${playbackBackendInstallHint()}`; } - private playWithMpv(url: string, initialTitle: string): void { - this.ipcPath = createMpvIpcPath(); + private playWithMpv(url: string, initialTitle: string, recoveredAudioOutput?: string): void { + this.ipcPath = mpvIpcPath(); this.mpvIpcClient = new MpvIpcClient(this.ipcPath); this.mpvSessionId += 1; this.confirmedMpvVolume = clampVolume(this.getSettings().volume); @@ -453,9 +493,11 @@ export class PlayerController { resolveCommand('mpv') ?? 'mpv', [ '--no-video', - '--really-quiet', + '--msg-level=all=warn', '--force-window=no', - ...(process.env.RADIOCLI_MPV_AUDIO_OUTPUT ? [`--ao=${process.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] : []), + ...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT + ? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] + : []), `--force-media-title=${this.currentMpvMediaTitle}`, `--volume=${this.getSettings().volume}`, `--input-ipc-server=${this.ipcPath}`, @@ -730,27 +772,49 @@ export class PlayerController { private wireProcess(): void { const child = this.process; - if (!child) { + const backend = this.backend; + if (!child || !backend) { return; } + this.lastPlayerExit = null; + let diagnostic = ''; + child.stderr.on('data', chunk => { + diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk)); + }); + // Local players can write diagnostics indefinitely. Drain both pipes so a // full OS pipe buffer can never stall playback. child.stdout.resume?.(); child.stderr.resume?.(); child.on('error', error => { + diagnostic = appendPlayerDiagnostic(diagnostic, error.message); + if (this.process !== child) return; + this.lastPlayerExit = {backend, code: null, signal: null, diagnostic, spawnError: true}; + this.rejectPendingAirPlayReady(error); + this.rejectPendingAirPlayRetune(error); + if (backend === 'airplay') { + this.currentAirPlayDevice = null; + this.currentAirPlayDeviceId = null; + this.pendingAirPlayPasscode = null; + this.airPlaySessionEstablished = false; + } + this.process = null; + this.stopMpvPolling(); + this.cleanupIpc(); this.setState({ ...this.state, - backend: this.backend ?? 'none', + backend, state: 'error', message: error.message, ready: false }); }); - child.on('exit', code => { + child.on('exit', (code, signal) => { if (this.process === child) { + this.lastPlayerExit = {backend, code, signal, diagnostic}; this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.')); if (this.backend === 'airplay') { this.currentAirPlayDevice = null; @@ -888,11 +952,15 @@ export class PlayerController { while (Date.now() - started < timeoutMs) { if (!this.process) { - throw new Error('Player exited before the stream became ready.'); + throw this.playerExitedBeforeReady(backend); } if (backend === 'ffplay' || backend === 'vlc') { - await waitForStartupWindow(() => this.process, Math.min(500, timeoutMs)); + await waitForStartupWindow( + () => this.process, + Math.min(500, timeoutMs), + () => this.playerExitedBeforeReady(backend) + ); return; } @@ -914,6 +982,26 @@ export class PlayerController { throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`); } + private shouldRetryMpvWithAlsa(error: unknown): boolean { + return this.runtime.platform === 'linux' && + (this.runtime.arch === 'arm' || this.runtime.arch === 'arm64') && + !this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT && + isAudioOutputInitializationFailure(this.lastPlayerExit?.diagnostic ?? '') && + isPlaybackOutputError(error); + } + + private playerExitedBeforeReady(backend: 'mpv' | 'ffplay' | 'vlc'): Error { + const exit = this.lastPlayerExit?.backend === backend ? this.lastPlayerExit : null; + const reason = exit + ? exit.signal ? `signal ${exit.signal}` : exit.code === null ? 'without an exit code' : `code ${exit.code}` + : 'before its exit status was available'; + const diagnostic = concisePlayerDiagnostic(exit?.diagnostic ?? ''); + const message = `${backend} exited before the stream became ready (${reason})${diagnostic ? `: ${diagnostic}` : '.'}`; + return isBackendInitializationFailure(backend, exit, diagnostic) + ? new PlaybackOutputError(message) + : new Error(message); + } + private async hasMpvAudioStarted(): Promise { const [timePos, audioPts] = await Promise.all([ this.queryMpv({command: ['get_property', 'time-pos']}).catch(() => null), @@ -1040,18 +1128,6 @@ export class PlayerController { } } -export function createMpvIpcPath( - platform: NodeJS.Platform = process.platform, - pid = process.pid, - timestamp = Date.now() -): string { - if (platform === 'win32') { - return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`; - } - - return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`); -} - function airPlayWorkerPath(): string { const currentPath = fileURLToPath(import.meta.url); const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js'; @@ -1123,17 +1199,56 @@ export function extractMpvTitle(metadata: Record | null): string return undefined; } -async function waitForStartupWindow(getProcess: () => ChildProcessWithoutNullStreams | null, ms: number): Promise { +async function waitForStartupWindow( + getProcess: () => ChildProcessWithoutNullStreams | null, + ms: number, + exitedError: () => Error = () => new Error('Player exited before the stream became ready.') +): Promise { const started = Date.now(); while (Date.now() - started < ms) { if (!getProcess()) { - throw new Error('Player exited before the stream became ready.'); + throw exitedError(); } await delay(Math.min(100, ms - (Date.now() - started))); } } +function appendPlayerDiagnostic(current: string, chunk: string): string { + const combined = current + chunk; + return combined.length <= maxPlayerDiagnosticCharacters + ? combined + : combined.slice(-maxPlayerDiagnosticCharacters); +} + +function concisePlayerDiagnostic(value: string): string { + const lines = value + .split(/\r?\n/) + .map(line => sanitizeTerminalText(line.replace(/https?:\/\/\S+/gi, '[stream URL]'))) + .filter((line): line is string => Boolean(line)) + .slice(-3) + .join(' · ') + .slice(0, 700); + return lines; +} + +function isAudioOutputInitializationFailure(value: string): boolean { + return /audio output initialization failed|could not open\/initialize audio device|failed to initialize audio driver/i.test(value); +} + +function isBackendInitializationFailure( + backend: 'mpv' | 'ffplay' | 'vlc', + exit: PlayerExit | null, + diagnostic: string +): boolean { + if (isAudioOutputInitializationFailure(diagnostic)) return true; + if (exit?.spawnError) return true; + if (/unknown (?:command line )?option|error initializing|failed to (?:create|connect to) .*(?:socket|pipe)/i.test(diagnostic)) return true; + // mpv reserves status 1 for player initialization and invalid options. A + // stream that could not be played uses status 2 and remains skippable. + return backend === 'mpv' && exit?.code === 1; +} + function cleanMetadataTitle(value: string | undefined): string | undefined { const normalized = cleanMediaTitle(value); if (!normalized) { diff --git a/src/providers/cache.ts b/src/providers/cache.ts index 1788910..e2788aa 100644 --- a/src/providers/cache.ts +++ b/src/providers/cache.ts @@ -1,6 +1,6 @@ import {chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync} from 'node:fs'; import {createHash} from 'node:crypto'; -import {homedir} from 'node:os'; +import {platformPaths} from '../platform/paths.js'; import {join} from 'node:path'; type CacheEnvelope = { @@ -116,37 +116,8 @@ export class ProviderCache { } function defaultProviderCachePath(): string { - if (process.env.RADIOCLI_HOME) { - return join(process.env.RADIOCLI_HOME, 'radiocli-cache.json'); - } - - if (process.env.RADIO_ATLAS_HOME) { - return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas-cache.json'); - } - - const currentPath = currentDefaultProviderCachePath(); - const legacyPath = legacyDefaultProviderCachePath(); - return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath; -} - -function currentDefaultProviderCachePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Caches', 'radiocli', 'radiocli-cache.json'); - } - - if (process.platform === 'win32') { - return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'radiocli-cache.json'); - } - - return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radiocli', 'radiocli-cache.json'); -} - -function legacyDefaultProviderCachePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas-cache.json'); - } - - return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radio-atlas', 'radio-atlas-cache.json'); + const {cache, legacyCache} = platformPaths(); + return existsSync(cache) || !existsSync(legacyCache) ? cache : legacyCache; } function writeJsonAtomically(filePath: string, value: unknown): void { @@ -163,7 +134,7 @@ function writeJsonAtomically(filePath: string, value: unknown): void { } } -export function backupBadFile(filePath: string): void { +function backupBadFile(filePath: string): void { if (!existsSync(filePath)) { return; } diff --git a/src/providers/radio-browser.test.ts b/src/providers/radio-browser.test.ts index 7b198c3..e7ac7c3 100644 --- a/src/providers/radio-browser.test.ts +++ b/src/providers/radio-browser.test.ts @@ -1,6 +1,7 @@ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; +import {createServer} from 'node:http'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {ProviderCache} from './cache.js'; import {RadioBrowserProvider} from './radio-browser.js'; @@ -10,6 +11,8 @@ const roots: string[] = []; describe('RadioBrowserProvider', () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); for (const root of roots.splice(0)) { rmSync(root, {recursive: true, force: true}); @@ -172,6 +175,79 @@ describe('RadioBrowserProvider', () => { expect(fetch).not.toHaveBeenCalled(); }); + it('discovers the current Radio Browser servers and ignores unsafe or non-HTTPS SRV records', async () => { + const resolveSrv = vi.fn(async () => [ + {name: 'de1.api.radio-browser.info', port: 443, priority: 1, weight: 1}, + {name: 'DE1.api.radio-browser.info.', port: 443, priority: 1, weight: 1}, + {name: 'wrong.example.com', port: 443, priority: 1, weight: 100}, + {name: 'insecure.api.radio-browser.info', port: 80, priority: 1, weight: 100} + ]); + const fetch = mockFetch(url => { + if (url.origin === 'https://all.api.radio-browser.info') { + return jsonResponse({message: 'unavailable'}, {status: 503, statusText: 'Service Unavailable'}); + } + return jsonResponse([{name: 'Japan', iso_3166_1: 'jp', stationcount: 500}]); + }); + const provider = new RadioBrowserProvider(undefined, cacheForTest(), {resolveSrv}); + + await expect(provider.countries(10)).resolves.toEqual([{name: 'Japan', code: 'JP', stationCount: 500}]); + expect(resolveSrv).toHaveBeenCalledOnce(); + expect(resolveSrv).toHaveBeenCalledWith('_api._tcp.radio-browser.info'); + expect(fetch.mock.calls.map(([input]) => new URL(String(input)).origin)).toEqual([ + 'https://all.api.radio-browser.info', + 'https://de1.api.radio-browser.info' + ]); + + fetch.mockClear(); + await provider.health(); + expect(resolveSrv).toHaveBeenCalledOnce(); + }); + + it('uses the canonical endpoint without waiting for DNS service discovery when it is healthy', async () => { + const resolveSrv = vi.fn(() => new Promise(() => {})); + const fetch = mockFetch(() => jsonResponse([{name: 'Japan', iso_3166_1: 'jp', stationcount: 500}])); + const provider = new RadioBrowserProvider(undefined, cacheForTest(), {resolveSrv}); + + await expect(provider.countries(10)).resolves.toEqual([{name: 'Japan', code: 'JP', stationCount: 500}]); + expect(fetch).toHaveBeenCalledOnce(); + expect(resolveSrv).not.toHaveBeenCalled(); + }); + + it('recovers an EAI_AGAIN lookup with the TLS-preserving system-resolver fallback', async () => { + const systemFetch = mockFetch(() => { + throw new TypeError('fetch failed', {cause: Object.assign(new Error('temporary failure in name resolution'), {code: 'EAI_AGAIN'})}); + }); + const fallbackFetch = vi.fn(async (_input: string | URL, _init?: RequestInit) => jsonResponse([ + {stationuuid: 'recovered-fm', name: 'Recovered FM', url: 'https://stream.example/live'} + ])); + const provider = new RadioBrowserProvider( + ['https://all.api.radio-browser.info'], + cacheForTest(), + {fetchWithSystemResolver: fallbackFetch, allowsDirectDnsFallback: () => true} + ); + + await expect(provider.popular(1)).resolves.toMatchObject([{id: 'recovered-fm', name: 'Recovered FM'}]); + expect(systemFetch).toHaveBeenCalledOnce(); + expect(fallbackFetch).toHaveBeenCalledOnce(); + expect(String(fallbackFetch.mock.calls[0]?.[0])).toContain('all.api.radio-browser.info/json/stations/search'); + }); + + it('does not use the direct DNS fallback when proxy routing forbids it', async () => { + const systemFetch = mockFetch(() => { + throw new TypeError('fetch failed', {cause: Object.assign(new Error('temporary failure in name resolution'), {code: 'EAI_AGAIN'})}); + }); + const fallbackFetch = vi.fn(async (_input: string | URL, _init?: RequestInit) => jsonResponse([])); + const provider = new RadioBrowserProvider( + ['https://all.api.radio-browser.info'], + cacheForTest(), + {fetchWithSystemResolver: fallbackFetch, allowsDirectDnsFallback: () => false} + ); + + await expect(provider.popular(1)).rejects.toThrow(/EAI_AGAIN|temporary failure|fetch failed/i); + expect(systemFetch).toHaveBeenCalledOnce(); + expect(fallbackFetch).not.toHaveBeenCalled(); + }); + it('returns stale cached data when every mirror is offline', async () => { const cacheFile = cacheFileForTest(); writeFileSync( @@ -211,6 +287,81 @@ describe('RadioBrowserProvider', () => { }); }); + it('uses stale cached startup results immediately in explicit offline mode', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const cache = cachedEntry('/json/stations/search?hidebroken=true&limit=1&order=clickcount&reverse=true', [ + {stationuuid: 'offline-fm', name: 'Offline FM', url: 'https://stream.example/live.mp3'} + ], Date.now() - 60 * 60 * 1000); + const fetch = mockFetch(() => {throw new Error('Network must not be attempted.');}); + const provider = new RadioBrowserProvider(['https://primary.example', 'https://secondary.example'], cache); + expect(await provider.popular(1)).toMatchObject([{id: 'offline-fm', streamUrl: 'https://stream.example/live.mp3'}]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reports an offline cache miss without trying a mirror', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = mockFetch(() => jsonResponse([])); + const provider = new RadioBrowserProvider(['https://primary.example'], cacheForTest()); + await expect(provider.popular(1)).rejects.toThrow(/offline.*cache|cache.*offline/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('does not vote, locate, or resolve externally when offline and retains a supplied stream URL', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = mockFetch(() => {throw new Error('Network must not be attempted.');}); + const provider = new RadioBrowserProvider(['https://primary.example'], cacheForTest()); + const station = {...radioBrowserStation('saved'), streamUrl: 'https://stream.example/live.mp3'}; + expect(await provider.vote(station)).toBe(false); + expect(await provider.detectLocation()).toBeNull(); + expect(await provider.resolve(station)).toEqual({url: station.streamUrl, name: station.name}); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('avoids an uncached atlas in low-bandwidth mode while ordinary directory requests still work', async () => { + vi.stubEnv('RADIOCLI_LOW_BANDWIDTH', '1'); + const fetch = mockFetch(() => jsonResponse([{stationuuid: 'small-list', name: 'Small list'}])); + const provider = new RadioBrowserProvider(['https://primary.example'], cacheForTest()); + await expect(provider.nearby({latitude: 48.8, longitude: 2.3, source: 'test'})).rejects.toThrow(/low.bandwidth.*cache|cache.*low.bandwidth/i); + expect(fetch).not.toHaveBeenCalled(); + expect(await provider.popular(1)).toMatchObject([{id: 'small-list'}]); + expect(fetch).toHaveBeenCalledOnce(); + expect(await provider.search('small', {limit: 1})).toMatchObject([{id: 'small-list'}]); + }); + + it('uses a bounded stale atlas in low-bandwidth mode without refreshing it', async () => { + vi.stubEnv('RADIOCLI_LOW_BANDWIDTH', '1'); + const cache = cachedEntry('/json/stations/search?has_geo_info=true&hidebroken=true&limit=100000&order=name', [ + {stationuuid: 'cached-nearby', name: 'Cached nearby', geo_lat: 48.8, geo_long: 2.3} + ], Date.now() - 7 * 60 * 60 * 1000); + const fetch = mockFetch(() => jsonResponse([])); + const provider = new RadioBrowserProvider(['https://primary.example'], cache); + expect(await provider.nearby({latitude: 48.8, longitude: 2.3, source: 'test'}, 1)).toMatchObject([{id: 'cached-nearby'}]); + expect(fetch).not.toHaveBeenCalled(); + }); + + for (const host of ['127.0.0.1', '::1']) { + it(`uses native fetch to read a real ${host} directory endpoint`, async context => { + let requests = 0; + const server = createServer((_request, response) => {requests += 1;response.end(JSON.stringify([{name: 'Japan', iso_3166_1: 'jp', stationcount: 1}]));}); + try { + await new Promise((resolve, reject) => {server.once('error', reject);server.listen(0, host, resolve);}); + } catch (error) { + if (host === '::1' && ['EAFNOSUPPORT', 'EPROTONOSUPPORT', 'EADDRNOTAVAIL'].includes((error as NodeJS.ErrnoException).code ?? '')) context.skip('The host does not provide IPv6 loopback.'); + throw error; + } + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No listener address.'); + const provider = new RadioBrowserProvider([`http://${host === '::1' ? '[::1]' : host}:${address.port}`], cacheForTest()); + expect(await provider.countries()).toEqual([{name: 'Japan', code: 'JP', stationCount: 1}]); + expect(requests).toBe(1); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } + }); + } + it('loads country stations with limit and offset pagination', async () => { const fetch = mockFetch(url => { expect(url.pathname).toBe('/json/stations/search'); @@ -372,6 +523,12 @@ function cacheForTest(): ProviderCache { return new ProviderCache(cacheFileForTest()); } +function cachedEntry(key: string, value: unknown, createdAt: number): ProviderCache { + const file = cacheFileForTest(); + writeFileSync(file, JSON.stringify({version: 1, entries: {[key]: {createdAt, value}}})); + return new ProviderCache(file); +} + function cacheFileForTest(): string { const root = mkdtempSync(join(tmpdir(), 'radiocli-provider-')); roots.push(root); diff --git a/src/providers/radio-browser.ts b/src/providers/radio-browser.ts index 0b1fe7a..7111313 100644 --- a/src/providers/radio-browser.ts +++ b/src/providers/radio-browser.ts @@ -1,8 +1,16 @@ +import type {SrvRecord} from 'node:dns'; +import {resolveSrv} from 'node:dns/promises'; import {z} from 'zod'; import type {Country, LocationGuess, ResolvedStream, SearchOptions, Station} from '../types.js'; import {ProviderCache} from './cache.js'; import {userAgent} from '../version.js'; import {safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText} from '../safety.js'; +import { + allowsDirectDnsFallback, + fetchHttpsWithSystemResolver, + networkPolicy, + withExternalResponse +} from '../platform/network.js'; const stationSchema = z.object({ stationuuid: z.string(), @@ -53,12 +61,23 @@ const locationSchema = z const geoAtlasLimit = 100_000; const geoAtlasMaxAgeMs = 6 * 60 * 60 * 1000; -// The atlas is much larger than ordinary directory responses. A single shared -// 20-second deadline used to give each of four mirrors roughly five seconds to -// connect, download, and parse up to 100k rows. Use a size-aware per-attempt -// budget while retaining an overall bound and stale-cache fallback. +// The atlas is much larger than ordinary directory responses. Give each live, +// discovered server enough time to connect, download, and parse up to 100k rows +// while retaining an overall bound and stale-cache fallback. const geoAtlasAttemptTimeoutMs = 30_000; const geoAtlasTotalTimeoutMs = 75_000; +const mirrorDiscoveryTimeoutMs = 750; +const radioBrowserService = '_api._tcp.radio-browser.info'; +const radioBrowserApiSuffix = '.api.radio-browser.info'; +const nameResolutionErrorCodes = new Set(['EAI_AGAIN', 'ENOTFOUND', 'ESERVFAIL', 'ETIMEOUT', 'EREFUSED']); + +type RadioBrowserRuntime = { + resolveSrv?: (hostname: string) => Promise; + fetchWithSystemResolver?: typeof fetchHttpsWithSystemResolver; + allowsDirectDnsFallback?: () => boolean; +}; + +const defaultRuntime: RadioBrowserRuntime = {}; class ProviderUnavailableError extends Error { constructor(message: string, cause?: Error) { @@ -75,12 +94,19 @@ export class RadioBrowserProvider { readonly id = 'radio-browser' as const; readonly label = 'Radio Browser'; private readonly baseUrls: string[]; + private readonly discoverMirrors: boolean; private activeBaseUrl: string; private geoAtlasPromise: Promise | null = null; private geoAtlasLoadedAt = 0; - - constructor(baseUrls = defaultRadioBrowserMirrors(), private readonly cache = new ProviderCache()) { - this.baseUrls = baseUrls.map(url => url.replace(/\/$/, '')); + private mirrorDiscoveryPromise: Promise | null = null; + + constructor( + baseUrls: string[] | undefined = undefined, + private readonly cache = new ProviderCache(), + private readonly runtime: RadioBrowserRuntime = defaultRuntime + ) { + this.discoverMirrors = baseUrls === undefined; + this.baseUrls = (baseUrls ?? defaultRadioBrowserMirrors()).map(url => url.replace(/\/$/, '')); this.activeBaseUrl = this.baseUrls[0] ?? 'https://all.api.radio-browser.info'; } @@ -260,35 +286,26 @@ export class RadioBrowserProvider { } async detectLocation(): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); try { - const response = await fetch('https://ipapi.co/json/', { - signal: controller.signal, - headers: {'User-Agent': userAgent()} + return await withExternalResponse('https://ipapi.co/json/', { + timeoutMs: 5000, + init: {headers: {'User-Agent': userAgent()}} + }, async response => { + if (!response.ok) return null; + const parsed = locationSchema.parse(await response.json()); + if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number') return null; + return { + city: parsed.city, + region: parsed.region, + country: parsed.country_name, + countryCode: parsed.country_code, + latitude: parsed.latitude, + longitude: parsed.longitude, + source: 'ipapi.co' + }; }); - if (!response.ok) { - return null; - } - - const parsed = locationSchema.parse(await response.json()); - if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number') { - return null; - } - - return { - city: parsed.city, - region: parsed.region, - country: parsed.country_name, - countryCode: parsed.country_code, - latitude: parsed.latitude, - longitude: parsed.longitude, - source: 'ipapi.co' - }; } catch { return null; - } finally { - clearTimeout(timeout); } } @@ -351,7 +368,8 @@ export class RadioBrowserProvider { { maxAgeMs: geoAtlasMaxAgeMs, timeoutMs: geoAtlasTotalTimeoutMs, - attemptTimeoutMs: geoAtlasAttemptTimeoutMs + attemptTimeoutMs: geoAtlasAttemptTimeoutMs, + cacheOnly: networkPolicy().lowBandwidth } ); @@ -361,7 +379,7 @@ export class RadioBrowserProvider { private async request( path: string, params: Record = {}, - options: {maxAgeMs?: number; timeoutMs?: number; attemptTimeoutMs?: number} = {} + options: {maxAgeMs?: number; timeoutMs?: number; attemptTimeoutMs?: number; cacheOnly?: boolean} = {} ): Promise { const cacheKey = buildCacheKey(path, params); if (options.maxAgeMs) { @@ -371,10 +389,20 @@ export class RadioBrowserProvider { } } + const policy = networkPolicy(); + if (policy.offline || options.cacheOnly) { + const stale = options.maxAgeMs ? this.cache.getStale(cacheKey) : null; + if (stale !== null) return stale; + throw new ProviderUnavailableError(policy.offline + ? `${this.label} is offline: no cached response is available.` + : `${this.label} is in low-bandwidth mode: no cached atlas is available. Disable RADIOCLI_LOW_BANDWIDTH to download it.`); + } + let lastError: Error | null = null; - const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl); const totalTimeoutMs = options.timeoutMs ?? 9000; const deadline = Date.now() + totalTimeoutMs; + const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl); + let discoveryAttempted = !this.discoverMirrors; for (let mirrorIndex = 0; mirrorIndex < mirrors.length; mirrorIndex += 1) { const baseUrl = mirrors[mirrorIndex]!; const remainingMs = deadline - Date.now(); @@ -387,13 +415,13 @@ export class RadioBrowserProvider { } try { - const mirrorsLeft = mirrors.length - mirrorIndex; + const mirrorsLeft = mirrors.length - mirrorIndex + (discoveryAttempted ? 0 : 1); const fairShareTimeoutMs = Math.max(1, Math.floor(remainingMs / mirrorsLeft)); const attemptTimeoutMs = Math.max( 1, Math.min(remainingMs, options.attemptTimeoutMs ?? fairShareTimeoutMs) ); - const value = await fetchJsonWithTimeout(url, attemptTimeoutMs); + const value = await fetchJsonWithTimeout(url, attemptTimeoutMs, this.runtime); this.activeBaseUrl = baseUrl; if (options.maxAgeMs) { try { @@ -407,6 +435,15 @@ export class RadioBrowserProvider { } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } + + if (!discoveryAttempted && mirrorIndex === mirrors.length - 1) { + discoveryAttempted = true; + const discoveryBudgetMs = Math.min(mirrorDiscoveryTimeoutMs, Math.max(1, deadline - Date.now())); + const discovered = await this.loadDiscoveredMirrors(discoveryBudgetMs); + for (const baseUrl of discovered) { + if (!mirrors.includes(baseUrl)) mirrors.push(baseUrl); + } + } } const stale = this.cache.getStale(cacheKey); @@ -419,10 +456,18 @@ export class RadioBrowserProvider { lastError ?? undefined ); } + + private async loadDiscoveredMirrors(timeoutMs: number): Promise { + this.mirrorDiscoveryPromise ??= settleWithin(discoverRadioBrowserMirrors(this.runtime), timeoutMs, []); + return this.mirrorDiscoveryPromise; + } } function describeRequestError(error: Error | null): string { if (!error) return 'request failed'; + const nestedCode = findErrorCode(error); + if (nestedCode === 'EAI_AGAIN') return 'temporary DNS lookup failed (EAI_AGAIN)'; + if (nestedCode && nameResolutionErrorCodes.has(nestedCode)) return `DNS lookup failed (${nestedCode})`; const cause = error.cause; if (cause && typeof cause === 'object') { const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : null; @@ -436,12 +481,7 @@ function describeRequestError(error: Error | null): string { } function defaultRadioBrowserMirrors(): string[] { - return [ - 'https://all.api.radio-browser.info', - 'https://de1.api.radio-browser.info', - 'https://nl1.api.radio-browser.info', - 'https://at1.api.radio-browser.info' - ]; + return ['https://all.api.radio-browser.info']; } function preferActiveMirror(baseUrls: string[], active: string): string[] { @@ -453,26 +493,96 @@ function buildCacheKey(path: string, params: Record): string { return `${path}?${new URLSearchParams(pairs).toString()}`; } -async function fetchJsonWithTimeout(url: URL, timeoutMs: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { - signal: controller.signal, - headers: { - 'User-Agent': userAgent(' (+https://radio-browser.info)'), - Accept: 'application/json' - } - }); +async function fetchJsonWithTimeout(url: URL, timeoutMs: number, runtime: RadioBrowserRuntime): Promise { + const startedAt = Date.now(); + const init = { + headers: { + 'User-Agent': userAgent(' (+https://radio-browser.info)'), + Accept: 'application/json' + } + } satisfies RequestInit; + const consume = async (response: Response): Promise => { if (!response.ok) { throw new Error(`Radio Browser request failed: ${response.status} ${response.statusText}`); } - - // Keep the abort timer alive while consuming the body as well as headers. return (await response.json()) as T; - } finally { - clearTimeout(timeout); + }; + + try { + return await withExternalResponse(url, {timeoutMs, init}, consume); + } catch (error) { + const remainingMs = timeoutMs - (Date.now() - startedAt); + if ( + remainingMs <= 0 || + !isNameResolutionError(error) || + !isRadioBrowserApiHostname(url.hostname) || + !(runtime.allowsDirectDnsFallback ?? allowsDirectDnsFallback)() + ) { + throw error; + } + + return withExternalResponse(url, { + timeoutMs: remainingMs, + init, + fetchImpl: runtime.fetchWithSystemResolver ?? fetchHttpsWithSystemResolver + }, consume); + } +} + +async function discoverRadioBrowserMirrors(runtime: RadioBrowserRuntime): Promise { + const records = await (runtime.resolveSrv ?? resolveSrv)(radioBrowserService); + return dedupeUrls( + records + .filter(record => record.port === 443) + .sort((a, b) => a.priority - b.priority || b.weight - a.weight || a.name.localeCompare(b.name)) + .flatMap(record => { + const hostname = record.name.replace(/\.$/, '').toLowerCase(); + return isRadioBrowserApiHostname(hostname) ? [`https://${hostname}`] : []; + }) + ); +} + +function isRadioBrowserApiHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return normalized === 'api.radio-browser.info' || normalized.endsWith(radioBrowserApiSuffix); +} + +function isNameResolutionError(error: unknown): boolean { + const code = findErrorCode(error); + return code !== null && nameResolutionErrorCodes.has(code); +} + +function findErrorCode(error: unknown): string | null { + const seen = new Set(); + const pending = [error]; + while (pending.length > 0) { + const value = pending.pop(); + if (!value || seen.has(value)) continue; + seen.add(value); + if (typeof value === 'object') { + if ('code' in value && typeof value.code === 'string') return value.code; + if ('cause' in value) pending.push(value.cause); + if (value instanceof AggregateError) pending.push(...value.errors); + } } + return null; +} + +function dedupeUrls(urls: string[]): string[] { + return [...new Set(urls)]; +} + +function settleWithin(promise: Promise, timeoutMs: number, fallback: T): Promise { + return new Promise(resolve => { + const timeout = setTimeout(() => resolve(fallback), timeoutMs); + promise.then(value => { + clearTimeout(timeout); + resolve(value); + }, () => { + clearTimeout(timeout); + resolve(fallback); + }); + }); } export function dedupeStations(stations: Station[]): Station[] { diff --git a/src/providers/radio-garden.test.ts b/src/providers/radio-garden.test.ts new file mode 100644 index 0000000..6e8612d --- /dev/null +++ b/src/providers/radio-garden.test.ts @@ -0,0 +1,58 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {RadioGardenProvider} from './radio-garden.js'; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('RadioGardenProvider networking', () => { + it('does not search or probe provider health when offline', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = vi.fn(async () => new Response('{"hits":[]}')); + vi.stubGlobal('fetch', fetch); + const provider = new RadioGardenProvider(); + await expect(provider.search('jazz')).rejects.toThrow(/offline/i); + expect(await provider.health()).toMatch(/offline/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('preserves supplied stream URLs when offline', async () => { + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetch = vi.fn(async () => new Response('{}')); + vi.stubGlobal('fetch', fetch); + const provider = new RadioGardenProvider(); + const station = {id: 'saved', provider: 'radio-garden' as const, name: 'Saved station', tags: [], streamUrl: 'https://stream.example/live.mp3'}; + expect(await provider.resolve(station)).toEqual({url: station.streamUrl, name: station.name}); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('aborts a search whose response body stalls after headers', async () => { + vi.useFakeTimers(); + let body!: ReadableStreamDefaultController; + vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({start(controller) {body = controller;}})))); + let error: Error | undefined; + const searching = new RadioGardenProvider().search('jazz').catch(value => {error = value as Error;}); + try { + await vi.advanceTimersByTimeAsync(9001); + expect(error?.message).toMatch(/timed out/i); + } finally { + body.error(new Error('test cleanup')); + await searching; + } + }); + + it('cancels an unused edge-protection response body', async () => { + const cancel = vi.fn(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({cancel}), {status: 403}))); + expect(await new RadioGardenProvider().health()).toBe('blocked by edge protection'); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('retains ordinary search behavior in low-bandwidth mode', async () => { + vi.stubEnv('RADIOCLI_LOW_BANDWIDTH', '1'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({hits: [{_source: {type: 'channel', title: 'Jazz FM', url: '/listen/jazz/one', subtitle: 'Japan'}}]})))); + expect(await new RadioGardenProvider().search('jazz')).toMatchObject([{id: 'one', name: 'Jazz FM'}]); + }); +}); diff --git a/src/providers/radio-garden.ts b/src/providers/radio-garden.ts index 1949404..9bb817b 100644 --- a/src/providers/radio-garden.ts +++ b/src/providers/radio-garden.ts @@ -1,5 +1,6 @@ import {z} from 'zod'; import type {ResolvedStream, SearchOptions, Station} from '../types.js'; +import {withExternalResponse} from '../platform/network.js'; const searchSchema = z .object({ @@ -34,14 +35,13 @@ export class RadioGardenProvider { async health(): Promise { try { - const response = await fetchWithTimeout(`${this.baseUrl}/geo`, 5000, { - headers: {'User-Agent': 'radiocli/0.1'} + return await withExternalResponse(`${this.baseUrl}/geo`, { + timeoutMs: 5000, + init: {headers: {'User-Agent': 'radiocli/0.1'}} + }, response => { + if (response.status === 403) return 'blocked by edge protection'; + return response.ok ? 'online' : `${response.status} ${response.statusText}`; }); - if (response.status === 403) { - return 'blocked by edge protection'; - } - - return response.ok ? 'online' : `${response.status} ${response.statusText}`; } catch (error) { return error instanceof Error ? error.message : 'unavailable'; } @@ -51,15 +51,13 @@ export class RadioGardenProvider { const url = new URL(`${this.baseUrl}/search`); url.searchParams.set('q', query); - const response = await fetchWithTimeout(url, 9000, { - headers: {'User-Agent': 'radiocli/0.1', Accept: 'application/json'} + const parsed = await withExternalResponse(url, { + timeoutMs: 9000, + init: {headers: {'User-Agent': 'radiocli/0.1', Accept: 'application/json'}} + }, async response => { + if (!response.ok) throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`); + return searchSchema.parse(await response.json()); }); - - if (!response.ok) { - throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`); - } - - const parsed = searchSchema.parse(await response.json()); return (parsed.hits ?? []) .map(hit => hit._source) .filter((source): source is NonNullable => source?.type === 'channel' && Boolean(source.url)) @@ -88,13 +86,3 @@ export class RadioGardenProvider { }; } } - -async function fetchWithTimeout(url: string | URL, timeoutMs: number, init: RequestInit = {}): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, {...init, signal: controller.signal}); - } finally { - clearTimeout(timeout); - } -} diff --git a/src/setup.test.ts b/src/setup.test.ts index 3af6a4d..b9dc72a 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -1,10 +1,85 @@ import {PassThrough} from 'node:stream'; import {describe, expect, it, vi} from 'vitest'; -import {createSetupPlan, detectPackageManager, parseSetupArgs, runSetup, type SetupComponent} from './setup.js'; +import {createSetupPlan, parseSetupArgs, runSetup, type SetupComponent} from './setup.js'; +import {detectPackageManager, ffplayInstallCommand, mpvInstallCommand, type SetupPackageManager} from './platform/packages.js'; const nothingInstalled: Record = {mpv: false, ffmpeg: false, vlc: false}; describe('RadioCLI setup', () => { + it.each([ + ['darwin', '', 'brew', 'brew', {}], + ['win32', '', 'winget', 'winget', {}], + ['win32', '', 'scoop', 'scoop', {}], + ['win32', '', 'choco', 'choco', {}], + ['linux', 'ID=ubuntu\nID_LIKE=debian', 'apt', 'apt', {}], + ['linux', 'ID=fedora', 'dnf', 'dnf', {}], + ['linux', 'ID=arch', 'pacman', 'pacman', {}], + ['linux', 'ID=alpine', 'apk', 'apk', {}], + ['linux', 'ID=opensuse', 'zypper', 'zypper', {}], + ['linux', 'ID=unknown', 'dnf', 'dnf', {}], + ['freebsd', '', 'pkg', 'pkg', {}], + ['openbsd', '', 'pkg_add', 'pkg_add', {}], + ['netbsd', '', 'pkgin', 'pkgin', {}], + ['sunos', '', 'pkgin', 'pkgin', {}], + ['haiku', '', 'pkgman', 'pkgman', {}], + ['android', '', 'termux-pkg', 'pkg', {TERMUX_VERSION: '0.118.3'}], + ['linux', 'ID=debian', 'termux-pkg', 'pkg', {TERMUX_VERSION: '0.118.3'}] + ] satisfies [NodeJS.Platform, string, SetupPackageManager, string, NodeJS.ProcessEnv][])('uses the selected %s/%s/%s setup recipes in playback hints', (platform, osRelease, manager, executable, env) => { + const hasCommand = (command: string): boolean => [executable, 'doas'].includes(command); + expect(detectPackageManager(platform, osRelease, hasCommand, env)).toBe(manager); + const plan = createSetupPlan({platform, osRelease, env, packageManager: manager, installed: nothingInstalled, + selected: ['mpv', 'ffmpeg'], elevation: 'doas'}); + const options = {hasCommand, getUid: () => 1000}; + const hints = {mpv: mpvInstallCommand(platform, osRelease, env, options), ffmpeg: ffplayInstallCommand(platform, osRelease, env, options)}; + for (const command of plan.commands) { + if (command.component === 'mpv' || command.component === 'ffmpeg') expect(hints[command.component]).toContain(command.display); + } + if (manager === 'termux-pkg') expect(hints.ffmpeg).toContain('x11-repo'); + if (manager === 'pkgin') expect(hints.ffmpeg).toContain('matching pkgsrc'); + if (manager === 'pkg') expect(hints.ffmpeg).toContain('SDL'); + if (manager === 'scoop') expect(hints.mpv).toContain('scoop bucket add extras'); + }); + + it('shows every Scoop prerequisite in the dry-run plan', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + await runSetup({platform: 'win32', env: {}, args: ['--dry-run', '--only=mpv'], input: new PassThrough(), output, + hasCommand: command => command === 'scoop'}); + expect(text).toContain('scoop bucket add extras'); + expect(text).toContain('scoop install mpv'); + expect(text).not.toContain('winget'); + }); + + it('prints an ASCII-safe setup plan in a dumb terminal without control sequences', async () => { + vi.stubEnv('TERM', 'dumb'); + const output = Object.assign(new PassThrough(), {isTTY: true}); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + try { + await runSetup({platform: 'linux', osRelease: 'ID=debian', args: ['--dry-run', '--only=mpv'], + input: new PassThrough(), output, hasCommand: command => command === 'apt'}); + expect(text).toContain('SETUP RECEIVER'); + expect(text).not.toMatch(/[^\x00-\x7f]/); + expect(text).not.toContain('\u001b'); + } finally {vi.unstubAllEnvs();} + }); + + it('caps setup colors at the terminal depth', async () => { + vi.stubEnv('TERM', 'xterm'); + vi.stubEnv('FORCE_COLOR', undefined); + vi.stubEnv('NO_COLOR', undefined); + const output = Object.assign(new PassThrough(), {isTTY: true, getColorDepth: () => 4}); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + try { + await runSetup({platform: 'linux', osRelease: 'ID=debian', args: ['--dry-run', '--only=mpv'], + input: new PassThrough(), output, hasCommand: command => command === 'apt'}); + expect(text).toContain('\u001b[32m'); + expect(text).not.toContain('38;2;'); + } finally {vi.unstubAllEnvs();} + }); + it('detects native package managers by platform and Linux family', () => { const available = new Set(['brew', 'winget', 'scoop', 'apt', 'dnf', 'pacman']); const hasCommand = (command: string): boolean => available.has(command); @@ -122,6 +197,172 @@ describe('RadioCLI setup', () => { expect(text).toContain('Agent integration: would be enabled and installed for detected MCP clients.'); }); + it.each([ + {platform: 'freebsd' as const, manager: 'pkg', uid: 0, commands: ['pkg'], expected: 'pkg install -y mpv'}, + {platform: 'openbsd' as const, manager: 'pkg_add', uid: 1000, commands: ['pkg_add', 'doas'], expected: 'doas pkg_add -I mpv'}, + {platform: 'linux' as const, manager: 'apk', uid: 0, commands: ['apk'], expected: 'apk add mpv'} + ])('executes the $manager privilege plan', async ({platform, manager, uid, commands, expected}) => { + const execute = vi.fn(async (_command: {display: string}) => undefined); + await runSetup({platform, osRelease: 'ID=alpine', args: ['--yes', '--only=mpv', `--package-manager=${manager}`], + input: new PassThrough(), output: new PassThrough(), hasCommand: command => commands.includes(command), + getUid: () => uid, runCommand: execute}); + expect(execute).toHaveBeenCalledOnce(); + expect(execute.mock.calls[0]?.[0]).toMatchObject({display: expected}); + }); + + it('rejects installation when a privileged package tool has no usable privilege helper', async () => { + const execute = vi.fn(async () => undefined); + await expect(runSetup({platform: 'freebsd', args: ['--yes', '--only=mpv'], getUid: () => 1000, + input: new PassThrough(), output: new PassThrough(), hasCommand: command => command === 'pkg', runCommand: execute + })).rejects.toThrow('requires root, sudo, or doas'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('labels the root requirement when previewing commands without an available privilege helper', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + await runSetup({platform: 'freebsd', env: {}, args: ['--dry-run', '--only=mpv'], getUid: () => 1000, + input: new PassThrough(), output, hasCommand: command => command === 'pkg'}); + expect(text).toContain('pkg install -y mpv'); + expect(text).toContain('Run package commands as root; sudo/doas unavailable.'); + expect(text).not.toContain('sudo pkg'); + }); + + it('shows a manual pkgsrc FFmpeg plan without invoking a guessed package', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => { text += String(chunk); }); + const execute = vi.fn(async () => undefined); + await runSetup({platform: 'netbsd', args: ['--dry-run', '--only=ffmpeg'], getUid: () => 0, + input: new PassThrough(), output, hasCommand: command => command === 'pkgin', runCommand: execute}); + expect(text).toContain('manual installation required'); + expect(text).toContain('RADIOCLI_FFPLAY_PATH'); + expect(execute).not.toHaveBeenCalled(); + await expect(runSetup({platform: 'netbsd', args: ['--yes', '--only=ffmpeg'], getUid: () => 0, + input: new PassThrough(), output, hasCommand: command => command === 'pkgin', runCommand: execute + })).rejects.toThrow('No verified automatic installation command'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg', label: 'Android / Termux', expected: 'pkg install -y mpv'}, + {platform: 'linux' as const, env: {PREFIX: '/data/data/com.termux/files/usr'}, manager: 'pkg', label: 'Android / Termux', expected: 'pkg install -y mpv'}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', label: 'Haiku', expected: 'pkgman install -y mpv'}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', label: 'Solaris / illumos', expected: 'pkgin -y install mpv'} + ])('previews the verified $platform installation without running it', async ({platform, env, manager, label, expected}) => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => undefined); + await runSetup({platform, env, args: ['--dry-run', '--only=mpv'], input: new PassThrough(), output, + hasCommand: command => command === manager, getUid: () => 0, runCommand: execute}); + expect(text).toContain(label); + expect(text).toContain(expected); + expect(text).toContain('Dry run complete'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'termux-pkg', executable: 'pkg', uid: 10123, expected: 'pkg install -y mpv'}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', executable: 'pkgman', uid: 1000, expected: 'pkgman install -y mpv'}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', executable: 'pkgin', uid: 0, expected: 'pkgin -y install mpv'} + ])('runs the explicitly approved $manager command with its native privilege model', async ({platform, env, manager, executable, uid, expected}) => { + const execute = vi.fn(async (_command: {display: string}) => undefined); + await runSetup({platform, env, args: ['--yes', '--only=mpv', `--package-manager=${manager}`], input: new PassThrough(), output: new PassThrough(), + hasCommand: command => command === executable, getUid: () => uid, runCommand: execute}); + expect(execute).toHaveBeenCalledOnce(); + expect(execute.mock.calls[0]?.[0]).toMatchObject({display: expected}); + }); + + it('requires approval before running Termux pkg and rejects root execution', async () => { + const execute = vi.fn(async () => undefined); + const options = {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, input: new PassThrough(), output: new PassThrough(), + hasCommand: (command: string) => command === 'pkg', runCommand: execute}; + await expect(runSetup({...options, args: ['--only=mpv'], getUid: () => 10123})).rejects.toThrow('Use --yes'); + await expect(runSetup({...options, args: ['--yes', '--only=mpv'], getUid: () => 0})).rejects.toThrow('normal Termux app user'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('checks the native pkg executable for an explicit Termux manager selection', async () => { + const execute = vi.fn(async () => undefined); + await expect(runSetup({platform: 'android', env: {TERMUX_VERSION: '0.118.3'}, args: ['--yes', '--only=mpv', '--package-manager=termux-pkg'], + input: new PassThrough(), output: new PassThrough(), hasCommand: () => false, getUid: () => 10123, runCommand: execute + })).rejects.toThrow('Requested package manager is not available: termux-pkg (pkg)'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg'}, + {platform: 'freebsd' as const, env: {}, manager: 'termux-pkg'}, + {platform: 'sunos' as const, env: {}, manager: 'pkg'}, + {platform: 'aix' as const, env: {}, manager: 'dnf'} + ])('rejects the wrong native recipe for $manager on $platform before even previewing an install', async ({platform, env, manager}) => { + const execute = vi.fn(async () => undefined); + await expect(runSetup({platform, env, args: ['--dry-run', '--only=mpv', `--package-manager=${manager}`], + input: new PassThrough(), output: new PassThrough(), hasCommand: () => true, getUid: () => 0, runCommand: execute + })).rejects.toThrow('No verified'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg', only: 'ffmpeg', note: 'x11-repo'}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', only: 'vlc', note: 'Node 20'}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', only: 'ffmpeg', note: 'RADIOCLI_FFPLAY_PATH'} + ])('keeps unverified $platform components manual', async ({platform, env, manager, only, note}) => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => undefined); + const options = {platform, env, input: new PassThrough(), output, hasCommand: (command: string) => command === manager, getUid: () => 1000, runCommand: execute}; + await runSetup({...options, args: ['--dry-run', `--only=${only}`]}); + expect(text).toContain('manual installation required'); + expect(text).toContain(note); + await expect(runSetup({...options, args: ['--yes', `--only=${only}`]})).rejects.toThrow('No verified automatic installation command'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('shows the Haiku tools package and current Node catalog blocker together', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + await runSetup({platform: 'haiku', env: {}, args: ['--dry-run', '--only=ffmpeg'], input: new PassThrough(), output, + hasCommand: command => command === 'pkgman'}); + expect(text).toContain('pkgman install -y ffmpeg8_tools'); + expect(text).toMatch(/Node 20.*Node 22/); + expect(text).not.toContain('sudo pkgman'); + }); + + it('prints actionable manual AIX guidance even with a DNF command on PATH', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => undefined); + const options = {platform: 'aix' as const, env: {}, input: new PassThrough(), output, + hasCommand: (command: string) => command === 'dnf', runCommand: execute}; + await runSetup({...options, args: ['--dry-run', '--only=mpv']}); + expect(text).toContain('RADIOCLI_MPV_PATH'); + expect(text).not.toContain('dnf install'); + await expect(runSetup({...options, args: ['--yes', '--only=mpv']})).rejects.toThrow('No supported system package manager'); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + {platform: 'android' as const, env: {TERMUX_VERSION: '0.118.3'}, manager: 'pkg', uid: 10123}, + {platform: 'haiku' as const, env: {}, manager: 'pkgman', uid: 1000}, + {platform: 'sunos' as const, env: {}, manager: 'pkgin', uid: 0} + ])('reports a native $manager failure without claiming setup completed', async ({platform, env, manager, uid}) => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => {text += String(chunk);}); + const execute = vi.fn(async () => {throw new Error(`${manager}: package catalog unavailable`);}); + await expect(runSetup({platform, env, args: ['--yes', '--only=mpv'], input: new PassThrough(), output, + hasCommand: command => command === manager, getUid: () => uid, runCommand: execute + })).rejects.toThrow('package catalog unavailable'); + expect(execute).toHaveBeenCalledOnce(); + expect(text).not.toContain('Setup complete'); + }); + it('runs selected installers sequentially and prints final verification', async () => { const output = new PassThrough(); let text = ''; diff --git a/src/setup.ts b/src/setup.ts index 51d0916..f86574d 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,23 +1,16 @@ +import {componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageInstallPrerequisites, packageManagerElevation, packageManagerNeedsRoot, packageManagerNotes, packageManagerProgram, packageManagers, platformLabel, type SetupCommand, type SetupComponent, type SetupPackageManager} from './platform/packages.js'; +export type {SetupComponent}; import {spawn, type SpawnOptions} from 'node:child_process'; -import {existsSync, readFileSync, realpathSync} from 'node:fs'; +import {realpathSync} from 'node:fs'; import {createInterface} from 'node:readline/promises'; import type {Readable, Writable} from 'node:stream'; -import {clearCommandCache, commandExists} from './player/command.js'; +import {clearCommandCache, commandExists, resolveCommand} from './platform/executables.js'; import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backend-install.js'; import {configureMcpIntegrations} from './agent/mcp-install.js'; import {JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; - -export type SetupComponent = 'mpv' | 'ffmpeg' | 'vlc'; -export type SetupPackageManager = 'brew' | 'winget' | 'scoop' | 'choco' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper'; - -type SetupCommand = { - component: SetupComponent | null; - label: string; - program: string; - args: string[]; - display: string; -}; +import {resolveTerminalCapabilities} from './platform/terminal.js'; +import {identifyPlatform, readLinuxOsRelease, type PlatformProfile} from './platform/runtime.js'; export type SetupPlan = { platform: NodeJS.Platform; @@ -31,11 +24,13 @@ export type SetupPlan = { type SetupOptions = { platform?: NodeJS.Platform; osRelease?: string; + env?: NodeJS.ProcessEnv; args?: string[]; input?: Readable; output?: Writable; hasCommand?: (command: string) => boolean; runCommand?: (command: SetupCommand, output: Writable) => Promise; + getUid?: () => number | undefined; }; type ParsedSetupArgs = { @@ -49,20 +44,22 @@ type ParsedSetupArgs = { }; const components: SetupComponent[] = ['mpv', 'ffmpeg', 'vlc']; -const packageManagers: SetupPackageManager[] = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper']; export async function runSetup(options: SetupOptions = {}): Promise { const platform = options.platform ?? process.platform; const osRelease = options.osRelease ?? readLinuxOsRelease(platform); + const env = options.env ?? process.env; + const host = identifyPlatform({platform, osRelease, env}); const input = options.input ?? process.stdin; const output = options.output ?? process.stdout; const hasCommand = options.hasCommand ?? commandExists; const parsed = parseSetupArgs(options.args ?? []); + if (parsed.packageManager) validateNativePackageManager(parsed.packageManager, host); const installed = detectInstalledComponents(hasCommand); - const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand); + const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand, env); writeHeader(output); - output.write(`System ${platformLabel(platform, osRelease)} · Node ${process.version}\n`); + output.write(`System ${platformLabel(platform, osRelease, env)} ${separator(output)} Node ${process.version}\n`); output.write(`Manager ${packageManager ?? 'not detected'}\n\n`); let selected = parsed.only ?? defaultComponents(platform, parsed.all); @@ -81,8 +78,14 @@ export async function runSetup(options: SetupOptions = {}): Promise { agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true); } - const plan = createSetupPlan({platform, osRelease, packageManager, installed, selected}); + const isRoot = (options.getUid ?? process.getuid)?.() === 0; + const elevation = packageManagerElevation(hasCommand, isRoot); + const plan = createSetupPlan({platform, osRelease, env, packageManager, installed, selected, elevation}); printPlan(plan, output); + if (packageManager && packageManagerNeedsRoot(packageManager) && !isRoot && !elevation) { + output.write(' Note: Run package commands as root; sudo/doas unavailable.\n'); + } + for (const note of packageManagerNotes(packageManager, host)) output.write(` Note: ${note}\n`); const missing = plan.selected.filter(component => !plan.installed[component]); @@ -108,8 +111,16 @@ export async function runSetup(options: SetupOptions = {}): Promise { return; } - if (parsed.packageManager && !hasCommand(parsed.packageManager)) { - throw new Error(`Requested package manager is not available: ${parsed.packageManager}.`); + const manual = missing.filter(component => !plan.commands.some(command => command.component === component)); + if (manual.length) throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`); + if (plan.packageManager === 'termux-pkg' && isRoot) throw new Error('Run setup as the normal Termux app user. Termux pkg refuses root execution.'); + if (packageManagerNeedsRoot(plan.packageManager) && !isRoot && !elevation) { + throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.'); + } + + if (parsed.packageManager && !hasCommand(packageManagerProgram(parsed.packageManager))) { + const program = packageManagerProgram(parsed.packageManager); + throw new Error(`Requested package manager is not available: ${parsed.packageManager}${program !== parsed.packageManager ? ` (${program})` : ''}.`); } if (!parsed.yes && isInteractive(input, output)) { @@ -124,10 +135,10 @@ export async function runSetup(options: SetupOptions = {}): Promise { if (plan.commands.some(command => command.program === 'sudo')) { output.write('\nRadioCLI needs administrator approval for the system package manager.\n'); - await runVisibleCommand('sudo', ['-v']); + await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']); } - const execute = options.runCommand ?? runInstallCommand; + const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform)); output.write('\nInstalling\n'); for (const command of plan.commands) { await execute(command, output); @@ -142,32 +153,29 @@ export async function runSetup(options: SetupOptions = {}): Promise { export function createSetupPlan({ platform, osRelease = '', + env = process.env, packageManager, installed, - selected + selected, + elevation = 'sudo' }: { platform: NodeJS.Platform; osRelease?: string; + env?: NodeJS.ProcessEnv; packageManager: SetupPackageManager | null; installed: Record; selected: SetupComponent[]; + elevation?: 'sudo' | 'doas' | null; }): SetupPlan { + if (packageManager) validateNativePackageManager(packageManager, identifyPlatform({platform, osRelease, env})); const uniqueSelected = components.filter(component => selected.includes(component)); const missing = uniqueSelected.filter(component => !installed[component]); - const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component)) : []; - if (packageManager === 'scoop' && missing.some(component => component === 'mpv' || component === 'vlc')) { - commands.unshift({ - component: null, - label: 'Scoop extras bucket', - program: 'scoop', - args: ['bucket', 'add', 'extras'], - display: 'scoop bucket add extras' - }); - } + const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, {elevation})).filter((command): command is SetupCommand => command !== null) : []; + if (packageManager) commands.unshift(...packageInstallPrerequisites(packageManager, missing)); return { platform, - platformLabel: platformLabel(platform, osRelease), + platformLabel: platformLabel(platform, osRelease, env), packageManager, installed, selected: uniqueSelected, @@ -175,28 +183,15 @@ export function createSetupPlan({ }; } -export function detectPackageManager( - platform: NodeJS.Platform, - osRelease: string, - hasCommand: (command: string) => boolean = commandExists -): SetupPackageManager | null { - if (platform === 'darwin') return hasCommand('brew') ? 'brew' : null; - if (platform === 'win32') return firstAvailable(['winget', 'scoop', 'choco'], hasCommand); - if (platform !== 'linux') return null; - - const ids = linuxReleaseIds(osRelease); - const preferred: SetupPackageManager[] = hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop']) - ? ['apt'] - : hasAny(ids, ['fedora', 'rhel', 'centos']) - ? ['dnf'] - : hasAny(ids, ['arch', 'manjaro']) - ? ['pacman'] - : hasAny(ids, ['alpine']) - ? ['apk'] - : hasAny(ids, ['opensuse', 'suse']) - ? ['zypper'] - : ['apt', 'dnf', 'pacman', 'apk', 'zypper']; - return firstAvailable(preferred, hasCommand); +function validateNativePackageManager(manager: SetupPackageManager, host: PlatformProfile): void { + const required = host.id === 'termux' ? 'termux-pkg' : host.id === 'haiku' ? 'pkgman' : host.id === 'sunos' ? 'pkgin' : null; + if ((required && manager !== required) + || (manager === 'termux-pkg' && host.id !== 'termux') + || (manager === 'pkgman' && host.id !== 'haiku') + || (manager === 'pkg' && host.id !== 'freebsd') + || ['aix', 'android', 'unknown'].includes(host.id)) { + throw new Error(`No verified ${manager} playback package recipe for ${host.id}.${required ? ` Use --package-manager=${required}.` : ' Install a native player manually and run radiocli doctor.'}`); + } } export function parseSetupArgs(args: string[]): ParsedSetupArgs { @@ -279,46 +274,6 @@ function parsePackageManager(value: string | undefined): SetupPackageManager { return value as SetupPackageManager; } -function packageInstallCommand(manager: SetupPackageManager, component: SetupComponent): SetupCommand { - const packages: Record> = { - brew: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - winget: {mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC'}, - scoop: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - choco: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - apt: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - dnf: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - pacman: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - apk: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'}, - zypper: {mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc'} - }; - const packageName = packages[manager][component]; - let program: string = manager; - let args: string[]; - - if (manager === 'brew') args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])]; - else if (manager === 'winget') args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements']; - else if (manager === 'scoop') args = ['install', packageName]; - else if (manager === 'choco') args = ['install', packageName, '-y']; - else if (manager === 'apt') { - program = 'sudo'; - args = ['apt-get', 'install', '-y', packageName]; - } else if (manager === 'dnf') { - program = 'sudo'; - args = ['dnf', 'install', '-y', packageName]; - } else if (manager === 'pacman') { - program = 'sudo'; - args = ['pacman', '-S', '--needed', '--noconfirm', packageName]; - } else if (manager === 'apk') { - program = 'sudo'; - args = ['apk', 'add', packageName]; - } else { - program = 'sudo'; - args = ['zypper', '--non-interactive', 'install', packageName]; - } - - return {component, label: componentLabel(component), program, args, display: [program, ...args].join(' ')}; -} - function defaultComponents(platform: NodeJS.Platform, all: boolean): SetupComponent[] { if (all) return [...components]; return platform === 'darwin' ? ['mpv', 'ffmpeg'] : ['mpv']; @@ -345,9 +300,9 @@ async function promptForComponents({ }): Promise { output.write('Choose components (installed items will be skipped):\n'); const selected: SetupComponent[] = []; - if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ' · installed' : ''}`, true)) selected.push('mpv'); - if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ' · installed' : ''}`, platform === 'darwin')) selected.push('ffmpeg'); - if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ' · installed' : ''}`, false)) selected.push('vlc'); + if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true)) selected.push('mpv'); + if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin')) selected.push('ffmpeg'); + if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false)) selected.push('vlc'); return selected; } @@ -364,18 +319,22 @@ async function promptYesNo(input: Readable, output: Writable, question: string, function printPlan(plan: SetupPlan, output: Writable): void { output.write('\nInstallation plan\n'); + for (const command of plan.commands.filter(command => command.component === null)) { + output.write(` ${pendingMark(output)} ${command.label} ${separator(output)} ${command.display}\n`); + } for (const component of plan.selected) { if (plan.installed[component]) output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`); else { const command = plan.commands.find(candidate => candidate.component === component); - output.write(` ${pendingMark(output)} ${componentLabel(component)}${command ? ` · ${command.display}` : ' · manual installation required'}\n`); + output.write(` ${pendingMark(output)} ${componentLabel(component)} ${separator(output)} ${command ? command.display : 'manual installation required'}\n`); } } if (plan.selected.length === 0) output.write(' No components selected\n'); } -async function runInstallCommand(command: SetupCommand, output: Writable): Promise { - const interactive = Boolean((output as NodeJS.WriteStream).isTTY); +async function runInstallCommand(command: SetupCommand, output: Writable, platform: NodeJS.Platform): Promise { + const terminal = setupTerminal(output); + const interactive = terminal.interactive && !terminal.reduceMotion; const startedAt = Date.now(); let timer: NodeJS.Timeout | undefined; let frame = 0; @@ -392,7 +351,8 @@ async function runInstallCommand(command: SetupCommand, output: Writable): Promi try { await new Promise((resolve, reject) => { - const child = spawn(command.program, command.args, {stdio: ['inherit', 'pipe', 'pipe']} satisfies SpawnOptions); + const invocation = packageCommandInvocation(command, platform, resolveCommand); + const child = spawn(invocation.program, invocation.args, {shell: false, stdio: ['inherit', 'pipe', 'pipe']} satisfies SpawnOptions); child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); }); child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); }); child.once('error', reject); @@ -415,8 +375,9 @@ function progressFrame(label: string, frame: number, elapsedMs: number, output: const cycle = travel * 2; const offset = frame % cycle; const start = offset <= travel ? offset : cycle - offset; - const bar = Array.from({length: width}, (_, index) => index >= start && index < start + segment ? '█' : '░').join(''); - return ` ${accent(output, '◒')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`; + const unicode = setupTerminal(output).unicode; + const bar = Array.from({length: width}, (_, index) => index >= start && index < start + segment ? (unicode ? '█' : '#') : (unicode ? '░' : '.')).join(''); + return ` ${accent(output, unicode ? '◒' : '*')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`; } function printVerification(output: Writable): void { @@ -430,20 +391,7 @@ function printVerification(output: Writable): void { function writeHeader(output: Writable): void { output.write(`${accent(output, 'RADIOCLI')} SETUP RECEIVER\n`); - output.write(`${accent(output, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}\n`); -} - -function platformLabel(platform: NodeJS.Platform, osRelease: string): string { - if (platform === 'darwin') return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`; - if (platform === 'win32') return 'Windows'; - if (platform === 'linux') return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux'; - return platform; -} - -function componentLabel(component: SetupComponent): string { - if (component === 'ffmpeg') return 'FFmpeg / ffplay'; - if (component === 'vlc') return 'VLC fallback'; - return 'mpv'; + output.write(`${accent(output, setupTerminal(output).unicode ? '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' : '------------------------------------')}\n`); } function isInteractive(input: Readable, output: Writable): boolean { @@ -451,23 +399,33 @@ function isInteractive(input: Readable, output: Writable): boolean { } function accent(output: Writable, value: string): string { - return colorsEnabled(output) ? `\u001b[38;2;116;242;138m${value}\u001b[0m` : value; + const level = setupTerminal(output).colorLevel; + const color = level === 3 ? '38;2;116;242;138' : level === 2 ? '38;5;120' : '32'; + return level > 0 ? `\u001b[${color}m${value}\u001b[0m` : value; } function successMark(output: Writable): string { - return accent(output, '✓'); + return accent(output, setupTerminal(output).unicode ? '✓' : '+'); } function pendingMark(output: Writable): string { - return accent(output, '◆'); + return accent(output, setupTerminal(output).unicode ? '◆' : '*'); } function failureMark(output: Writable): string { - return colorsEnabled(output) ? '\u001b[38;2;255;95;135m✗\u001b[0m' : '✗'; + const terminal = setupTerminal(output); + const value = terminal.unicode ? '✗' : 'x'; + const color = terminal.colorLevel === 3 ? '38;2;255;95;135' : terminal.colorLevel === 2 ? '38;5;204' : '31'; + return terminal.colorLevel > 0 ? `\u001b[${color}m${value}\u001b[0m` : value; } -function colorsEnabled(output: Writable): boolean { - return Boolean((output as NodeJS.WriteStream).isTTY) && !process.env.NO_COLOR; +function setupTerminal(output: Writable) { + const stream = output as NodeJS.WriteStream; + return resolveTerminalCapabilities(process.env, {isTTY: Boolean(stream.isTTY), colorDepth: stream.getColorDepth?.()}); +} + +function separator(output: Writable): string { + return setupTerminal(output).unicode ? '·' : '-'; } function clearLine(): string { @@ -494,35 +452,3 @@ async function runVisibleCommand(program: string, args: string[]): Promise child.once('close', code => code === 0 ? resolve() : reject(new Error(`${program} ${args.join(' ')} exited with code ${code}.`))); }); } - -function firstAvailable(values: T[], hasCommand: (command: string) => boolean): T | null { - return values.find(hasCommand) ?? null; -} - -function readLinuxOsRelease(platform: NodeJS.Platform): string { - if (platform !== 'linux' || !existsSync('/etc/os-release')) return ''; - try { - return readFileSync('/etc/os-release', 'utf8'); - } catch { - return ''; - } -} - -function linuxReleaseIds(osRelease: string): Set { - const ids = new Set(); - for (const line of osRelease.split('\n')) { - const match = /^(ID|ID_LIKE)=(.*)$/.exec(line); - if (!match) continue; - for (const value of match[2]!.replaceAll('"', '').split(/\s+/)) if (value.trim()) ids.add(value.trim().toLowerCase()); - } - return ids; -} - -function osReleaseValue(osRelease: string, key: string): string { - const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`)); - return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? ''; -} - -function hasAny(values: Set, candidates: string[]): boolean { - return candidates.some(candidate => values.has(candidate)); -} diff --git a/src/storage/store.test.ts b/src/storage/store.test.ts index 2b940c0..15aeb17 100644 --- a/src/storage/store.test.ts +++ b/src/storage/store.test.ts @@ -1,17 +1,25 @@ -import {existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import * as fs from 'node:fs'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; -import {afterEach, describe, expect, it} from 'vitest'; +import {afterEach, describe, expect, it, vi} from 'vitest'; import {JsonLibraryStore} from './store.js'; +import * as paths from '../platform/paths.js'; import {AlarmPowerGuardStore} from '../alarms/power-guard-store.js'; import {defaultReceiverStyle, receiverStyleNames, themeNames, type AlarmCreateInput, type Station} from '../types.js'; const roots: string[] = []; const originalRadioCliHome = process.env.RADIOCLI_HOME; const originalRadioAtlasHome = process.env.RADIO_ATLAS_HOME; +// Windows ACLs and a privileged POSIX user do not enforce these chmod fixtures. +const nativePermissions = process.platform !== 'win32' && process.getuid?.() !== 0; + +vi.mock('node:fs', async importOriginal => ({...await importOriginal()})); +vi.mock('../platform/paths.js', async importOriginal => ({...await importOriginal()})); describe('JsonLibraryStore', () => { afterEach(() => { + vi.restoreAllMocks(); for (const root of roots.splice(0)) { rmSync(root, {recursive: true, force: true}); } @@ -522,7 +530,236 @@ describe('JsonLibraryStore', () => { expect(store.snapshot().settings.theme).toBe('green'); expect(store.snapshot().settings.receiverStyle).toBe(defaultReceiverStyle); expect(store.snapshot().activity.sessions).toEqual([]); - expect(readdirSync(root).some(name => name.startsWith('library.json.bad-'))).toBe(true); + const backup = readdirSync(root).find(name => name.startsWith('library.json.bad-')); + expect(backup).toBeDefined(); + expect(readFileSync(join(root, backup!), 'utf8')).toBe('{not json'); + }); + + it.skipIf(!nativePermissions)('keeps a readable library usable in a read-only directory and preserves it after a failed commit', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli library café ')); + roots.push(root); + const file = join(root, '音楽 library.json'); + const original = JSON.stringify({settings: {theme: 'amber'}}); + writeFileSync(file, original); + chmodSync(root, 0o500); + try { + const store = new JsonLibraryStore(file); + const before = store.snapshot(); + expect(before.settings.theme).toBe('amber'); + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(/RADIOCLI_HOME.*writable/i); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + expect(readdirSync(root)).toEqual(['音楽 library.json']); + } finally { + chmodSync(root, 0o700); + } + }); + + it.skipIf(!nativePermissions)('does not replace a read-only library through an otherwise writable directory', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli readonly café ')); + roots.push(root); + const file = join(root, 'library.json'); + const original = JSON.stringify({settings: {theme: 'amber'}}); + writeFileSync(file, original); + chmodSync(file, 0o400); + try { + const store = new JsonLibraryStore(file); + const before = store.snapshot(); + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(/RADIOCLI_HOME.*writable/i); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + expect(readdirSync(root)).toEqual(['library.json']); + } finally { + chmodSync(file, 0o600); + } + }); + + it.skipIf(!nativePermissions)('does not rename an unreadable intact library as corrupt or return empty defaults', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli unreadable café ')); + roots.push(root); + const file = join(root, 'private library.json'); + const original = JSON.stringify({settings: {theme: 'amber'}}); + writeFileSync(file, original); + chmodSync(file, 0); + try { + let failure: unknown; + try { new JsonLibraryStore(file); } catch (error) { failure = error; } + expect(failure).toMatchObject({code: 'EACCES', cause: {code: 'EACCES'}}); + expect((failure as Error).message).toMatch(/read.*RADIOCLI_HOME.*writable/i); + expect((failure as Error).message).not.toContain(root); + expect(readdirSync(root)).toEqual(['private library.json']); + } finally { + chmodSync(file, 0o600); + } + expect(readFileSync(file, 'utf8')).toBe(original); + }); + + it.skipIf(!nativePermissions)('does not interpret an inaccessible ancestor as an absent library', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli inaccessible café ')); + roots.push(root); + const parent = join(root, 'private'); + const file = join(parent, 'library.json'); + mkdirSync(parent); + writeFileSync(file, '{"settings":{"theme":"amber"}}'); + chmodSync(parent, 0); + try { + expect(() => new JsonLibraryStore(file)).toThrow(/read.*RADIOCLI_HOME.*writable/i); + } finally { + chmodSync(parent, 0o700); + } + expect(readFileSync(file, 'utf8')).toBe('{"settings":{"theme":"amber"}}'); + expect(readdirSync(parent)).toEqual(['library.json']); + }); + + it.skipIf(!nativePermissions)('does not switch to an older library when the preferred directory is inaccessible', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli migration café ')); + roots.push(root); + const parent = join(root, 'current data'); + const file = join(parent, 'library.json'); + const legacy = join(root, 'legacy.json'); + mkdirSync(parent); + writeFileSync(file, '{"settings":{"theme":"ruby"}}'); + writeFileSync(legacy, '{"settings":{"theme":"amber"}}'); + vi.spyOn(paths, 'platformPaths').mockReturnValue({...paths.platformPaths(), library: file, legacyLibrary: legacy}); + chmodSync(parent, 0); + try { + expect(() => new JsonLibraryStore()).toThrow(/read.*RADIOCLI_HOME.*writable/i); + } finally { + chmodSync(parent, 0o700); + } + expect(readFileSync(file, 'utf8')).toBe('{"settings":{"theme":"ruby"}}'); + expect(readFileSync(legacy, 'utf8')).toBe('{"settings":{"theme":"amber"}}'); + }); + + it.skipIf(!nativePermissions)('preserves corrupt data when a backup cannot be written', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli corrupt café ')); + roots.push(root); + const file = join(root, 'library.json'); + writeFileSync(file, '{not json'); + chmodSync(root, 0o500); + try { + expect(() => new JsonLibraryStore(file)).toThrow(/corrupt.*RADIOCLI_HOME.*writable/i); + expect(readFileSync(file, 'utf8')).toBe('{not json'); + expect(readdirSync(root)).toEqual(['library.json']); + } finally { + chmodSync(root, 0o700); + } + }); + + it('retains I/O failure details without classifying the library as corrupt', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + writeFileSync(file, '{"settings":{"theme":"amber"}}'); + const failure = Object.assign(new Error(`disk failure at ${file}`), {code: 'EIO'}); + vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { throw failure; }); + + let reported: unknown; + try { new JsonLibraryStore(file); } catch (error) { reported = error; } + expect(reported).toMatchObject({code: 'EIO', cause: failure}); + expect((reported as Error).message).not.toContain(file); + expect(readFileSync(file, 'utf8')).toBe('{"settings":{"theme":"amber"}}'); + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it('preserves committed data and the original write failure when temporary and lock cleanup also fail', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + const failure = Object.assign(new Error(`rename failed at ${file}`), {code: 'ENOSPC'}); + const cleanup = Object.assign(new Error('cleanup failed'), {code: 'EACCES'}); + vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => { throw failure; }); + vi.spyOn(fs, 'rmSync').mockImplementation(() => { throw cleanup; }); + + let reported: unknown; + try { store.updateSettings({theme: 'ruby'}); } catch (error) { reported = error; } + expect(reported).toMatchObject({code: 'ENOSPC', cause: failure}); + expect((reported as Error).message).toMatch(/RADIOCLI_HOME.*writable/i); + expect((reported as Error).message).not.toContain(file); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + }); + + it('reports a busy library without revealing the private path or changing state', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli busy café ')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + mkdirSync(`${file}.lock`); + + let reported: unknown; + try { store.updateSettings({theme: 'ruby'}); } catch (error) { reported = error; } + expect((reported as Error).message).toMatch(/busy/i); + expect((reported as Error).message).not.toContain(root); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + }); + + it('preserves the library if setting private temporary-file permissions fails', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + const failure = Object.assign(new Error('chmod failed'), {code: 'EPERM'}); + const chmod = vi.spyOn(fs, 'chmodSync').mockImplementationOnce(() => { throw failure; }); + + if (process.platform === 'win32') { + store.updateSettings({theme: 'ruby'}); + expect(chmod).not.toHaveBeenCalled(); + expect(new JsonLibraryStore(file).snapshot().settings.theme).toBe('ruby'); + } else { + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(expect.objectContaining({code: 'EPERM', cause: failure})); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); + } + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it('keeps the saved state accurate if lock release fails after a successful replacement', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + vi.spyOn(fs, 'rmSync').mockImplementationOnce(() => { throw Object.assign(new Error('release failed'), {code: 'EACCES'}); }); + + expect(store.updateSettings({theme: 'ruby'}).settings.theme).toBe('ruby'); + expect(new JsonLibraryStore(file).snapshot().settings.theme).toBe('ruby'); + }); + + it('reports lock-inspection failures without retrying them as a missing lock', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({theme: 'amber'}); + const before = store.snapshot(); + const original = readFileSync(file, 'utf8'); + const lockPath = `${file}.lock`; + mkdirSync(lockPath); + const failure = Object.assign(new Error(`lock inspection failed at ${lockPath}`), {code: 'EIO'}); + const stat = fs.statSync; + let inspected = false; + vi.spyOn(fs, 'statSync').mockImplementation(((...args: Parameters) => { + if (args[0] === lockPath && !inspected) { + inspected = true; + throw failure; + } + return stat(...args); + }) as typeof fs.statSync); + + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(expect.objectContaining({code: 'EIO', cause: failure})); + expect(store.snapshot()).toEqual(before); + expect(readFileSync(file, 'utf8')).toBe(original); }); it.each(['scope', 'spectrum', 'oscilloscope', 'motion-bars', 'radar', 'dual-ripple', 'perspective-floor', 'bloom-bars', 'running-horse', 'starlink', 'tuning-dial', 'cassette', `${'term'}${'flix'}-plasma`, 'sierpinksi', 'voronoi', 'orbits', 'chromatic', 'ferro-crown', 'origami-tide', 'tv-static', 'sunspot', 'plasma', 'metaballs', 'motion-blob', 'clifford', 'mirror', 'paris', 'kyoto', 'sahara'])( diff --git a/src/storage/store.ts b/src/storage/store.ts index 026c425..5afbfbe 100644 --- a/src/storage/store.ts +++ b/src/storage/store.ts @@ -1,3 +1,5 @@ +import {platformPaths} from '../platform/paths.js'; +import {assertStorageWritable} from '../platform/storage.js'; import {chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; import {dirname, isAbsolute, join, resolve} from 'node:path'; @@ -19,7 +21,6 @@ import { type UpdateCheckState } from '../types.js'; import {canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone} from '../alarms/schedule.js'; -import {backupBadFile} from '../providers/cache.js'; import {migrateReceiverStyle} from '../ui/visualizers/receiver-style-registry.js'; const stationSchema: z.ZodType = z @@ -282,7 +283,7 @@ export class JsonLibraryStore { private readonly now: () => Date; constructor( - filePath = defaultStorePath(), + filePath = defaultLibraryPath(), options: {idGenerator?: () => string; now?: () => Date} = {} ) { this.filePath = filePath; @@ -623,14 +624,24 @@ export class JsonLibraryStore { } private read(): LibraryState { - if (!existsSync(this.filePath)) { - return defaultState(); + let contents: string; + try { + contents = readFileSync(this.filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultState(); + throw storageError('read the RadioCLI library', error); } try { - return migrateLibraryState(librarySchema.parse(JSON.parse(readFileSync(this.filePath, 'utf8')))); + return migrateLibraryState(librarySchema.parse(JSON.parse(contents))); } catch { - backupBadFile(this.filePath); + try { + assertStorageWritable(this.filePath); + renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`); + } catch (error) { + // Reset only after preserving the original bytes. A failed read never reaches here. + throw storageError('preserve the corrupt RadioCLI library', error); + } return defaultState(); } } @@ -649,8 +660,13 @@ export class JsonLibraryStore { } private write(state: LibraryState): void { - mkdirSync(dirname(this.filePath), {recursive: true, mode: 0o700}); - writeJsonAtomically(this.filePath, libraryStateForDisk(state)); + try { + assertStorageWritable(this.filePath); + mkdirSync(dirname(this.filePath), {recursive: true, mode: 0o700}); + writeJsonAtomically(this.filePath, libraryStateForDisk(state)); + } catch (error) { + throw storageError('write the RadioCLI library', error); + } } } @@ -697,38 +713,20 @@ function recoverActiveListeningSessionInState(state: LibraryState): LibraryState return finishActiveListeningSessionInState(state, new Date(recoveredEnd)); } -function defaultStorePath(): string { - if (process.env.RADIOCLI_HOME) { - return join(process.env.RADIOCLI_HOME, 'radiocli.json'); - } - - if (process.env.RADIO_ATLAS_HOME) { - return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas.json'); - } - - const currentPath = currentDefaultStorePath(); - const legacyPath = legacyDefaultStorePath(); - return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath; -} - -function currentDefaultStorePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json'); - } - - if (process.platform === 'win32') { - return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json'); - } - - return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json'); -} - -function legacyDefaultStorePath(): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas.json'); +export function defaultLibraryPath(): string { + const {library, legacyLibrary} = platformPaths(); + for (const filePath of [library, legacyLibrary]) { + try { + statSync(filePath); + return filePath; + } catch (error) { + // A permission failure must not select an older library or empty defaults. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw storageError('read the RadioCLI library', error); + } + } } - - return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radio-atlas', 'radio-atlas.json'); + return library; } function resolveUserPath(requestedPath: string): string { @@ -809,46 +807,76 @@ function libraryStateForDisk(state: LibraryState): LibraryState { } function writeJsonAtomically(filePath: string, value: unknown): void { - const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`; try { - writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); - renameSync(tempPath, filePath); + writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); if (process.platform !== 'win32') { - chmodSync(filePath, 0o600); + chmodSync(tempPath, 0o600); } + // No fallible persistence steps follow the atomic replacement. + renameSync(tempPath, filePath); } catch (error) { - rmSync(tempPath, {force: true}); - throw error; + try { rmSync(tempPath, {force: true}); } catch { /* Preserve the original write failure. */ } + throw storageError('write RadioCLI data', error); } } function acquireStoreLock(filePath: string): () => void { const lockPath = `${filePath}.lock`; - mkdirSync(dirname(filePath), {recursive: true, mode: 0o700}); + try { + assertStorageWritable(filePath); + mkdirSync(dirname(filePath), {recursive: true, mode: 0o700}); + } catch (error) { + throw storageError('write the RadioCLI library', error); + } const deadline = Date.now() + 1000; while (true) { try { mkdirSync(lockPath, {mode: 0o700}); - return () => rmSync(lockPath, {recursive: true, force: true}); + return () => { + // The write has already succeeded or failed. Lock cleanup cannot reverse that result. + try { rmSync(lockPath, {recursive: true, force: true}); } catch { /* Stale locks expire below. */ } + }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== 'EEXIST') throw error; + if (code !== 'EEXIST') throw storageError('lock the RadioCLI library', error); try { if (Date.now() - statSync(lockPath).mtimeMs > 10_000) { rmSync(lockPath, {recursive: true, force: true}); - continue; } - } catch { - continue; + } catch (lockError) { + if ((lockError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw storageError('lock the RadioCLI library', lockError); + } } if (Date.now() >= deadline) { - throw new Error(`RadioCLI library is busy: ${filePath}`); + throw storageError('lock the RadioCLI library', Object.assign(new Error('Another writer holds the library lock.'), {code: 'EBUSY', cause: error})); } Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); } } } +class LibraryStorageError extends Error { + readonly code?: string; + readonly errno?: number; + + constructor(operation: string, cause: unknown) { + const code = (cause as NodeJS.ErrnoException | undefined)?.code; + const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : ''; + super(code === 'EBUSY' + ? 'The RadioCLI library is busy. Retry after the other writer finishes, or set RADIOCLI_HOME to a private writable directory.' + : `Unable to ${operation}${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`, {cause}); + this.name = 'LibraryStorageError'; + this.code = code; + this.errno = (cause as NodeJS.ErrnoException | undefined)?.errno; + } +} + +function storageError(operation: string, cause: unknown): LibraryStorageError { + return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause); +} + function mergeLibraryChanges(base: LibraryState, disk: LibraryState, next: LibraryState): LibraryState { return { recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50), diff --git a/src/ui/AdaptiveContent.test.tsx b/src/ui/AdaptiveContent.test.tsx index 28a26bd..a8b4b99 100644 --- a/src/ui/AdaptiveContent.test.tsx +++ b/src/ui/AdaptiveContent.test.tsx @@ -1,5 +1,6 @@ -import {render} from 'ink-testing-library'; -import {describe, expect, it} from 'vitest'; +import {act} from 'react'; +import {cleanup, render as inkRender} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {receiverStyleNames} from '../types.js'; import type { AirPlayDevice, @@ -17,6 +18,25 @@ import {DisplayContext, resolveDisplayMode} from './display-context.js'; import {displayWidth} from './format.js'; import {defaultExploreCursor} from './app-state.js'; +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // Ink's fixture supplies 100 columns but no rows. Keep its 24-row fallback + // deterministic without asking the host terminal through commands like tput. + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '24'); +}); +afterEach(() => { + act(() => cleanup()); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function render(tree: Parameters[0]): ReturnType { + let view!: ReturnType; + act(() => { view = inkRender(tree); }); + return view; +} + const station: Station = { id: 'kexp', provider: 'radio-browser', @@ -238,7 +258,7 @@ function renderAdaptive( renderStations: Station[] = [station] ): string { const renderLibrary = {...library, settings: {...settings, receiverStyle}}; - return render( + const view = render( - ).lastFrame() ?? ''; + ); + try { + return view.lastFrame() ?? ''; + } finally { + act(() => { + view.unmount(); + view.cleanup(); + }); + } } diff --git a/src/ui/AdaptiveContent.tsx b/src/ui/AdaptiveContent.tsx index 646fed5..b195032 100644 --- a/src/ui/AdaptiveContent.tsx +++ b/src/ui/AdaptiveContent.tsx @@ -64,6 +64,7 @@ type AdaptiveContentProps = { stationError?: string; filterLabel: string; sleepLabel: string; + airPlaySupported?: boolean; }; type AdaptiveRow = { @@ -110,9 +111,10 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { appVersion, favoriteKeys, stationTitle, + airPlaySupported = true, } = props; const accent = themeAccent(theme); - const {ascii} = useDisplay(); + const {ascii, reduceMotion} = useDisplay(); const {bodyRows} = adaptiveFrameMetrics(mode, height); const title = screenTitle(screen); const status = adaptiveStatus(props); @@ -142,7 +144,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { ascii={ascii} theme={theme} receiverStyle={library.settings.receiverStyle} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ); } @@ -161,7 +163,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { loading={loadingStations} error={props.stationError} ascii={ascii} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ); } @@ -182,7 +184,8 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { selected, width, mode, - ascii + ascii, + airPlaySupported }); const empty = adaptiveEmptyState({ screen, @@ -208,7 +211,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { width={width} theme={theme} ascii={ascii} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ); } @@ -237,7 +240,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { const prompt = airPlayCode ? `Code: ${airPlayCode}` : 'Type the code shown by the receiver.'; return ( - {bodyRows > 0 ? {truncate(prompt, width)} : null} + {bodyRows > 0 ? {ascii ? toAsciiSafe(truncate(prompt, width)) : truncate(prompt, width)} : null} ); } @@ -257,7 +260,8 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { selected, width, mode, - ascii + ascii, + airPlaySupported }); const empty = adaptiveEmptyState({ screen, @@ -280,7 +284,7 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { pageSize={bodyRows} width={width} theme={theme} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={reduceMotion} /> ) : ( @@ -318,6 +322,7 @@ function AdaptiveExplore({ }): React.ReactElement { const accent = themeAccent(theme); const {headerRows, headerGap, bodyRows} = adaptiveExploreFrameMetrics(mode, height); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const layout = computeAdaptiveExploreLayout(mode, width, Math.max(1, bodyRows)); const marker = React.useMemo( () => [{lat: cursor.latitude, lon: cursor.longitude, selected: true}], @@ -349,8 +354,8 @@ function AdaptiveExplore({ return ( - {truncate(title, width)} - {headerRows > 1 ? {truncate(status, width)} : null} + {a(truncate(title, width))} + {headerRows > 1 ? {a(truncate(status, width))} : null} {headerGap ? : null} {bodyRows > 0 ? layout.split ? ( @@ -403,7 +408,7 @@ function AdaptiveCosmoMap({ ascii: boolean; }): React.ReactElement { return ( - + {rows.map((row, rowIndex) => { const chunks: Array<{kind: CosmoMapCellKind; text: string}> = []; for (const cell of row.cells) { @@ -456,6 +461,7 @@ function AdaptiveFrame({ }): React.ReactElement { const accent = themeAccent(theme); const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const titlePrefix = mode === 'micro' ? 'RC / ' : 'RADIOCLI '; const titleText = truncate(`${titlePrefix}${title}${mode === 'micro' && status ? ` · ${status}` : ''}`, width); const ruleWidth = Math.max(0, width - displayWidth(titleText) - 1); @@ -463,10 +469,10 @@ function AdaptiveFrame({ return ( - - {titleText}{ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : ''} + + {a(titleText)}{ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : ''} - {mode === 'compact' && height >= 4 ? {truncate(status, width)} : null} + {mode === 'compact' && height >= 4 ? {a(truncate(status, width))} : null} {gapRows ? : null} {children} @@ -501,6 +507,8 @@ function AdaptiveHome({ library: LibraryState; }): React.ReactElement { const gapRows = height >= 5 ? 1 : 0; + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const showSummary = mode === 'compact' && height >= 10; const menuRows = Math.max(1, height - 1 - gapRows * 2 - (showSummary ? 1 : 0)); const selectedIndex = Math.min(Math.max(selected, 0), homeItems.length - 1); @@ -517,15 +525,15 @@ function AdaptiveHome({ const active = absoluteIndex === selectedIndex; const detail = mode === 'compact' && width >= 52 ? ` · ${item.detail}` : ''; return ( - - {active ? '> ' : ' '}{absoluteIndex + 1} {truncate(`${item.label}${detail}`, Math.max(1, width - 4))} + + {active ? '> ' : ' '}{absoluteIndex + 1} {a(truncate(`${item.label}${detail}`, Math.max(1, width - 4)))} ); })} {showSummary ? ( - {truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width)} + {a(truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width))} ) : null} {gapRows ? : null} @@ -562,7 +570,7 @@ function AdaptiveList({ const text = `${row.label}${detail}`; const favoriteWidth = row.favoriteGlyph ? 2 : 0; return ( - + {prefix} {rows.map(row => ( - - {truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)} + + {ascii ? toAsciiSafe(truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)) : truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)} {row.key === 'activity' ? : null} ))} @@ -614,15 +623,16 @@ function AdaptiveNowPlaying({ reduceMotion: boolean; }): React.ReactElement { const pulse = useReceiverPulse(); + const {screenReader} = useDisplay(); const accent = themeAccent(theme); const stationName = station?.name ?? 'No station tuned'; - const showMetadata = mode === 'compact' && height >= 9 && Boolean(metadata?.title); + const showMetadata = Boolean(metadata?.title) && (screenReader || mode === 'compact' && height >= 9); const headerRows = mode === 'compact' ? 2 : 1; const metadataRows = showMetadata ? 1 : 0; const gapRows = height >= 5 ? 1 : 0; const availableVisualRows = Math.max(1, height - headerRows - metadataRows - gapRows * 2); const visualHeight = visualizerHeight(receiverStyle, availableVisualRows, width); - const visualRows = buildVisualizer( + const visualRows = screenReader ? [] : buildVisualizer( receiverStyle, pulse, width, @@ -639,14 +649,14 @@ function AdaptiveNowPlaying({ return ( - + {mode === 'micro' ? : truncate(header, width)} - {mode === 'compact' ? {truncate(status, width)} : null} + {mode === 'compact' ? {ascii ? toAsciiSafe(truncate(status, width)) : truncate(status, width)} : null} {gapRows ? : null} - + {!screenReader ? {visualRows.map((row, index) => ( {row.segments @@ -656,9 +666,9 @@ function AdaptiveNowPlaying({ : row.text} ))} - + : null} {showMetadata ? ( - + ) : null} @@ -712,6 +722,7 @@ function AdaptiveSearch({ }): React.ReactElement { const accent = themeAccent(theme); const {panel: panelBackground} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const fieldText = query || 'station, genre, or place'; const prefix = loading ? (ascii ? '* ' : '⣾ ') : editing ? '› ' : '/ '; const fieldRows = height >= 4 ? 3 : 1; @@ -730,11 +741,11 @@ function AdaptiveSearch({ height={3} flexShrink={0} > - {prefix} - {truncate(fieldText, Math.max(1, width - 5))} + {a(prefix)} + {a(truncate(fieldText, Math.max(1, width - 5)))} ) : ( - {truncate(`[${prefix}${fieldText}]`, width)} + {a(truncate(`[${prefix}${fieldText}]`, width))} )} {gapRows ? : null} @@ -772,6 +783,7 @@ function adaptiveRows(input: { width: number; mode: 'compact' | 'micro'; ascii: boolean; + airPlaySupported: boolean; }): AdaptiveRow[] { const {screen, stations, countries, airPlayDevices, library, diagnostics, backends, updateCheck, favoriteKeys, selected, width, mode, ascii} = input; if (screen === 'home') { @@ -784,11 +796,11 @@ function adaptiveRows(input: { } if (screen === 'settings') { const pageItems = settingsItemsForPage(input.settingsPage); - const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion)); + const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported)); const labelWidth = pairedColumnWidth(labels, width, mode); return pageItems.map((item, index) => ({ key: item, - label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion), labelWidth), labelWidth), + label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported), labelWidth), labelWidth), detail: input.settingsPage === 'root' ? adaptiveSettingsRootValue(item) : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion), diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4103586..07387f8 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,9 +1,9 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {fileURLToPath} from 'node:url'; -import {Box, Text, useApp, useStdin, useStdout, useWindowSize} from 'ink'; +import {Box, Text, useApp, useIsScreenReaderEnabled, useStdin, useStdout, useWindowSize} from 'ink'; import {ProviderManager} from '../providers/provider-manager.js'; import {PlayerController, type PlaybackControlResult} from '../player/player-controller.js'; -import {playbackBackendInstallHint, playbackBackendLabel} from '../player/backend-install.js'; +import {airPlayMacOSOnlyMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, playbackBackendLabel} from '../player/backend-install.js'; import {JsonLibraryStore, stationKey} from '../storage/store.js'; import {defaultAgentControlSettings, type AirPlayDevice, type AppSettings, type Country, type IcyNowPlaying, type LibraryState, type LocationGuess, type PlaybackState, type Screen, type Station} from '../types.js'; import {nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent} from './theme.js'; @@ -20,8 +20,10 @@ import {useAppInput} from './use-app-input.js'; import {useCommandExecutor} from './use-command-executor.js'; import {isAirPlayCodePromptActive} from './screens/AirPlayCodeScreen.js'; import {isAirPlayBackendAvailable} from './airplay-settings.js'; +import {networkPolicy} from '../platform/network.js'; import {audioOutputLabel, resolvedAudioOutput} from './audio-output.js'; import {copyToClipboard, openExternal} from './system-actions.js'; +import {safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText} from '../safety.js'; import {appVersion} from '../version.js'; import {automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall} from '../update-check.js'; import {helpItemCount} from './help-content.js'; @@ -72,10 +74,11 @@ type AppProps = { initialAgentCommand?: RadioSessionCommand; mcpConfigurator?: typeof configureMcpIntegrations; updateChecker?: typeof checkForUpdate; + platform?: NodeJS.Platform; }; const LOADING_SPINNER_MS = 120; -const VISUALIZER_MESSAGE_MS = 4500; +const TRANSIENT_NOTICE_MS = 4500; const LISTENING_HEARTBEAT_MS = 30_000; const COUNTRY_STATIONS_PAGE_SIZE = 120; const COUNTRY_STATIONS_LOAD_AHEAD = 12; @@ -94,15 +97,17 @@ const settingToggleLabel: Record = { automaticUpdateChecks: 'Automatic update checks' }; -export function App({store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate}: AppProps): React.ReactElement { +export function App({store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform}: AppProps): React.ReactElement { const {exit} = useApp(); const {stdin} = useStdin(); const {stdout} = useStdout(); const {columns, rows} = useWindowSize(); + const screenReader = useIsScreenReaderEnabled(); const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]); const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]); const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]); const installedVersion = useMemo(() => appVersion(), []); + const airPlaySupported = isAirPlayPlatformSupported(platform); const [library, setLibrary] = useState(() => store.snapshot()); const settingsRef = useRef(library.settings); @@ -116,6 +121,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const [selected, setSelected] = useState(0); const [settingsPage, setSettingsPage] = useState('root'); const [message, setMessage] = useState(null); + const [persistenceWarning, setPersistenceWarning] = useState(null); + const [presenceWarning, setPresenceWarning] = useState(null); const [footerMessage, setFooterMessage] = useState(null); const [countries, setCountries] = useState([]); const [countryFilter, setCountryFilter] = useState(''); @@ -139,6 +146,22 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const [sleepUntil, setSleepUntil] = useState(null); const [showDiagnostics, setShowDiagnostics] = useState(false); const [capturingTransportAction, setCapturingTransportAction] = useState(null); + const reportActionError = useCallback((error: unknown) => { + setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.'); + }, []); + // Optional history cannot change the result of playback or navigation. Keep + // its warning until an explicit save succeeds; a no-op checkpoint proves nothing. + const persistLibrary = useCallback((write: () => LibraryState): LibraryState | undefined => { + try { + const nextLibrary = write(); + setLibrary(nextLibrary); + return nextLibrary; + } catch (error) { + const detail = sanitizeTerminalText(error instanceof Error ? error.message : String(error)); + setPersistenceWarning(`Library not saved. Set RADIOCLI_HOME to a writable directory.${detail ? ` ${detail}` : ''}`); + return undefined; + } + }, []); const announcedUpdateRef = useRef(false); const automaticUpdateCheckStartedRef = useRef(false); const installingUpdateRef = useRef(false); @@ -177,10 +200,22 @@ export function App({store: providedStore, providers: providedProviders, alarmSe throw new Error('RadioCLI is still starting.'); }); + const showTransientMessage = useCallback((nextMessage: string) => { + if (transientMessageTimerRef.current) { + clearTimeout(transientMessageTimerRef.current); + } + + setMessage(nextMessage); + transientMessageTimerRef.current = setTimeout(() => { + setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage); + transientMessageTimerRef.current = null; + }, TRANSIENT_NOTICE_MS); + }, []); + const theme = library.settings.theme; const displayMode = useMemo( - () => resolveDisplayMode(library.settings), - [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion] + () => resolveDisplayMode(library.settings, process.env, {screenReader, isTTY: Boolean(stdout.isTTY), colorDepth: stdout.getColorDepth?.()}), + [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion, screenReader, stdout] ); const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]); const diagnostics = player.diagnostics(); @@ -285,6 +320,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const layout = computeTerminalLayout(columns, rows, footerRows); const frameWidth = layout.frameWidth; const mouseReportingActive = + !displayMode.screenReader && + process.env.TERM?.toLowerCase() !== 'dumb' && !commandMode && !capturingTransportAction && !editingCountryFilter && @@ -296,7 +333,18 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ); useEffect(() => player.onChange(setPlayback), [player]); - useEffect(() => stdin.isTTY ? registerTuiPresence() : undefined, [stdin]); + useEffect(() => { + if (!stdin.isTTY) return; + try { + const unregister = registerTuiPresence(); + return () => { + try {unregister();} + catch {console.error('RadioCLI could not remove its alarm-control presence marker. Stale markers are checked on the next launch.');} + }; + } catch { + setPresenceWarning('Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.'); + } + }, [stdin]); const playingStationRef = useRef(null); playingStationRef.current = playingStation; @@ -310,26 +358,26 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const key = stationKey(station); if (activeListeningStationRef.current !== key) { activeListeningStationRef.current = key; - setLibrary(store.startListeningSession(station)); + persistLibrary(() => store.startListeningSession(station)); } return; } if (activeListeningStationRef.current) { activeListeningStationRef.current = null; - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); } - }, [playback.ready, playback.state, playingStation, store]); + }, [persistLibrary, playback.ready, playback.state, playingStation, store]); useEffect(() => { if (playback.state !== 'playing' || !playback.ready || !playingStation) { return; } const timer = setInterval(() => { - setLibrary(store.checkpointActiveListeningSession()); + persistLibrary(() => store.checkpointActiveListeningSession()); }, LISTENING_HEARTBEAT_MS); return () => clearInterval(timer); - }, [playback.ready, playback.state, playingStation, store]); + }, [persistLibrary, playback.ready, playback.state, playingStation, store]); useEffect( () => @@ -340,10 +388,10 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const trackKey = `${stationKey(station)}:${metadata.title}`; if (lastRecordedTrackRef.current === trackKey) return; lastRecordedTrackRef.current = trackKey; - setLibrary(store.recordTrack(station, metadata.title)); + persistLibrary(() => store.recordTrack(station, metadata.title!)); } }), - [player, store] + [persistLibrary, player, store] ); useEffect(() => { @@ -368,7 +416,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe useEffect(() => { if ( footerPlayback.state !== 'loading' || - library.settings.reduceMotion || + displayMode.reduceMotion || process.env.RADIOCLI_DISABLE_ANIMATION === '1' || process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1' ) { @@ -378,7 +426,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS); return () => clearInterval(timer); - }, [footerPlayback.state, library.settings.reduceMotion]); + }, [footerPlayback.state, displayMode.reduceMotion]); useEffect(() => { if ( @@ -427,29 +475,32 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const delayMs = sleepUntil - Date.now(); if (delayMs <= 0) { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); - void player.stop(); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError); setSleepUntil(null); return; } const timer = setTimeout(() => { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); - void player.stop(); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError); setSleepUntil(null); }, delayMs); return () => clearTimeout(timer); - }, [player, sleepUntil, store]); + }, [persistLibrary, player, reportActionError, sleepUntil, store]); useEffect(() => { const backends = player.refreshDetectedBackends(); setAvailableBackends(backends); - void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([])); + const network = networkPolicy(); + if (airPlaySupported && !network.offline && !network.lowBandwidth) { + void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([])); + } if (backends.length === 0) { setMessage(`No playback backend found. ${playbackBackendInstallHint()}`); } - }, [player]); + }, [airPlaySupported, player]); const refreshProviderHealth = useCallback(() => { providers.health(settingsRef.current).then(setProviderHealth).catch(() => setProviderHealth({})); @@ -461,9 +512,9 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const refreshUpdateCheck = useCallback(async () => { const updateCheck = await updateChecker({currentVersion: installedVersion}); - setLibrary(store.updateCheckState(updateCheck)); + persistLibrary(() => store.updateCheckState(updateCheck)); return updateCheck; - }, [installedVersion, store, updateChecker]); + }, [installedVersion, persistLibrary, store, updateChecker]); useEffect(() => { if ( @@ -484,12 +535,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe announcedUpdateRef.current = true; setMessage(`Update available: v${updateCheck.latestVersion} · run :update`); } + }).catch(error => { + if (!cancelled) reportActionError(error); }); return () => { cancelled = true; }; - }, [library.settings.automaticUpdateChecks, refreshUpdateCheck]); + }, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]); const setStationContextFor = useCallback((key: StationContextKey, context: StationContext) => { setStationContexts(current => ({...current, [key]: context})); @@ -507,6 +560,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const nextLibrary = store.updateSettings(settings); settingsRef.current = nextLibrary.settings; setLibrary(nextLibrary); + setPersistenceWarning(null); return nextLibrary; }, [store] @@ -523,9 +577,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return; } - updateSettings({preferredBackend}); - setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`); - }, [availableBackends, selectedAirPlayDevice, updateSettings]); + try { + updateSettings({preferredBackend}); + setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`); + } catch (error) { + reportActionError(error); + } + }, [availableBackends, reportActionError, selectedAirPlayDevice, updateSettings]); const updateMediaKeys = useCallback( (mediaKeys: AppSettings['mediaKeys']) => { @@ -605,9 +663,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, [go, openSettingsPage]); const openAirPlayCode = useCallback(() => { + if (!airPlaySupported) { + showTransientMessage(airPlayMacOSOnlyMessage); + return; + } setAirPlayCode(''); go('airplay-code', {resetSelection: true, clearMessage: false}); - }, [go]); + }, [airPlaySupported, go, showTransientMessage]); useEffect(() => { if (isAirPlayCodePromptActive(playback) && screenRef.current !== 'airplay-code') { @@ -617,14 +679,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const shutdown = useCallback(() => { tuneRequestRef.current += 1; - store.finishActiveListeningSession(); - player.stop().finally(exit); - }, [exit, player, store]); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError).finally(exit); + }, [exit, persistLibrary, player, reportActionError, store]); useEffect(() => { const finishForSignal = () => { - store.finishActiveListeningSession(); - void player.stop().finally(() => process.exit(0)); + persistLibrary(() => store.finishActiveListeningSession()); + void player.stop().catch(reportActionError).finally(() => process.exit(0)); }; process.once('SIGTERM', finishForSignal); process.once('SIGHUP', finishForSignal); @@ -632,7 +694,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe process.off('SIGTERM', finishForSignal); process.off('SIGHUP', finishForSignal); }; - }, [player, store]); + }, [persistLibrary, player, reportActionError, store]); const showStationContext = useCallback( (context: StationContext, next: Screen = 'stations', options: NavigationOptions = {}) => { @@ -855,7 +917,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }); selectedByScreenRef.current.search = 0; lastSubmittedSearchRef.current = query.trim(); - setLibrary(store.addSearch(query)); + persistLibrary(() => store.addSearch(query)); searchHistoryRef.current = {cursor: -1, draft: ''}; setSelected(0); setEditingSearch(true); @@ -866,7 +928,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (requestId === searchRequestRef.current) setLoadingStations(false); } }, - [filters, providers, searchQuery, setStationContextFor, store] + [filters, persistLibrary, providers, searchQuery, setStationContextFor, store] ); const loadMoreSearchResults = useCallback(async () => { @@ -1088,8 +1150,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe setPlayingStation(station); setTuningStation(null); playbackQueueRef.current = queue; - const nextLibrary = store.addRecent(station); - if (screenRef.current === 'library') { + const nextLibrary = persistLibrary(() => store.addRecent(station)); + if (nextLibrary && screenRef.current === 'library') { const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters); const nextLibraryIndex = nextLibraryStations.findIndex(item => stationMatches(item, station)); if (nextLibraryIndex >= 0) { @@ -1098,7 +1160,6 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } } - setLibrary(nextLibrary); if (options.openNowPlaying) { go('now-playing'); } @@ -1125,7 +1186,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe setMessage(message); } }, - [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store] + [filters, go, persistLibrary, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store] ); // Resume the most recent station on launch when opted in, like a radio @@ -1156,7 +1217,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe setMessage('Auto-skip canceled. Choose a station and press Enter.'); }, []); - const showTransientFooterMessage = useCallback((nextMessage: string, durationMs = VISUALIZER_MESSAGE_MS) => { + const showTransientFooterMessage = useCallback((nextMessage: string, durationMs = TRANSIENT_NOTICE_MS) => { if (transientFooterMessageTimerRef.current) { clearTimeout(transientFooterMessageTimerRef.current); } @@ -1188,18 +1249,19 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const wasFavorite = store.isFavorite(station); setLibrary(store.toggleFavorite(station)); + setPersistenceWarning(null); const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`; - if (screenRef.current === 'library') { + if (screenRef.current === 'library' || screenRef.current === 'search') { showTransientFooterMessage(favoriteMessage); } else { - setMessage(favoriteMessage); + showTransientMessage(favoriteMessage); } if (!wasFavorite && settingsRef.current.shareDirectoryVotes) { // Best-effort upvote back to the directory; never blocks favoriting. - void providers.vote(station); + void providers.vote(station).catch(reportActionError); } }, - [providers, showTransientFooterMessage, store] + [providers, reportActionError, showTransientFooterMessage, showTransientMessage, store] ); const showControlResult = useCallback((result: PlaybackControlResult) => { @@ -1208,16 +1270,22 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } }, []); - const openStationHomepage = useCallback((station: Station | null) => { + const openStationHomepage = useCallback(async (station: Station | null) => { if (!station?.homepage) { setMessage('This station has no homepage.'); return; } + const url = safeExternalHttpUrl(station.homepage); + if (!url) { + setMessage('This station has no valid HTTP(S) homepage.'); + return; + } + setMessage( - openExternal(station.homepage) + await openExternal(url) ? `Opening homepage: ${station.name}` - : 'That station homepage is not a valid HTTP(S) URL.' + : `Could not open a browser in this session. Homepage: ${sanitizeTerminalText(url) ?? ''}` ); }, []); @@ -1233,13 +1301,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined); } - if (!url) { - setMessage(`No stream URL available for ${station.name}.`); + const safeUrl = url ? safeMediaTarget(url) : null; + if (!safeUrl) { + setMessage(`No safe stream URL available for ${station.name}.`); return; } - const copied = copyToClipboard(url); - setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${url}`); + const copied = await copyToClipboard(safeUrl); + setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${sanitizeTerminalText(safeUrl) ?? ''}`); }, [providers] ); @@ -1269,9 +1338,9 @@ export function App({store: providedStore, providers: providedProviders, alarmSe updateSettings({volume: player.getState().volume}); } showControlResult(result); - }); + }).catch(reportActionError); }, - [player, showControlResult, updateSettings] + [player, reportActionError, showControlResult, updateSettings] ); const adjustVolume = useCallback( @@ -1283,30 +1352,18 @@ export function App({store: providedStore, providers: providedProviders, alarmSe updateSettings({volume: player.getState().volume}); } showControlResult(result); - }); + }).catch(reportActionError); }, - [player, showControlResult, updateSettings] + [player, reportActionError, showControlResult, updateSettings] ); const toggleMute = useCallback(() => { - void player.toggleMute().then(showControlResult); - }, [player, showControlResult]); + void player.toggleMute().then(showControlResult).catch(reportActionError); + }, [player, reportActionError, showControlResult]); const togglePause = useCallback(() => { - void player.togglePause().then(showControlResult); - }, [player, showControlResult]); - - const showTransientMessage = useCallback((nextMessage: string) => { - if (transientMessageTimerRef.current) { - clearTimeout(transientMessageTimerRef.current); - } - - setMessage(nextMessage); - transientMessageTimerRef.current = setTimeout(() => { - setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage); - transientMessageTimerRef.current = null; - }, VISUALIZER_MESSAGE_MS); - }, []); + void player.togglePause().then(showControlResult).catch(reportActionError); + }, [player, reportActionError, showControlResult]); const cycleDisplayColor = useCallback(() => { const theme = nextTheme(settingsRef.current.theme); @@ -1350,6 +1407,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, [updateSettings]); const refreshAirPlayTargets = useCallback(async (announce = true): Promise => { + if (!airPlaySupported) { + if (announce) showTransientMessage(airPlayMacOSOnlyMessage); + return []; + } + if (networkPolicy().offline) { + setMessage('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.'); + return []; + } try { const devices = await player.refreshAirPlayDevices(); setAvailableAirPlayDevices(devices); @@ -1374,14 +1439,18 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return []; } - }, [player]); + }, [airPlaySupported, player, showTransientMessage]); const openAirPlaySettings = useCallback(() => { + if (!airPlaySupported) { + showTransientMessage(airPlayMacOSOnlyMessage); + return; + } const preferredIndex = availableAirPlayDevices.findIndex(device => device.id === settingsRef.current.preferredAirPlayDevice); selectedByScreenRef.current['airplay-settings'] = Math.max(0, preferredIndex); go('airplay-settings', {resetSelection: false}); void refreshAirPlayTargets(false); - }, [availableAirPlayDevices, go, refreshAirPlayTargets]); + }, [airPlaySupported, availableAirPlayDevices, go, refreshAirPlayTargets, showTransientMessage]); const selectAirPlayDeviceAt = useCallback( (index: number) => { @@ -1619,7 +1688,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } const command = updateCommandForInstall(); - const copied = copyToClipboard(command.command); + const copied = await copyToClipboard(command.command); const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion; const prefix = latestVersion ? `Latest v${latestVersion}. ` : ''; const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method'; @@ -1682,6 +1751,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe loadCountry, openAirPlaySettings, openLibrary, + persistLibrary, player, playingStation, providers, @@ -1746,6 +1816,9 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } }); const respond = (message: string, ok = true, data?: RadioSessionResult['data']): RadioSessionResult => ({ok, message, status: sessionStatus(), ...(data ? {data} : {})}); + if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !airPlaySupported) { + return respond(airPlayMacOSOnlyMessage, false, command.type === 'airplay-list' ? [] : undefined); + } if (command.type === 'status') return respond(playingStationRef.current ? `${player.getState().state}: ${playingStationRef.current.name}` : 'RadioCLI is idle.'); if (command.type === 'play') { if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state)) { @@ -1766,7 +1839,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (command.type === 'resume') { const control = await player.resume(); return respond(control.message ?? 'Resumed.', control.ok); } if (command.type === 'stop') { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); playingStationRef.current = null; setPlayingStation(null); @@ -1776,7 +1849,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } if (command.type === 'alarm-preempt') { tuneRequestRef.current += 1; - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); playingStationRef.current = null; setPlayingStation(null); @@ -1798,10 +1871,12 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`); } if (command.type === 'airplay-list') { + if (networkPolicy().offline) return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []); const devices = await refreshAirPlayTargets(false); return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices); } if (command.type === 'airplay-select') { + if (networkPolicy().offline) return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false); const devices = await refreshAirPlayTargets(false); const device = devices.find(item => item.id === command.deviceId); if (!device) return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices); @@ -1968,6 +2043,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe spinnerFrame }); const basePageFooter = pageFooterText({ + airPlaySupported, canEnterAirPlayCode, capturingTransportAction, commandMode, @@ -1991,7 +2067,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const hasActiveMicroPlayback = Boolean( footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused') ); - const microFooter = footerMessage ?? message ?? ( + const statusMessage = message ?? persistenceWarning ?? presenceWarning; + const microFooter = footerMessage ?? statusMessage ?? ( pageFooterOwnsCompactRow ? pageFooter : footerPlayback.state === 'loading' && playbackFooter @@ -2002,13 +2079,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ); const compactGlobalFooter = '←/→ tabs · ? help · q quit'; const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2; - const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter); + const fullStatusRows = fullStatusFooterRows(screen, statusMessage, footerMessage, playbackFooter); const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve); - const compactStatus = footerMessage ?? message ?? playbackFooter; + const compactStatus = footerMessage ?? statusMessage ?? playbackFooter; const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0)); const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve); const microLegendWidth = Math.max(1,frameWidth-versionReserve); - const microLegend = commandMode || capturingTransportAction || footerMessage || message + const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage ? microFooter : microShortcutFooterText(microFooter, microLegendWidth); const footerText = (value: string): string => displayMode.ascii ? toAsciiSafe(value) : value; @@ -2018,7 +2095,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe screen={screen} playback={playback} receiverStyle={library.settings.receiverStyle} - reduceMotion={Boolean(library.settings.reduceMotion)} + reduceMotion={displayMode.reduceMotion} > ) : ( <> - {layout.mode === 'micro' ? + {layout.mode === 'micro' ? {footerText(truncate(microLegend, microLegendWidth))} : <> - {compactStatus ? {footerText(truncate(compactStatus, frameWidth))} : null} + {compactStatus ? {footerText(truncate(compactStatus, frameWidth))} : null} {compactLegendRows.map((row, index) => index===compactLegendRows.length-1?{footerText(row)}:{footerText(row)})} } diff --git a/src/ui/AppContent.tsx b/src/ui/AppContent.tsx index e9a226a..4e704e0 100644 --- a/src/ui/AppContent.tsx +++ b/src/ui/AppContent.tsx @@ -68,6 +68,7 @@ type AppContentProps = { theme: ThemeName; updateCheck?: UpdateCheckState; alarmTui: AlarmTuiController; + airPlaySupported?: boolean; }; export function AppContent({ @@ -105,7 +106,8 @@ export function AppContent({ storePath, theme, updateCheck, - alarmTui + alarmTui, + airPlaySupported = true }: AppContentProps): React.ReactElement { if (layout.compact) { if (screen === 'alarms') return ; @@ -145,6 +147,7 @@ export function AppContent({ stationError={stationContext.error} filterLabel={filterLabel} sleepLabel={sleepLabel} + airPlaySupported={airPlaySupported} /> ); } @@ -286,6 +289,7 @@ export function AppContent({ diagnostics={diagnostics} width={frameWidth} height={layout.contentRows} + airPlaySupported={airPlaySupported} /> ); } diff --git a/src/ui/__snapshots__/visual-baseline.test.tsx.snap b/src/ui/__snapshots__/visual-baseline.test.tsx.snap new file mode 100644 index 0000000..3637fa6 --- /dev/null +++ b/src/ui/__snapshots__/visual-baseline.test.tsx.snap @@ -0,0 +1,11325 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`palette baseline > 'alarm selection' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mAlarms[22m[38;2;138;150;168m ──────────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mScheduler: Ready[39m [49m +[48;2;7;10;15m[38;2;116;242;138mNext wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m Create alarm [49m +[1m[48;2;7;10;15m[38;2;116;242;138m› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM[39m[22m [49m +[48;2;7;10;15m Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194mNative scheduling is ready; the terminal does not need to stay open after save.[39m [49m +[1m[48;2;116;242;138m[30m BETA · Experimental. Use a secondary device for critical wake-ups. [39m[22m[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'amber/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;176;0mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'amber/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;176;0mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;176;0mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;176;0m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;214;138;0m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;176;0mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;176;0mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;176;0m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;176;0m2[39m [38;2;170;180;194mLongest streak: [38;2;255;176;0m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;176;0m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;176;0m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;176;0m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;176;0m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;95;55;0m [48;2;9;13;20m [48;2;154;98;0m [48;2;9;13;20m [48;2;214;138;0m [48;2;9;13;20m [48;2;255;176;0m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'blue/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;83;168;255mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'blue/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;83;168;255mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;83;168;255mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;83;168;255m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;51;136;221m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;83;168;255mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;83;168;255mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;83;168;255m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;83;168;255m2[39m [38;2;170;180;194mLongest streak: [38;2;83;168;255m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;83;168;255m2 days[39m [38;2;170;180;194mStations listened: [38;2;83;168;255m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;83;168;255m2/371[39m [38;2;170;180;194mStations counted after: [38;2;83;168;255m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;18;56;95m [48;2;9;13;20m [48;2;31;111;235m [48;2;9;13;20m [48;2;51;136;221m [48;2;9;13;20m [48;2;83;168;255m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'copper/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;208;135;112mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'copper/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;208;135;112mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;135;112mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;208;135;112m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;184;111;82m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;135;112mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;208;135;112mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;208;135;112m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;208;135;112m2[39m [38;2;170;180;194mLongest streak: [38;2;208;135;112m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;208;135;112m2 days[39m [38;2;170;180;194mStations listened: [38;2;208;135;112m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;208;135;112m2/371[39m [38;2;170;180;194mStations counted after: [38;2;208;135;112m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;68;40;31m [48;2;9;13;20m [48;2;127;79;55m [48;2;9;13;20m [48;2;184;111;82m [48;2;9;13;20m [48;2;208;135;112m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'coral/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;126;107mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'coral/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;126;107mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;126;107mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;126;107m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;214;92;73m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;126;107mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;126;107mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;126;107m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;126;107m2[39m [38;2;170;180;194mLongest streak: [38;2;255;126;107m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;126;107m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;126;107m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;126;107m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;126;107m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;76;32;24m [48;2;9;13;20m [48;2;154;64;50m [48;2;9;13;20m [48;2;214;92;73m [48;2;9;13;20m [48;2;255;126;107m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'cyan/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;34;211;238mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'cyan/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;34;211;238mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;34;211;238mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;34;211;238m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;34;184;207m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;34;211;238mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;34;211;238mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;34;211;238m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;34;211;238m2[39m [38;2;170;180;194mLongest streak: [38;2;34;211;238m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;34;211;238m2 days[39m [38;2;170;180;194mStations listened: [38;2;34;211;238m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;34;211;238m2/371[39m [38;2;170;180;194mStations counted after: [38;2;34;211;238m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;14;59;66m [48;2;9;13;20m [48;2;22;138;158m [48;2;9;13;20m [48;2;34;184;207m [48;2;9;13;20m [48;2;34;211;238m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'default receiver' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;116;242;138m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [1m[38;2;116;242;138mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;116;242;138mPLAYING[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⢀⣤⣠⣤⣄ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⣤⢾⣓⡋⣰⣾⠉⠛⠙⢻⣤ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⢠⣞⠃⠈⣱⡿⠃ ⢳⡆⠈⠹⠳⢦⡄ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠿⣿⣿⣶⠻⠉ ⠠⣽⠓⠿⢶⣆ ⢽ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⣠⣾⠾⠙⠚⡙⠚⠳⠿⠳⠂⠘⠋⠁ ⠘⠂ ⣼⠃ ⠈ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⣀⡀⣄⣤⣦⠞⠃ ⠁ ⠘⣳⠄⢨⡏ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠰⠟⠉⠉⣽⡟⠉⠰⣷⠄⢀⡀ ⢠⡆ ⢸⣿⣾⣅ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠻⢷⣩⣀⡀ ⣠⡏ ⠘⢿⣿⡄ ⣿⠿⣾⡿⣴⢶⡴⠾⢿⣗⣠⡌⢹⣿⠚⠛⠺⣿⠍ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠟⠿⣿⢾⠞⠋ ⢰⣿⣯ ⢯ ⢀⡀⢀⠙⠓⢿⣿⢿⠆ ⠈⣹⣷ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⢼⠟⢿⣷⣜⣷⣤⣄⡀ ⠹⠞⢿⣿⣷⣷⡿⠋⠈ ⢽⣿⡆ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠈⠳⣇⣤⡈⠙⢹⣟⣹⢷⡀ ⠟⠛⠋ ⢩⣿⣿ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠘⣿⢇⡀ ⠈⠿⠂⠙ ⠛ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠻⣿⣷⣆⡀ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m ⠓⠛⠓⠛⠙⠛⠙⠛⠁ [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138mBjork - Joga[39m [33m★ Favorite[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'green/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;116;242;138mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'green/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;116;242;138mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;116;242;138m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;57;211;83m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;116;242;138mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;116;242;138mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;116;242;138m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;116;242;138m2[39m [38;2;170;180;194mLongest streak: [38;2;116;242;138m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;116;242;138m2 days[39m [38;2;170;180;194mStations listened: [38;2;116;242;138m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;116;242;138m2/371[39m [38;2;170;180;194mStations counted after: [38;2;116;242;138m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;14;68;41m [48;2;9;13;20m [48;2;38;166;65m [48;2;9;13;20m [48;2;57;211;83m [48;2;9;13;20m [48;2;116;242;138m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'ice/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;185;246;255mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'ice/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;185;246;255mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;185;246;255mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;185;246;255m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;134;220;232m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;185;246;255mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;185;246;255mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;185;246;255m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;185;246;255m2[39m [38;2;170;180;194mLongest streak: [38;2;185;246;255m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;185;246;255m2 days[39m [38;2;170;180;194mStations listened: [38;2;185;246;255m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;185;246;255m2/371[39m [38;2;170;180;194mStations counted after: [38;2;185;246;255m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;36;71;77m [48;2;9;13;20m [48;2;74;149;160m [48;2;9;13;20m [48;2;134;220;232m [48;2;9;13;20m [48;2;185;246;255m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'lime/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;163;230;53mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'lime/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;163;230;53mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;163;230;53mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;163;230;53m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;132;179;43m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;163;230;53mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;163;230;53mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;163;230;53m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;163;230;53m2[39m [38;2;170;180;194mLongest streak: [38;2;163;230;53m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;163;230;53m2 days[39m [38;2;170;180;194mStations listened: [38;2;163;230;53m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;163;230;53m2/371[39m [38;2;170;180;194mStations counted after: [38;2;163;230;53m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;47;61;18m [48;2;9;13;20m [48;2;95;125;31m [48;2;9;13;20m [48;2;132;179;43m [48;2;9;13;20m [48;2;163;230;53m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'mono scene' 1`] = ` +"[1m[48;2;7;10;15m[38;2;208;208;208mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;208;208;208m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [1m[38;2;208;208;208mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;208;208;208mPLAYING[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;10;10;14m[38;2;10;10;14m [38;2;92;92;92m˙[38;2;10;10;14m [38;2;216;216;216m│[38;2;10;10;14m [38;2;92;92;92m˙[38;2;10;10;14m [38;2;255;255;255m•[38;2;10;10;14m [38;2;216;216;216m│[38;2;10;10;14m [38;2;106;106;106m˙˙[38;2;10;10;14m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;11;11;16m[38;2;11;11;16m [38;2;216;216;216m│[38;2;11;11;16m [38;2;216;216;216m│[38;2;11;11;16m [48;2;17;17;22m[38;2;88;88;96m▀▀▀▀[48;2;11;11;16m[38;2;11;11;16m [38;2;106;106;106m˙[38;2;11;11;16m [38;2;240;240;240m••[38;2;11;11;16m [38;2;106;106;106m˙[38;2;11;11;16m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;13;13;19m[38;2;13;13;19m [48;2;86;86;96m[38;2;255;255;255m•[38;2;86;86;96m [38;2;255;255;255m•[48;2;13;13;19m[38;2;13;13;19m [38;2;216;216;216m│[38;2;13;13;19m [48;2;17;17;22m[38;2;72;72;88m│[38;2;190;190;190m·[38;2;74;74;82m│[38;2;226;226;226m·[48;2;13;13;19m[38;2;13;13;19m [38;2;106;106;106m˙[38;2;240;240;240m•[1m●[22m[38;2;13;13;19m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;14;14;21m[38;2;14;14;21m [48;2;86;86;96m[38;2;86;86;96m [38;2;255;255;255m•[48;2;14;14;21m[38;2;14;14;21m [38;2;43;43;52m│[38;2;14;14;21m [48;2;17;17;22m[38;2;255;255;255m╱◆╲[48;2;14;14;21m[38;2;14;14;21m [48;2;17;17;22m[38;2;72;72;88m│[38;2;120;120;120m▪[38;2;74;74;82m│[38;2;43;43;52m░[48;2;14;14;21m[38;2;14;14;21m [38;2;240;240;240m••[38;2;14;14;21m [38;2;92;92;92m˙[38;2;14;14;21m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;16;16;23m[38;2;16;16;23m [48;2;86;86;96m[38;2;255;255;255m•••[48;2;16;16;23m[38;2;16;16;23m [48;2;25;25;32m[38;2;74;74;82m▀▀▀▀▀[48;2;16;16;23m[38;2;16;16;23m [48;2;17;17;22m[38;2;255;255;255m╱◆╲[48;2;16;16;23m[38;2;16;16;23m [48;2;25;25;32m[38;2;74;74;82m▀▀▀▀▀▀[48;2;16;16;23m[38;2;16;16;23m [48;2;17;17;22m[38;2;48;48;55m▄[38;2;226;226;226m·[38;2;48;48;55m▄▄[48;2;16;16;23m[38;2;16;16;23m [38;2;92;92;92m˙[38;2;16;16;23m [38;2;106;106;106m˙˙˙[38;2;16;16;23m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m▀▀▀[48;2;18;18;25m[38;2;18;18;25m [38;2;255;255;255m▀[48;2;17;17;22m[38;2;216;216;216m▀[38;2;255;255;255m▀[38;2;216;216;216m▀[38;2;255;255;255m▀[38;2;216;216;216m▀[48;2;18;18;25m[38;2;255;255;255m▀[38;2;18;18;25m [38;2;92;92;92m˙[38;2;18;18;25m [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m█[48;2;18;18;25m[38;2;18;18;25m [48;2;17;17;22m[38;2;216;216;216m╱╱◆╲╲[48;2;18;18;25m[38;2;18;18;25m [38;2;92;92;92m˙[38;2;18;18;25m [48;2;25;25;32m[38;2;226;226;226m·[38;2;25;25;32m [38;2;190;190;190m•[38;2;74;74;82m│[38;2;25;25;32m [48;2;18;18;25m[38;2;18;18;25m [48;2;17;17;22m[38;2;17;17;22m [38;2;72;72;88m│[38;2;17;17;22m [38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [48;2;18;18;25m[38;2;18;18;25m [48;2;17;17;22m[38;2;74;74;82m▀▀▀[48;2;18;18;25m[38;2;18;18;25m [38;2;43;43;52m│[38;2;18;18;25m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;17;17;22m [38;2;120;120;120m▪[38;2;74;74;82m│[38;2;17;17;22m [48;2;19;19;28m[38;2;19;19;28m [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;216;216;216m╱╱╱◆╲╲╲[48;2;19;19;28m[38;2;19;19;28m [48;2;25;25;32m[38;2;74;74;82m█[38;2;120;120;120m▪[38;2;43;43;52m░[38;2;25;25;32m [38;2;74;74;82m│[38;2;226;226;226m·[38;2;25;25;32m [38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;17;17;22m [38;2;72;72;88m│[38;2;190;190;190m·[38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;19;19;28m[38;2;19;19;28m [48;2;17;17;22m[38;2;74;74;82m▀▀▀▀[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;17;17;22m [38;2;226;226;226m•[38;2;74;74;82m│[38;2;226;226;226m·[38;2;17;17;22m [48;2;21;21;30m[38;2;21;21;30m [48;2;25;25;32m[38;2;74;74;82m█[38;2;52;52;60m▄▄[38;2;25;25;32m [38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;17;17;22m [38;2;216;216;216m│[38;2;74;74;82m│[38;2;43;43;52m░[38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;25;25;32m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;52;52;60m▄[38;2;190;190;190m•[38;2;52;52;60m▄[38;2;190;190;190m•[38;2;25;25;32m [38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;120;120;120m▪[38;2;48;48;55m▄[38;2;190;190;190m·[38;2;48;48;55m▄[38;2;120;120;120m▪[38;2;17;17;22m [48;2;21;21;30m[38;2;21;21;30m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;21;21;30m[38;2;43;43;52m│[38;2;21;21;30m [48;2;17;17;22m[38;2;74;74;82m█[38;2;43;43;52m░[38;2;74;74;82m█[48;2;21;21;30m[38;2;21;21;30m [48;2;17;17;22m[38;2;74;74;82m█[38;2;43;43;52m░[38;2;17;17;22m [38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m▀▀▀▀▀▀[48;2;28;28;37m[38;2;28;28;37m [48;2;32;32;40m[38;2;32;32;40m░[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;17;17;22m [38;2;120;120;120m▪[38;2;74;74;82m│[38;2;43;43;52m░[38;2;17;17;22m [38;2;74;74;82m█[48;2;28;28;37m[38;2;28;28;37m [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;226;226;226m•[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;48;48;55m▄▄[38;2;216;216;216m│[38;2;48;48;55m▄▄[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;25;25;32m [38;2;226;226;226m·[38;2;74;74;82m│[38;2;25;25;32m [38;2;74;74;82m█[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;120;120;120m▪[38;2;43;43;52m░[38;2;17;17;22m [38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [48;2;28;28;37m[38;2;28;28;37m [48;2;32;32;40m[38;2;32;32;40m░[48;2;28;28;37m[38;2;28;28;37m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;28;28;37m[38;2;28;28;37m [48;2;25;25;32m[38;2;25;25;32m [38;2;43;43;52m░[38;2;74;74;82m│[38;2;190;190;190m•[38;2;25;25;32m [48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;43;43;52m░[38;2;74;74;82m█[48;2;28;28;37m[38;2;28;28;37m [48;2;17;17;22m[38;2;74;74;82m█[38;2;48;48;55m▄▄[38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;25;25;32m[38;2;74;74;82m▀▀▀[48;2;31;31;41m[38;2;31;31;41m [48;2;17;17;22m[38;2;17;17;22m [38;2;74;74;82m│[38;2;17;17;22m [48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m│[38;2;17;17;22m [38;2;226;226;226m·[38;2;74;74;82m█[48;2;31;31;41m[38;2;31;31;41m [48;2;25;25;32m[38;2;74;74;82m█[38;2;52;52;60m▄▄[38;2;25;25;32m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;226;226;226m•[38;2;74;74;82m│[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[38;2;120;120;120m·[48;2;25;25;32m[38;2;74;74;82m█[38;2;52;52;60m▄▄[38;2;43;43;52m░[38;2;52;52;60m▄[38;2;226;226;226m·[38;2;25;25;32m [38;2;74;74;82m█[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;17;17;22m[38;2;190;190;190m•[38;2;48;48;55m▄[38;2;43;43;52m░[38;2;48;48;55m▄▄[38;2;43;43;52m░[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░[48;2;31;31;41m[38;2;31;31;41m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;43;43;52m░[38;2;25;25;32m [38;2;74;74;82m│[38;2;25;25;32m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;43;43;52m░[38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m█[48;2;35;35;44m[38;2;35;35;44m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;226;226;226m·[38;2;74;74;82m│[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;17;17;22m[38;2;74;74;82m█[38;2;226;226;226m·[38;2;48;48;55m▄[38;2;17;17;22m [38;2;48;48;55m▄[38;2;226;226;226m•[38;2;17;17;22m [38;2;48;48;55m▄[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;43;43;52m░[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░░[48;2;17;17;22m[38;2;74;74;82m█[38;2;120;120;120m▪[38;2;17;17;22m [38;2;74;74;82m│[38;2;226;226;226m·[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;25;25;32m [38;2;74;74;82m│[38;2;25;25;32m [38;2;74;74;82m█[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;43;43;52m░[38;2;226;226;226m·[38;2;74;74;82m│[38;2;72;72;88m│[38;2;17;17;22m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;35;35;44m[38;2;35;35;44m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;74;74;82m█[38;2;190;190;190m•[38;2;52;52;60m▄[38;2;25;25;32m [38;2;52;52;60m▄▄[38;2;25;25;32m [38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;74;74;82m█[38;2;190;190;190m•[38;2;74;74;82m█[48;2;32;32;40m[38;2;32;32;40m [48;2;17;17;22m[38;2;74;74;82m█[38;2;17;17;22m [38;2;74;74;82m█[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;17;17;22m[38;2;17;17;22m [38;2;208;208;208m░[48;2;32;32;40m[38;2;32;32;40m░[48;2;25;25;32m[38;2;208;208;208m░ [38;2;25;25;32m [48;2;38;38;48m[38;2;38;38;48m [48;2;17;17;22m[38;2;208;208;208m░ ░ ░ [38;2;17;17;22m [38;2;208;208;208m [48;2;32;32;40m[38;2;32;32;40m░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;17;17;22m[38;2;17;17;22m [38;2;208;208;208m [38;2;17;17;22m [38;2;208;208;208m ░[38;2;17;17;22m [38;2;208;208;208m░ [48;2;32;32;40m[38;2;32;32;40m░ ░[48;2;25;25;32m[38;2;208;208;208m ░[38;2;25;25;32m [48;2;32;32;40m[38;2;32;32;40m░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░░[48;2;17;17;22m[38;2;208;208;208m░[38;2;17;17;22m [38;2;208;208;208m░[38;2;17;17;22m [38;2;208;208;208m [38;2;17;17;22m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;25;25;32m [38;2;208;208;208m [38;2;25;25;32m [38;2;208;208;208m [38;2;25;25;32m [38;2;208;208;208m ░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;17;17;22m [38;2;72;72;88m│[38;2;208;208;208m░[38;2;17;17;22m [38;2;72;72;88m│[38;2;208;208;208m [38;2;17;17;22m [48;2;32;32;40m[38;2;32;32;40m░[48;2;38;38;48m[38;2;38;38;48m [48;2;32;32;40m[38;2;32;32;40m░ [48;2;25;25;32m[38;2;25;25;32m [38;2;208;208;208m ░[38;2;25;25;32m [48;2;32;32;40m[38;2;32;32;40m░[48;2;17;17;22m[38;2;208;208;208m░ ░[48;2;32;32;40m[38;2;32;32;40m [48;2;17;17;22m[38;2;208;208;208m░ [38;2;17;17;22m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;13;13;18m[38;2;104;104;107m [38;2;226;226;226m•[38;2;244;244;244m•[38;2;104;104;107m [38;2;168;168;168m•[38;2;104;104;107m ─[38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m ˙ [38;2;244;244;244m•[38;2;226;226;226m•[38;2;104;104;107m [38;2;168;168;168m•[38;2;104;104;107m˙[38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m [38;2;244;244;244m•[38;2;104;104;107m ˙ [38;2;226;226;226m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m [38;2;168;168;168m•[38;2;120;120;120m•[38;2;104;104;107m ˙[38;2;226;226;226m•[38;2;104;104;107m ˙[38;2;153;153;154m•[38;2;226;226;226m•[38;2;168;168;168m•[38;2;190;190;190m•[38;2;104;104;107m ˙ ─[38;2;190;190;190m•[38;2;104;104;107m [38;2;226;226;226m•[38;2;104;104;107m˙ [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;11;11;15m[38;2;90;90;92m˙── [38;2;138;138;140m·[38;2;126;126;127m·[38;2;90;90;92m [38;2;88;88;89m·[38;2;90;90;92m ˙[38;2;138;138;140m·[38;2;90;90;92m ─ [38;2;126;126;127m·[38;2;138;138;140m·[38;2;90;90;92m [38;2;88;88;89m·[38;2;69;69;71m·[38;2;90;90;92m ˙ [38;2;138;138;140m·[38;2;90;90;92m [38;2;138;138;140m·[38;2;90;90;92m [38;2;126;126;127m·[38;2;90;90;92m ˙ [38;2;138;138;140m·[38;2;90;90;92m [38;2;127;127;129m·[38;2;90;90;92m [38;2;138;138;140m·[38;2;90;90;92m ˙[38;2;88;88;89m·[38;2;90;90;92m─ [38;2;138;138;140m·[38;2;90;90;92m ˙[38;2;138;138;140m·[38;2;88;88;89m·[38;2;108;108;109m·[38;2;90;90;92m˙ [38;2;138;138;140m·[38;2;90;90;92m [39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;9;9;13m[38;2;75;75;77m˙ [38;2;127;127;129m·[38;2;75;75;77m [38;2;127;127;129m·[38;2;75;75;77m ˙ ─ ─ ─ ─ ˙˙ ˙ ˙ [38;2;108;108;109m·[38;2;75;75;77m [38;2;91;91;92m·[38;2;75;75;77m ─ [38;2;108;108;109m·[38;2;75;75;77m ˙[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [48;2;7;7;10m[38;2;59;59;62m ˙ ˙˙ ˙˙˙[38;2;127;127;129m·[38;2;59;59;62m [38;2;69;69;71m·[38;2;127;127;129m·[38;2;59;59;62m ˙ [38;2;127;127;129m·[38;2;59;59;62m─ ˙ ─ ˙ · ˙ ˙ ˙ ˙[39m[48;2;9;13;20m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208mBjork - Joga[39m [33m★ Favorite[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m│[39m [38;2;208;208;208m│[39m[49m +[48;2;9;13;20m[38;2;208;208;208m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'mono/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;208;208;208mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'mono/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;208;208;208mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;208;208mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;208;208;208m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;176;176;176m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;208;208;208mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;208;208;208mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;208;208;208m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;208;208;208m2[39m [38;2;170;180;194mLongest streak: [38;2;208;208;208m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;208;208;208m2 days[39m [38;2;170;180;194mStations listened: [38;2;208;208;208m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;208;208;208m2/371[39m [38;2;170;180;194mStations counted after: [38;2;208;208;208m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;58;58;58m [48;2;9;13;20m [48;2;118;118;118m [48;2;9;13;20m [48;2;176;176;176m [48;2;9;13;20m [48;2;208;208;208m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'receiver accent' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;116;242;138m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [1m[38;2;116;242;138mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;116;242;138mPLAYING[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;57;211;83m◉[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [38;2;38;166;65m∘[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;38;166;65m●[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ ◉ ◉ [38;2;57;211;83m●[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;26;32;41m·[38;2;116;242;138m [38;2;14;68;41m·[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;57;211;83m●[38;2;116;242;138m ◉ ◉ [38;2;57;211;83m◉[38;2;116;242;138m [38;2;38;166;65m•[38;2;116;242;138m [38;2;14;68;41m∘[38;2;116;242;138m [39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138mBjork - Joga[39m [33m★ Favorite[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'rose/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;143;199mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'rose/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;143;199mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;143;199mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;143;199m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;214;92;154m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;143;199mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;143;199mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;143;199m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;143;199m2[39m [38;2;170;180;194mLongest streak: [38;2;255;143;199m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;143;199m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;143;199m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;143;199m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;143;199m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;76;34;56m [48;2;9;13;20m [48;2;143;63;106m [48;2;9;13;20m [48;2;214;92;154m [48;2;9;13;20m [48;2;255;143;199m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'ruby/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;255;95;135mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'ruby/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;255;95;135mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;95;135mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;255;95;135m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;201;63;104m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;255;95;135mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;255;95;135mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;255;95;135m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;255;95;135m2[39m [38;2;170;180;194mLongest streak: [38;2;255;95;135m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;255;95;135m2 days[39m [38;2;170;180;194mStations listened: [38;2;255;95;135m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;255;95;135m2/371[39m [38;2;170;180;194mStations counted after: [38;2;255;95;135m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;76;18;48m [48;2;9;13;20m [48;2;143;39;79m [48;2;9;13;20m [48;2;201;63;104m [48;2;9;13;20m [48;2;255;95;135m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'scene' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mNow playing[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;9;13;20m[38;2;116;242;138m╭────────────────────────────────────────────────────────────────────────────────────────────────╮[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [1m[38;2;116;242;138mKEXP 90.3 FM[22m[38;2;170;180;194m · SEATTLE, UNITED STATES[39m [38;2;116;242;138mPLAYING[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;4;7;20m[38;2;4;7;20m [38;2;122;138;184m˙[38;2;4;7;20m [38;2;199;210;232m│[38;2;4;7;20m [38;2;122;138;184m˙[38;2;4;7;20m [38;2;242;246;255m•[38;2;4;7;20m [38;2;199;210;232m│[38;2;4;7;20m [38;2;138;148;184m˙˙[38;2;4;7;20m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;5;9;25m[38;2;5;9;25m [38;2;199;210;232m│[38;2;5;9;25m [38;2;199;210;232m│[38;2;5;9;25m [48;2;11;17;40m[38;2;58;90;158m▀▀▀▀[48;2;5;9;25m[38;2;5;9;25m [38;2;138;148;184m˙[38;2;5;9;25m [38;2;232;236;248m••[38;2;5;9;25m [38;2;138;148;184m˙[38;2;5;9;25m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;6;11;29m[38;2;6;11;29m [48;2;143;156;192m[38;2;237;243;255m•[38;2;143;156;192m [38;2;237;243;255m•[48;2;6;11;29m[38;2;6;11;29m [38;2;199;210;232m│[38;2;6;11;29m [48;2;11;17;40m[38;2;42;74;134m│[38;2;169;212;255m·[38;2;38;55;92m│[38;2;255;209;102m·[48;2;6;11;29m[38;2;6;11;29m [38;2;138;148;184m˙[38;2;232;236;248m•[1m●[22m[38;2;6;11;29m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;6;13;34m[38;2;6;13;34m [48;2;143;156;192m[38;2;143;156;192m [38;2;237;243;255m•[48;2;6;13;34m[38;2;6;13;34m [38;2;26;36;68m│[38;2;6;13;34m [48;2;11;17;40m[38;2;237;243;255m╱◆╲[48;2;6;13;34m[38;2;6;13;34m [48;2;11;17;40m[38;2;42;74;134m│[38;2;90;111;158m▪[38;2;38;55;92m│[38;2;26;36;68m░[48;2;6;13;34m[38;2;6;13;34m [38;2;232;236;248m••[38;2;6;13;34m [38;2;122;138;184m˙[38;2;6;13;34m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;7;15;38m[38;2;7;15;38m [48;2;143;156;192m[38;2;237;243;255m•••[48;2;7;15;38m[38;2;7;15;38m [48;2;14;23;50m[38;2;38;55;92m▀▀▀▀▀[48;2;7;15;38m[38;2;7;15;38m [48;2;11;17;40m[38;2;237;243;255m╱◆╲[48;2;7;15;38m[38;2;7;15;38m [48;2;14;23;50m[38;2;38;55;92m▀▀▀▀▀▀[48;2;7;15;38m[38;2;7;15;38m [48;2;11;17;40m[38;2;26;38;69m▄[38;2;255;209;102m·[38;2;26;38;69m▄▄[48;2;7;15;38m[38;2;7;15;38m [38;2;122;138;184m˙[38;2;7;15;38m [38;2;138;148;184m˙˙˙[38;2;7;15;38m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m▀▀▀[48;2;8;17;43m[38;2;8;17;43m [38;2;237;243;255m▀[48;2;11;17;40m[38;2;199;210;232m▀[38;2;237;243;255m▀[38;2;199;210;232m▀[38;2;237;243;255m▀[38;2;199;210;232m▀[48;2;8;17;43m[38;2;237;243;255m▀[38;2;8;17;43m [38;2;122;138;184m˙[38;2;8;17;43m [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m█[48;2;8;17;43m[38;2;8;17;43m [48;2;11;17;40m[38;2;199;210;232m╱╱◆╲╲[48;2;8;17;43m[38;2;8;17;43m [38;2;122;138;184m˙[38;2;8;17;43m [48;2;14;23;50m[38;2;255;209;102m·[38;2;14;23;50m [38;2;169;212;255m•[38;2;38;55;92m│[38;2;14;23;50m [48;2;8;17;43m[38;2;8;17;43m [48;2;11;17;40m[38;2;11;17;40m [38;2;42;74;134m│[38;2;11;17;40m [38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [48;2;8;17;43m[38;2;8;17;43m [48;2;11;17;40m[38;2;38;55;92m▀▀▀[48;2;8;17;43m[38;2;8;17;43m [38;2;26;36;68m│[38;2;8;17;43m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;11;17;40m [38;2;90;111;158m▪[38;2;38;55;92m│[38;2;11;17;40m [48;2;9;19;47m[38;2;9;19;47m [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;199;210;232m╱╱╱◆╲╲╲[48;2;9;19;47m[38;2;9;19;47m [48;2;14;23;50m[38;2;38;55;92m█[38;2;90;111;158m▪[38;2;26;36;68m░[38;2;14;23;50m [38;2;38;55;92m│[38;2;255;209;102m·[38;2;14;23;50m [38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;11;17;40m [38;2;42;74;134m│[38;2;169;212;255m·[38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;9;19;47m[38;2;9;19;47m [48;2;11;17;40m[38;2;38;55;92m▀▀▀▀[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;11;17;40m [38;2;255;209;102m•[38;2;38;55;92m│[38;2;255;209;102m·[38;2;11;17;40m [48;2;10;21;52m[38;2;10;21;52m [48;2;14;23;50m[38;2;38;55;92m█[38;2;27;41;73m▄▄[38;2;14;23;50m [38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;11;17;40m [38;2;199;210;232m│[38;2;38;55;92m│[38;2;26;36;68m░[38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;14;23;50m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;27;41;73m▄[38;2;169;212;255m•[38;2;27;41;73m▄[38;2;169;212;255m•[38;2;14;23;50m [38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;90;111;158m▪[38;2;26;38;69m▄[38;2;169;212;255m·[38;2;26;38;69m▄[38;2;90;111;158m▪[38;2;11;17;40m [48;2;10;21;52m[38;2;10;21;52m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;10;21;52m[38;2;26;36;68m│[38;2;10;21;52m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;36;68m░[38;2;38;55;92m█[48;2;10;21;52m[38;2;10;21;52m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;36;68m░[38;2;11;17;40m [38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m▀▀▀▀▀▀[48;2;23;27;63m[38;2;23;27;63m [48;2;17;26;53m[38;2;17;26;53m░[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;11;17;40m [38;2;90;111;158m▪[38;2;38;55;92m│[38;2;26;36;68m░[38;2;11;17;40m [38;2;38;55;92m█[48;2;23;27;63m[38;2;23;27;63m [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;255;209;102m•[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;38;69m▄▄[38;2;199;210;232m│[38;2;26;38;69m▄▄[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;14;23;50m [38;2;255;209;102m·[38;2;38;55;92m│[38;2;14;23;50m [38;2;38;55;92m█[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;90;111;158m▪[38;2;26;36;68m░[38;2;11;17;40m [38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [48;2;23;27;63m[38;2;23;27;63m [48;2;17;26;53m[38;2;17;26;53m░[48;2;23;27;63m[38;2;23;27;63m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;23;27;63m[38;2;23;27;63m [48;2;14;23;50m[38;2;14;23;50m [38;2;26;36;68m░[38;2;38;55;92m│[38;2;169;212;255m•[38;2;14;23;50m [48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;36;68m░[38;2;38;55;92m█[48;2;23;27;63m[38;2;23;27;63m [48;2;11;17;40m[38;2;38;55;92m█[38;2;26;38;69m▄▄[38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;14;23;50m[38;2;38;55;92m▀▀▀[48;2;30;30;69m[38;2;30;30;69m [48;2;11;17;40m[38;2;11;17;40m [38;2;38;55;92m│[38;2;11;17;40m [48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m│[38;2;11;17;40m [38;2;255;209;102m·[38;2;38;55;92m█[48;2;30;30;69m[38;2;30;30;69m [48;2;14;23;50m[38;2;38;55;92m█[38;2;27;41;73m▄▄[38;2;14;23;50m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;255;209;102m•[38;2;38;55;92m│[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[38;2;90;111;158m·[48;2;14;23;50m[38;2;38;55;92m█[38;2;27;41;73m▄▄[38;2;26;36;68m░[38;2;27;41;73m▄[38;2;255;209;102m·[38;2;14;23;50m [38;2;38;55;92m█[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;11;17;40m[38;2;169;212;255m•[38;2;26;38;69m▄[38;2;26;36;68m░[38;2;26;38;69m▄▄[38;2;26;36;68m░[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░[48;2;30;30;69m[38;2;30;30;69m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;26;36;68m░[38;2;14;23;50m [38;2;38;55;92m│[38;2;14;23;50m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;26;36;68m░[38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m█[48;2;37;33;74m[38;2;37;33;74m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;255;209;102m·[38;2;38;55;92m│[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;11;17;40m[38;2;38;55;92m█[38;2;255;209;102m·[38;2;26;38;69m▄[38;2;11;17;40m [38;2;26;38;69m▄[38;2;255;209;102m•[38;2;11;17;40m [38;2;26;38;69m▄[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;26;36;68m░[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░░[48;2;11;17;40m[38;2;38;55;92m█[38;2;90;111;158m▪[38;2;11;17;40m [38;2;38;55;92m│[38;2;255;209;102m·[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;14;23;50m [38;2;38;55;92m│[38;2;14;23;50m [38;2;38;55;92m█[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;26;36;68m░[38;2;255;209;102m·[38;2;38;55;92m│[38;2;42;74;134m│[38;2;11;17;40m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;37;33;74m[38;2;37;33;74m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;38;55;92m█[38;2;169;212;255m•[38;2;27;41;73m▄[38;2;14;23;50m [38;2;27;41;73m▄▄[38;2;14;23;50m [38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;38;55;92m█[38;2;169;212;255m•[38;2;38;55;92m█[48;2;17;26;53m[38;2;17;26;53m [48;2;11;17;40m[38;2;38;55;92m█[38;2;11;17;40m [38;2;38;55;92m█[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;11;17;40m[38;2;11;17;40m [38;2;255;180;92m░[48;2;17;26;53m[38;2;17;26;53m░[48;2;14;23;50m[38;2;255;180;92m░ [38;2;14;23;50m [48;2;44;36;80m[38;2;44;36;80m [48;2;11;17;40m[38;2;255;180;92m░ ░ ░ [38;2;11;17;40m [38;2;255;180;92m [48;2;17;26;53m[38;2;17;26;53m░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;11;17;40m[38;2;11;17;40m [38;2;255;180;92m [38;2;11;17;40m [38;2;255;180;92m ░[38;2;11;17;40m [38;2;255;180;92m░ [48;2;17;26;53m[38;2;17;26;53m░ ░[48;2;14;23;50m[38;2;255;180;92m ░[38;2;14;23;50m [48;2;17;26;53m[38;2;17;26;53m░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░░[48;2;11;17;40m[38;2;255;180;92m░[38;2;11;17;40m [38;2;255;180;92m░[38;2;11;17;40m [38;2;255;180;92m [38;2;11;17;40m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;14;23;50m [38;2;255;180;92m [38;2;14;23;50m [38;2;255;180;92m [38;2;14;23;50m [38;2;255;180;92m ░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;11;17;40m [38;2;42;74;134m│[38;2;255;180;92m░[38;2;11;17;40m [38;2;42;74;134m│[38;2;255;180;92m [38;2;11;17;40m [48;2;17;26;53m[38;2;17;26;53m░[48;2;44;36;80m[38;2;44;36;80m [48;2;17;26;53m[38;2;17;26;53m░ [48;2;14;23;50m[38;2;14;23;50m [38;2;255;180;92m ░[38;2;14;23;50m [48;2;17;26;53m[38;2;17;26;53m░[48;2;11;17;40m[38;2;255;180;92m░ ░[48;2;17;26;53m[38;2;17;26;53m [48;2;11;17;40m[38;2;255;180;92m░ [38;2;11;17;40m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;6;11;28m[38;2;93;101;120m [38;2;255;209;102m•[38;2;255;246;220m•[38;2;93;101;120m [38;2;255;68;85m•[38;2;93;101;120m ─[38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m ˙ [38;2;255;246;220m•[38;2;255;209;102m•[38;2;93;101;120m [38;2;255;68;85m•[38;2;93;101;120m˙[38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;246;220m•[38;2;93;101;120m ˙ [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m [38;2;255;68;85m•[38;2;90;111;158m•[38;2;93;101;120m ˙[38;2;255;209;102m•[38;2;93;101;120m ˙[38;2;140;149;168m•[38;2;255;209;102m•[38;2;255;68;85m•[38;2;169;212;255m•[38;2;93;101;120m ˙ ─[38;2;169;212;255m•[38;2;93;101;120m [38;2;255;209;102m•[38;2;93;101;120m˙ [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;5;9;25m[38;2;79;86;104m˙── [38;2;154;128;68m·[38;2;129;126;119m·[38;2;79;86;104m [38;2;129;37;52m·[38;2;79;86;104m ˙[38;2;154;128;68m·[38;2;79;86;104m ─ [38;2;129;126;119m·[38;2;154;128;68m·[38;2;79;86;104m [38;2;129;37;52m·[38;2;51;64;95m·[38;2;79;86;104m ˙ [38;2;154;128;68m·[38;2;79;86;104m [38;2;154;128;68m·[38;2;79;86;104m [38;2;129;126;119m·[38;2;79;86;104m ˙ [38;2;154;128;68m·[38;2;79;86;104m [38;2;142;118;64m·[38;2;79;86;104m [38;2;154;128;68m·[38;2;79;86;104m ˙[38;2;129;37;52m·[38;2;79;86;104m─ [38;2;154;128;68m·[38;2;79;86;104m ˙[38;2;154;128;68m·[38;2;129;37;52m·[38;2;94;119;148m·[38;2;79;86;104m˙ [38;2;154;128;68m·[38;2;79;86;104m [39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;4;8;21m[38;2;66;72;88m˙ [38;2;142;118;64m·[38;2;66;72;88m [38;2;142;118;64m·[38;2;66;72;88m ˙ ─ ─ ─ ─ ˙˙ ˙ ˙ [38;2;94;119;148m·[38;2;66;72;88m [38;2;81;88;104m·[38;2;66;72;88m ─ [38;2;94;119;148m·[38;2;66;72;88m ˙[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [48;2;3;6;18m[38;2;52;57;72m ˙ ˙˙ ˙˙˙[38;2;142;118;64m·[38;2;52;57;72m [38;2;51;64;95m·[38;2;142;118;64m·[38;2;52;57;72m ˙ [38;2;142;118;64m·[38;2;52;57;72m─ ˙ ─ ˙ · ˙ ˙ ˙ ˙[39m[48;2;9;13;20m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138mBjork - Joga[39m [33m★ Favorite[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m│[39m [38;2;116;242;138m│[39m[49m +[48;2;9;13;20m[38;2;116;242;138m╰────────────────────────────────────────────────────────────────────────────────────────────────╯[39m[49m" +`; + +exports[`palette baseline > 'settings selection' 1`] = ` +"[1m[48;2;7;10;15m[38;2;116;242;138mSettings[22m[38;2;138;150;168m ────────────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mSettings › Appearance[39m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [38;2;116;242;138mDisplay color[39m[22m [38;2;116;242;138mgreen[39m [49m +[48;2;7;10;15m Receiver style [38;2;116;242;138mpulse-grid[39m [49m +[48;2;7;10;15m Transparent background [38;2;116;242;138moff[39m [49m +[48;2;7;10;15m ASCII-safe display [38;2;116;242;138moff[39m [49m +[48;2;7;10;15m Reduce motion [38;2;116;242;138moff[39m [49m +[48;2;7;10;15m Mouse and trackpad scrolling [38;2;116;242;138mauto[39m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m[38;2;170;180;194mSelected[39m[22m [49m +[48;2;7;10;15m[38;2;170;180;194mPress Enter to change this setting.[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'slate/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;159;180;207mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'slate/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;159;180;207mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;159;180;207mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;159;180;207m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;124;145;171m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;159;180;207mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;159;180;207mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;159;180;207m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;159;180;207m2[39m [38;2;170;180;194mLongest streak: [38;2;159;180;207m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;159;180;207m2 days[39m [38;2;170;180;194mStations listened: [38;2;159;180;207m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;159;180;207m2/371[39m [38;2;170;180;194mStations counted after: [38;2;159;180;207m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;42;51;64m [48;2;9;13;20m [48;2;79;97;120m [48;2;9;13;20m [48;2;124;145;171m [48;2;9;13;20m [48;2;159;180;207m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'teal/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;94;234;212mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'teal/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;94;234;212mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;94;234;212mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;94;234;212m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;45;212;191m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;94;234;212mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;94;234;212mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;94;234;212m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;94;234;212m2[39m [38;2;170;180;194mLongest streak: [38;2;94;234;212m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;94;234;212m2 days[39m [38;2;170;180;194mStations listened: [38;2;94;234;212m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;94;234;212m2/371[39m [38;2;170;180;194mStations counted after: [38;2;94;234;212m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;18;63;60m [48;2;9;13;20m [48;2;31;118;108m [48;2;9;13;20m [48;2;45;212;191m [48;2;9;13;20m [48;2;94;234;212m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`palette baseline > 'violet/home' 1`] = ` +"[1m[48;2;7;10;15mRADIOCLI[22m [38;2;255;75;92m██[38;2;255;159;67m██[38;2;255;209;102m██[38;2;163;230;53m██[38;2;34;197;94m██[38;2;45;212;191m██[38;2;56;189;248m██[38;2;129;140;248m██[38;2;192;132;252m██[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLive public radio from around the world[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[1m[48;2;7;10;15m> [22m[38;2;170;180;194m1 [1m[38;2;140;80;240mPlaying[39m[22m [38;2;170;180;194m Receiver display and controls[39m [49m +[48;2;7;10;15m [38;2;170;180;194m2 [39mLibrary [38;2;170;180;194m Favorites, recent stations, imported streams[39m [49m +[48;2;7;10;15m [38;2;170;180;194m3 [39mExplore [38;2;170;180;194m Move a map cursor through geotagged stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m4 [39mSearch [38;2;170;180;194m Find stations by name, genre, language, place[39m [49m +[48;2;7;10;15m [38;2;170;180;194m5 [39mCountries [38;2;170;180;194m Browse by country list with a world-map toggle[39m [49m +[48;2;7;10;15m [38;2;170;180;194m6 [39mNearby [38;2;170;180;194m Approximate IP location for local stations[39m [49m +[48;2;7;10;15m [38;2;170;180;194m7 [39mStats [38;2;170;180;194m Listening graph, stations, streaks, hours[39m [49m +[48;2;7;10;15m [38;2;170;180;194m8 [39mAlarms (beta) [38;2;170;180;194m Wake to radio and schedule station playback[39m [49m +[48;2;7;10;15m [38;2;170;180;194m9 [39mSettings [38;2;170;180;194m Audio output, colors, providers[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m[38;2;170;180;194m1 recent · 1 favorites · 1 imported[39m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m +[48;2;7;10;15m [49m" +`; + +exports[`palette baseline > 'violet/stats' 1`] = ` +"[1m[48;2;7;10;15m[38;2;140;80;240mListening stats[22m[38;2;138;150;168m ─────────────────────────────────────────────────────────────────────────────────[39m [49m +[48;2;7;10;15m[38;2;170;180;194mLocal listening history · never leaves this machine[39m [49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;140;80;240mActivity — 2026[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m Dec Jan Feb Mar Apr May Jun Jul Aug Sep [39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;140;80;240m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mMon [39m[48;2;28;28;28m [48;2;118;63;205m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mWed [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFri [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194m [39m[48;2;28;28;28m [48;2;9;13;20m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m +[48;2;7;10;15m [49m +[48;2;9;13;20m[38;2;40;49;60m┌────────────────────────────────────────────────────────────────────────────────────────────────┐[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[1m[38;2;140;80;240mSummary[39m[22m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mFavorite station: [38;2;140;80;240mKEXP 90.3 FM[39m [38;2;170;180;194mTotal hours listened: [38;2;140;80;240m1.5h[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mSessions: [38;2;140;80;240m2[39m [38;2;170;180;194mLongest streak: [38;2;140;80;240m2 days[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mCurrent streak: [38;2;140;80;240m2 days[39m [38;2;170;180;194mStations listened: [38;2;140;80;240m2[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mActive days: [38;2;140;80;240m2/371[39m [38;2;170;180;194mStations counted after: [38;2;140;80;240m2 min[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m│[38;2;170;180;194mLess · [39m[48;2;28;28;28m [48;2;9;13;20m [48;2;62;22;118m [48;2;9;13;20m [48;2;84;39;153m [48;2;9;13;20m [48;2;118;63;205m [48;2;9;13;20m [48;2;140;80;240m [48;2;9;13;20m [38;2;170;180;194mMore[39m [38;2;40;49;60m│[39m[49m +[48;2;9;13;20m[38;2;40;49;60m└────────────────────────────────────────────────────────────────────────────────────────────────┘[39m[49m" +`; + +exports[`receiver baseline / compact / ascii > braille-wave 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + + + + :::: .: .:. +.+-. .-::-:.-+. .*: :+:::--:-+. : +: +- +- +- -+::: + .*::*. .+-:+-. + + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > gallop-fine 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + + + .:-:. + :..:#*.-+. + .-+-:-: -# + .+:- ++*-:-@@-- + %*++*-. * + **+ + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > manhattan 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + + . o o | . oo + | | | .o@ +### | | #### oo . . +# #.### ooo /*\\ |.|o . # +# #.# # . ####### . . /*\\ . . .| ||. .. . # + ... . . . . //*\\\\ . . .| . .. . + oo o .o oo o.o -o .oo + ..-.. - . . . ..- . . . .. +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > pulse-grid 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / ascii > ultracode 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + + + + + + + + + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > braille-wave 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + + ⣀⣀⣀⣀ ⢀⣀ ⢀⣀⡀ +⢀⡴⠋⠁ ⠈⠙⠒⠒⠋⠉⠈⠙⢦⡀ ⢀⡞⠉ ⠉⠳⠤⠤⠤⠖⠋⠉⠙⢦⡀ ⣀ +⠉ ⠹⣄ ⣰⠋ ⠳⣄ ⣠⠞⠉⠉⠉ + ⠈⢧⣀⣀⡼⠁ ⠈⠧⣄⣀⡴⠋⠁ + + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > gallop-fine 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + ⢀⣀⣄⣀⡀ + ⣀⢀⡀⠰⡯⠟⢀⠦⢕⠄ + ⠠⡤⢖⠋⠉⡉⠉ ⢰⣼ + ⠐⢦⣀⡤ ⠳⣆⡧⠦⠤⠦⣿⣿⠲⣂ + ⢿⡺⢴⣌⠻⠚⠁ ⢼ + ⣬⢧⡎ + +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > manhattan 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + ˙ • • │ ˙ •• + │ │ │ ˙•● +▀▀▀ │ │ ▀▀▀▀ •• ░ ░ +█ █░▀▀▀ ••• ╱◆╲ │░│• ░ ▀ +█ █░█ █ ░ ▀▀▀▀▀▀▀ ░ ░ ╱◆╲ ░ ░ ░│ ││░ ░░ ░ █ + ░░░ ░ ░ ░ ░ ╱╱◆╲╲ ░ ░ ░│ ░ ░░ ░ + •• • ˙• •• •˙• ─• ˙•• + ··─·· ─ · · ˙ ··─ · · ˙ ·· +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > pulse-grid 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`receiver baseline / compact / unicode > ultracode 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + + + + + + + + + +Bjork - Joga +" +`; + +exports[`receiver baseline / full / ascii > braille-wave 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| | +| | +| | +| | +| | +| .---:--:-. :-::-. -::-: ::: | +| .+- :-:::-:. -+. -+. .-+: .---. .-+. | +| .+- +: +: .-::-: +- :: | +| :+ *. .*. -+. | +| :+ .*. +: -+. | +| .*. -+ -+. -+. | +| :-+-. :-:-. | +| | +| | +| | +| | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > gallop-fine 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| | +| .+-+- | +| +#*++%:+-#+ | +| :#:.*%: *-.+++: | +| #@@#*: .%-#*+ # | +| -%*--+-+#+.:-. :. #: . | +| :.-+*+: . :#.-* | +| :*::%#::%... :- +@%+ | +| *#*:. -* :%@: @#%%**+*%#--*@-++@- | +| *#@#+- -@% # ...--%@%: .#% | +| **%%*%+-. ++%@%%%-. #@- | +| .+*+:-*%##. *+- +@@ | +| :@+. .#.- + | +| *@%+. | +| -+-+-+-+. | +| | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > manhattan 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . | . o | .. | +| | | #### . oo . | +| o o | |.|. .o@ | +| o | /*\\ |*|. oo . | +| ooo ##### /*\\ ###### #.## . ... | +| ### ####### . # # //*\\\\ . . o| | || ### | | +| #.# *| # # ///*\\\\\\ #*. |. # |.|| #.# #### | +| # #. . o|. ### # #. ||.# #.#o#o # *#.#* . .| #.# #. # | +| #.#. . ###### . #. *|. # # o#. ###|###. #. .| # *. || . . .|o #.# #### | +| # #.### | . . .# | .# ### #. # o|.#..###.#. # . o#.##. . . # . | #.# # # .# | +| #.#.# # # .| #. . .#.# #o ##. .# .#. ..#* |.#. # | # .# ..|| #. . #o# ## #.#o# # # | +| ... . . . . . . . . . . . . ... . . . . |. | . . . .. . . | +| oo o -o o . oo o.o o o o . o o oo .o .oooo . -o o. | +| .-- .. . .. - .. .. . . . . . . . . ..- . ..... . | +| . . . . - - - - .. . . . . - . . | +| . .. .... .. . .- . - . . . . . . | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > pulse-grid 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o @ @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| . o @ o o o @ o . . . o o @ o o o o @ o o o o o o o @ @ o o o o @ o . . . . o @ o o o o o | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / ascii > ultracode 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`receiver baseline / full / unicode > alpine 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ˙ •˙˙ ˙˙˙ ˙ ˙ ˙ ˙˙ ˙ • ˙ • ˙ │ +│ ·˙˙ ˙ •••••˙ ˙˙ ˙˙ ˙ ˙ ˙ ˙ · ˙ · ˙ │ +│ ˙ ••●●●•• ˙˙˙˙ ˙˙˙ ˙ ˙˙ ˙ ˙ ˙ ˙˙˙˙ ˙˙˙˙ ˙ │ +│ · · ••••• ˙ ·˙ ˙˙ ˙ ˙˙ ˙˙ ˙ ˙˙ │ +│ • · ˙˙· · • ˙ ˙˙ ˙ ˙ ˙ │ +│ • •˙ ˙ ˙ ˙ ˙ ˙ ˙ ˙ ·· · ˙ ·˙ │ +│ ˙ · ˙ ▀▀▀▀▀▀▀ ░······· ˙ ˙ ˙ ▲˙ │ +│ ·········▲ · ····················· ·▀▓▓▓▓▓▓▓▓▓▓█▀ · · ·············· · ▓ ··· │ +│ ·· ··▲▓▲········ ▲ · ·············▀▓▀•▓•▀•▀•▓•▓•▀•▀•▓•▀▓▀ ········ ···▓▓▓······▓▓▓· · │ +│ ·····˙▓▓▓▓▓ · · · ▓▓▓ · · ·· ···· · ·▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ·······▲▓▲·· ▲▓▓▓▲ │ +│ · · ·▲▓▓▓▲ ░▲▓▲ · ··· ▓░░▌╋▐░░░░●░░▌╋▐░░▓ ░ ▓▓▓▓▓ ▓▓▓▓▓ │ +│ ░ ▓▓▓▲▓▓▓░ ░ ▓▓▲▓▓ ░ ░░ ░▓░░░░▓░░░░•░░░░▓░░▓ • ▲▓▲▓▲ ▲▓▓▲▓▓▲ │ +│ · │ ·˙│˙˙˙·˙˙˙˙˙˙˙˙˙˙ · · · · │ · │ │ +│ ·· · · · ˙˙˙˙˙·˙˙˙˙˙˙˙˙˙ │ +│ · · · · · · · · · · │ +│ · · · · ·· · · · · · · · · · │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > aurora 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ··│·│····│·┊ ··│┊║│▒│█│█│║║║║││·┊·│┊·║┊·┊ ┊│ ·┊│ │ +│ ··┊·│·┊┊┊│┊│·· ··││║║█║█░█░▓▒░║││┊│┊│┊·│·· ··│· ·┊│· │ +│ ····│·│┊│║│║┊│ ┊┊║║▓░█▒█▒█▒▓║║║││┊║┊│· ✦ ┊┊┊· ·┊║┊· │ +│ ··┊·│┊│││║│║┊│· ·││▒▒█▓███▓█░░║│┊│┊┊║·· ·┊┊┊│ ·┊║║┊·· │ +│ ···┊·│┊║║║║║║┊┊ ··║░███████░║│┊··┊║│┊║· ✦ ┊┊┊║· ·│││┊││ │ +│ ···│┊║│║║║░││·· ┊┊▒▓█████▓█│┊·····│┊┊║·· ···║┊·│ ··┊┊┊║║┊··· │ +│ ··┊││▒║▒║║│┊┊· ·││███████░║┊· ··│┊┊║┊┊│·· │·┊║┊·······│║│┊││·· │ +│ ✦·┊┊║║▓║║│┊┊·· ··│║█████▓█║║┊·· ···┊·┊║┊┊║┊┊·· · ·║││││····┊║│││║┊· │ +│ ··┊║║▓││·· ││▓▓███▒█║▒│┊│┊┊··· │·┊║│║║┊│· │││║║┊┊┊·┊║││┊┊· │ +│ ··││▓││· ┊│▒▒█▓█▒█║▒│││┊│┊┊· │·┊║│║║││· ││││║┊·┊·│║┊· │ +│ ·┊┊▒││· ┊┊║▒▓▒█░█║▒││║│║┊┊·· │✦┊║│║║││· │║││║┊··┊┊│ │ +│ ✦ ┊┊║┊│·· ┊┊║░▒░▓░▓║║││║│║│┊·· │·┊║││║┊│· ✦ │║│┊│··┊┊ │ +│ ·┊·║┊│·· ┊│║░░░▓║▓│║││║┊│┊┊·· │··║┊│║┊┊· ┊║┊·· │┊ │ +│ ·····│·│··· · ┊│║║░║║│║││┊║║┊│·· ···║┊┊║·· ┊│·· │· ✦ │ +│ ··│·┊··│ │ ·· ··┊││║║│║│║┊│┊┊║┊│·· · ·║┊┊│·· ✦ ·│· │· ✦ │ +│ │·│·┊· │ ┊ ·· ··┊┊││││║│▒┊││┊║┊│·· ·║┊┊║· ✦ ✦ ·║· │· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > braille-wave 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ⢀⡤⠖⠚⠉⠓⠲⠤⣄⡀ ⣀⣠⠤⠤⣄⡀ ⣠⠤⠤⢤⣀ ⣀⣀⣀ │ +│ ⢀⡴⠋ ⠉⠓⠒⠒⠒⠚⠉⠁ ⠙⢦⡀ ⣠⠞⠁ ⠈⠓⢦⣀ ⢀⣠⠖⠋⠁ ⠈⠙⢦⡀ │ +│ ⢀⡴⠋ ⠳⡄ ⣰⠃ ⠈⠙⠒⠒⠚⠉ ⠹⣄ ⣀⣀ │ +│ ⠘⣆ ⡼⠁ ⠈⢧⡀ ⣠⠞⠁ │ +│ ⠘⢦ ⢀⡼⠁ ⠳⡄ ⣠⠞⠁ │ +│ ⠈⢳⡀ ⣠⠞ ⠙⢦⡀ ⣠⠞⠁ │ +│ ⠉⠓⠶⠚⠁ ⠉⠓⠒⠋⠁ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > cascade 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ █ ┊ ▓▒▒▒▒ │┊┊█ ·██ ▓▓· ▒░▓┊ ║ │┊┊· ▓█ █┊▓│┊│ ▒ │ ││ ·┊ ┊█┊┊┊║┊┊ ┊ ┊║░ ┊ ║ ▓┊█│█┊ │ +│ ┊┊·█▓▓▒▓ ·┊║┊│· · █ ▓█ ▒▒▓┊ ║ ║┊┊┊ █ ·┊█│┊║ ▒ ║·║│··· ┊┊┊ ┊┊┊░│┊·┊·│║▒ ┊ ║ █┊ │█┊ │ +│ ┊┊ █▓▓▓▓┊ ┊║┊║ █ ▓▒█┊ ░ ░┊┊┊ █ │ ║┊░ ▓┊║ ░║· ·┊┊│ ┊┊┊▒│┊ ┊ ║░▒ │ ░ ┊·║ ┊ │ +│ ┊┊ ████┊ ┊░┊║ · · ┊ █▓ ┊ ▒·┊░┊┊┊ ║ ░┊░┊ █┊░ ░░· ┊┊║ ┊┊┊▒║┊ ┊ ║▒▓ │ ▒ ┊ ░ ┊ │ +│ ┊┊ ·█·┊ ┊▒┊░ ·· ┊ ██·┊ ▒ ┊▒││┊· ┊┊ ·║ ▒┊▒┊ ┊▒ ▒▒ │┊║ ││┊▓░┊ ┊ ░▒█·║┊▒ ┊ ░ ┊ │ +│ · ┊┊ ┊ ┊▓│▒ · · · ┊┊·█ ┊ ▓ ┊▓│║┊ ┊┊┊ · ░ ▒┊▓┊┊ ┊▒ ▓▒ ┊ │┊░ ║║┊█▒┊·│ ▒▓█┊░┊▓ ┊ ▒·┊ │ +│ ┊┊ ·┊·┊▓│▒ · ┊┊ ┊ █ ┊▓║║┊ ┊┊┊ ▒ ▓│█┊┊ ┊▓ █▓ ┊ ║┊▒·║║┊█▒┊ │ ▒█ ┊▒┊█ ┊ ▓·┊ │ +│ ·┊│ ┊ ┊█║▓ · · ·┊┊ · │ █ ┊█░░┊ ┊┊┊ ▒ █║█┊┊ ┊█ ██ ┊·░┊▒ ░░┊ ▓┊ ║ ▓ ┊▒┊ ┊·█ │ │ +│ │║ │ │ ░ ┊┊· ░ · ┊ ▒▒│·┊┊┊ ··█ ░ ┊┊·│ ┊ ▒│█ ▒▒║·█║ ░ · ┊█┊ ·║ ░ │ +│ ║░ · · │ ║ ▒ ┊┊ ░ ··│ ▓▓║ ┊┊┊·· · ▒ │┊ │ ··┊ ▓║ ▓▓║ ║ ▒· ┊█┊ ··║ · ░ │ +│ ║▒ ║ ║ ▓ │┊ ▒ │·██║ ┊┊┊ ▓ │┊ ║ · ┊ █░ ·██░ ░ ▓ · ┊·┊ ░ · ▒ │ +│ ░▒ ░ ░ █· ││ ▓ ║ █ ░ ┊││· · ▓ ║│ ░·· ┊ █░ ▒ ▒·█ · │ │ ▒ ▓ │ +│ ▒▓ ░·▒ █ ║│ ▓ ░ ·▒ ││║ █ ░│ ░ │ ▒ ▓ ▒ █ │ ║ ▒ ▓ │ +│ ▓█ ▒ ▒ · · ░║ · █ ░ ▓ ║║░ · ░║ ▒ · ║ ▓ ▓ ▓ · ║ ║ ▓ █ │ +│ · ▓█ ·▓ ▓·· · ▒░ · ▒ ▓ ░░░ ▒░ ▓ ░··█ █ █ ░ ░ █ │ +│ █··· █ █ ·· ▒░ ·▓ █·░▒▒ ▓░ ▓ · · ░ █· ·· ·░ ▒ █ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > caustics 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓▒░░· ·░░▒▓██▓▒▒░·· ··░▒ │ +│ ▒░·· ·░░▒▓██▓▓▒░░·· ··░░▒▓ │ +│ ░·· ··░▒▒▓██▓▓▒▒░░·· ···░▒▒▓█ │ +│ ░··· ··░░▒▒▓███▓▓▒▒░░░····· ·····░░░▒▒▓██ │ +│ ░░····· ···░░░▒▒▒▓▓████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▓▓▓██ │ +│ ░░░░░░░░░░░░░░░░░░················░░░░░░░░░▒▒▒▒▒▒▓▓▓▓███████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓███ │ +│ ▒▓▓▓▓▓▓▓▓▓████████████████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓█████ │ +│ ███████▓▓▓▓▒▒▒▒▒▒░░░░░░░░░░░·······░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓███████████████████ │ +│ ▓▓▓▒▒▒░░░░···· ····░░░░▒▒▒▒▓▓▓▓█████████████████████ │ +│ ▓▒▒▒▒▒░░░░░····· ····░░░░▒▒▒▓▓▓▓████████████████████ │ +│ ▓▓▓▒▒▒▒▒▒▒▒░░░░░░······ ·····░░░░▒▒▒▒▓▓▓▓███████████████████ │ +│ ▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░░░░·······················░░░░░░▒▒▒▒▒▓▓▓▓███████████▓▓▓▓▓▓▓ │ +│ ███████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓████████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓███████████▓▓▓▓▓ │ +│ ███▓▓▒▒▒░░░····· ·····░░░░▒▒▒▓▓▓▓██████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓████████▓▓▓ │ +│ ██▓▒▒░░·· ··░░░▒▒▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█████▓ │ +│ ██▓▒░░·· ··░░▒▒▓████▓▓▒▒▒░░░░░░░░░░░░░░░░▒▒▒▓▓▓██ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > chladni 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠁⠒⠠⢀⡀ ⠑⠠ ⣀ ⣀ ⠤⠂⠁ ⢀⡀⠄⠐⠈⠁ ⠈⠁⠂⠤⢀ ⠈⠂⠄⢀⡀ │ +│ ⠁⠒⠠⢄⡀ ⠉⠉⠁ ⢀⡀⠄⠒⠈ ⠈⠁⠂⠤⢀⡀ ⠈⠉ │ +│ ⠉⠒⠠⠄⡀ ⢀⡀⠄⠒⠈⠁ ⠈⠁⠒⠤⢀ │ +│ ⠉⠉ ⠒⠠⢄ ⠉⠐⠠⠄⡀ ⢀⡀⠄⠒⠈ ⡀⠄⠒ ⠉⠉ ⠈⠉⠉ ⠂⠠⢀ ⠈⠁⠂⠠⢀ │ +│ ⠑⡀ ⠉⠐⠢⣤⣀⣀⣤⠤⠒⠈ ⠎ ⢡ ⠈⠁⠒⠤⣄⣀ │ +│ ⢠⠃ ⢀⡠⠴⠚⠛⠛⠛⠢⠄⡀ ⢄ ⡸ ⣀⠠⠔⠛⠛ │ +│ ⣀⡀⠠⠄⠂⠁ ⢀ ⠄⠂⠈ ⠉⠐⠠⠄⡀ ⠑⠠⠄ ⣀ ⣀ ⠠⠐⠊ ⣀⠠⠐⠂⠁ │ +│ ⢀⡠⠄⠂⠉ ⠉⠐⠢⠄⣀ ⣀⠠⠐⠂⠁ │ +│ ⢀⠠⠄⠂⠉ ⡠⠄⠂ ⠉ ⠉ ⠐⠂⢄ ⠈⠐⠂⠄⣀ ⡀⠠⠐ ⠁ ⢀⠠⠐⠂⠈⠉ │ +│ ⣤⣤⠔⠂⠉ ⡎ ⠑ ⠈⠐⠢⣤⣤⣤⡤⠖⠊⠁ ⢠⠃ │ +│ ⠉⠙⠒⠤⢀⡀ ⢃ ⡰ ⡀⠤⠒⠛⠉⠉⠛⠢⠄⣀ ⠈⢄ │ +│ ⠁⠂⠠⢀⡀ ⠁⠂⠠ ⣀⣀⡀ ⣀⣀ ⠤⠐⠈ ⡀⠤⠐⠈⠁ ⠈⠐⠂⠄⣀ ⠑⠂⠤ ⣀⣀ │ +│ ⠁⠒⠤⢀⡀ ⢀⡀⠤⠐⠈⠁ ⠈⠐⠂⠤⣀ │ +│ ⣀⡀ ⠈⠁⠒⠠⢀⡀ ⡀⠤⠐⠈⠁ ⢀⣀⣀ ⠈⠑⠂⠤⢀ │ +│ ⠈⠁⠐⠠⡀ ⠁⠒⠠⢀⡀ ⢀⡀⠄⠐⠈⠁ ⢀⠠⠒ ⠉ ⠉ ⠂⢄ ⠈⠁⠂⠤⢀ │ +│ ⠈⡄ ⠉⠒⢦⣤⣤⣤⠒⠈⠁ ⠃ ⢱ ⠈⠑⠲⣤⣤ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > constellation 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ✦ │ +│ · │ +│ ✦ · ✦·······✦· │ +│ ··· ·····✦ ········ ····· │ +│ · · ······ · ··✦····· ······ ·· │ +│ ·· · ······ ·· ····················✦ │ +│ · ✦ ·· ···· ······✦······· │ +│ · · · ········ ······ · │ +│ ··· · ····✦··✦· │ +│ ·· ·✦ │ +│ ✦ ··· │ +│ ····· ···· │ +│ ····· ··· │ +│ ···✦·· │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > copper-bars 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ██████████████████████████████████████████████████████████████████████████████████████████ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ██████████████████████████████████████████████████████████████████████████████████████████ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ██████████████████████████████████████████████████████████████████████████████████████████ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > crossfade 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > cube 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ +----------------+ │ +│ @@@@@@@@@@@@@@@@@@@\\ │ +│ @@@@@@@@@@@@@@@@@@@@\\ │ +│ /@@@@@@@@@@@@@@@@@@@@\\ │ +│ @======================@ │ +│ ***********************| │ +│ ********************** │ +│ *********************| │ +│ |********************| │ +│ ******************** │ +│ +******************+ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > cyclone 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠠ ⡰⠐⠊⠈⠡⠁⢠⠁⠤⠁⣈⠂⢱⢄⡆⣀ ⠐ ⠠⢀⠐⠄⢀ │ +│ ⠂⠐ ⡠⠤⡐⠎⠌⠊⡂⠒⠠ ⠄⢀⠑⠠⡊⢪⠐⠂ ⠄⠄⠐⠤ ⡂ │ +│ ⡀⠂⠁⠠⠠⢐⡔⠈⡆⢲⡠⢆⣠⢀ ⠠⠂⠄⠂⡑⠌⢈ ⠄ ⡂⠃⠔⠄⠠⠂ │ +│ ⠄⠁⢀⠠⡐⠑ ⡀⡠⠠⠄⠅⡈⠈⠓⠂⡈⠠⢤⢁ ⠠ ⠰⠈⡠⠐ ⠤ │ +│ ⡠⠈ ⠄⠐ ⠄⡀⠠ ⠂ ⠠⢀⢰⢠⢀ ⢀⢃⣃⣒ ⠂⠐⠅⢀⠄⡂⠄⠄ │ +│ ⠠ ⡂⢈⡈⠠⠈ ⠆⠌ ⠂⠡⠸⠘⠜⢔⠬⡉⢣⣀ ⣳⠸⡠⠅ ⠊⢈⡣ ⠔⡀⠄⠁⠈ │ +│ ⡁⢀⠄⠂⠠⡈⡁⠃ ⠤⠥⢦⢢⣢⠈⢿⢤⠻⣨⠌⠁⣈⣒⠁⢤⠂ ⢌⠢ ⠁⠂ │ +│ ⠁⣀⠄ ⠠ ⠆ ⡠⠢ ⢔⡴⢚⣢⣯⣋⣂⡿⢖⡡⢎⡠⢕⣢⠂ ⡪ ⢔⠕⠂⠈ ⠂ ⠂ │ +│ ⠠ ⠘⢈ ⡊⠕ ⠂⢸ ⢀⡂⢨⣟⢈⢙⡌⠳⠄⡀⡄⡄⢆⠡⢈⠰ ⢀⠘⠐⠄ ⡆ │ +│ ⠁ ⠑⠁ ⡁⡃⠄⠁⠩⡨⡑⣂⠬⡥⢂⠢⢪⣃⠒⠠ ⠈⠂⠁⡀⣀⡐⢄⠸⠄⡀⠨⠆ ⠐⠈ │ +│ ⠂ ⠢⠉⢈ ⣁⠡⠂ ⠔⡂⠐⡈⢭⣅⢂⠈⠈⠑⠲⠒⠶⡢⠹⡒⠤⠢⠁⠁⢄⠐⠁ ⠂⠈⠄ │ +│ ⠙ ⡉⡀⡤⠈⡀ ⠐⢊⠠⠂⡀⠈⠓⠦⡀⡨⡂⢧⢹⠊⠶⢈⠁⠂⠈ ⠁⡀⡈⡀⠡ │ +│ ⠈⠎⠉⡀⡰⠄⠄ ⠁⠌⢀⠆⣀ ⠁⠁ ⠂⠘ ⠃ ⠁ ⠄⠐ │ +│ ⠈⠆⠁⣊⡁⢌⢂ ⠃⢐⠅⢌ ⡂ ⡀ ⡀ ⡀ ⠄ ⠁⠈ │ +│ ⠔ ⢉⠄⡀⠌⠄⡀ ⠐ ⠌ ⠂ ⢀ ⡠⠄ │ +│ ⠠ ⠈⠠⢔⠄⢀⠃ ⡀ ⠡⠃⠈⠕⠈⠨ ⠈ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > equalizer 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ·· ██ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ·· ██ ██ ██ ·· ·· ·· ·· ◆◆ ◆◆ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ██ ██ ██ ██ ██ ◆◆ ◆◆ ◆◆ ·· ·· ◆◆ ·· ·· ·· ·· ·· ██ ██ ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· │ +│ ·· ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ◆◆ ·· ·· ·· ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ·· ◆◆ ◆◆ │ +│ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ◆◆ ·· ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ◆◆ ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ◆◆ ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ██ ██ ██ ██ ██ ██ ██ ██ ██ ◆◆ ◆◆ ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ·· ·· ·· ·· │ +│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fern 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⣠⣾⣿⠆ │ +│ ⢀⢤⢳⣿⣭⡊ │ +│ ⡄⡝⡏⡬⡑⡽⠁ │ +│ ⢀⠠⠃⡦⠗⡘⡡⡌⢗ │ +│ ⠰⠩⢦⠑⢅⢃⠒⠄⣃ │ +│ ⡾⠜⡦⠑⠎⡈⡀⠪⠔⡢ │ +│ ⢀⡦⢰⡆⠮⢞⢈⠃⡐⢪⠐⢆ │ +│ ⢀⠈⡉⡉⣔⡐⠄⠞⠈⢀⡐⢌⡅ │ +│ ⢠⡄⠠⡠⠔⡮⣄⠃⠐ ⢀⠐⠦⡑⠛⣠ │ +│ ⢸⢟⡄⠹⠁ ⡀⢁⣘⠈ ⠠⢀⣁⡀ │ +│ ⡴⢀⠐⡎⣤⠐ ⠈ ⢀⠈⠃⠤⠁⢁⠔ │ +│ ⠘⡵⠂ ⠁⢀⡇⠈ ⠤⡀⣁⡀ │ +│ ⠐⡀⠨ ⠁⠂ ⠐⡰⠠⡈⠉ │ +│ ⠈ ⠄⠠⠈ ⢀⠁ │ +│ ⠃ ⡀⠈ │ +│ ⠁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fire 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ░░░ │ +│ ░░░ │ +│ ░▄▄▄ ░░░ │ +│ ▄▄▄▄ ░░░▄ · ▄ │ +│ ░░ ▄▄░░ ▄▄▄▄ · ▄▄▄ │ +│ ░░ ░░░░░ ▄▄▄░░ ░░▄ ▄░░░ │ +│ ▄▄░ ░░░▄▄▄▄ ░░░▄▄ ░░░ ▄▄░░░ ▄▄▄ ░░░▄▄ │ +│ ░░░░░░▄▄▄▄▄▄░░░ ░▄▄▄▄▄ ▄▄▄ ▄░░░░░░ ░•░░░░▄▄▄▄▄ │ +│ ░░▄▄▄▄▄▄░░░░░░░▄▄▄▄▄▄░░░ ▄▄▄▄░░░░░░▄▄▄ ░░░░▄▄▄▄▄▄▄░ │ +│ ▄▄▄▄▄▄░░░░▒▒▒▄▄▄▄▄░░▒▒░░ ▄▄▒▒░░░░▄▄▄▄▄▄ ░░░▄▄▄▄▄▄░░░░ │ +│ ▄▄▄▄░·░░░░▄▒▒▒▄▄░░▒▒▒▒▒▒▄▄▄ ▄░▒▒▓▒▒▄▄▄▒▒▄▄░ ░▄▒▒▄▄▄░░░░░░▄ │ +│ ▄░░░░░░▄▄▒▒▒▒▒░░░▒▒▓▓▒▒▒▄▄░░▒▒▒▓▓▒▒▒▒▒▒▒▒░░░░▄▄▄▒▒▒▒░░░░░░▄▄▄ │ +│ ▀▀▀█▀▀▀▒▒▒▒▀▀█▀▀▀▀█▀▒▓▓▒▀▀▀▀█▀▀▀▀▓▒▒▒▀█▀▀▀▀█▀▀▄▄▒▒▀▀▀█▀▀▀▀█▄▄▄▄█▀▀▀ │ +│ ▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄▄▄▄▄▄▄▓▓▓▄▄ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fireworks 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · · *· · ·· │ +│ ··· ✦ +· · · · │ +│ · ··· · · · ··· +· · │ +│ · ✸✦● ···· ✦ ··*· ●· │ +│ ✦· ✦●✸ ★ · x· · │ +│ · ·★· · ●✸✦ ·· +· │ +│ · ··· · · · · · · ·x │ +│ · · · · · ·· · │ +│ · ·· ···· ·· · · │ +│ · · ● ·· ···· · │ +│ · ·· · · │ +│ · ●· · · · │ +│ │ · · │ +│ │ │ +│ · · │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > flyover 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·····+·+·=:x:x:X:X:X:$:$:$:$:$:$:$:X:X:X:X:x:x:x:x:=:= │ +│ · : :·;·+·+·=:x:x:x:X:XXX:XXX:X:x:x:x:x:=·=·=·+·+·+·+·+·+· │ +│ ···+···=·=:x:x:x:x:x:x:=:=·=···+···+·················· │ +│ : · · :·;·+·+·=·=·=:=:=·=·=·=·+·+·+·;·;·;·;·;·;·;·;·;·;·;·:·: │ +│ ·· ···;···+·=·=·=·=···+···;········ │ +│ +·;·; : · :·;·+·+·+·=·=·+·+·+·;·;·;·: : : : : : · │ +│ ···+···· ·······+···+···+······· │ +│ x:=·=·+·;·: · : :·;·+·+·=·=·+·+·+·;·;·: : : : : : · · · │ +│ x:x:x:=·=·+·; : : :·;·+·=·=·=·=·+·+·;·;·:·: : : · :·;·+·=· │ +│ :x:x:x:=······ ···+···=···=···+········· ···+···+·· │ +│ =:=:x:x:=·+·;·: · · · :·;·+·=·=:=·=·+·+·+·;·;·;·: : · :·;·+·+·;·;·: │ +│ ·+·=·=·=·=···;· ··+·=·=·=·=·=·+···+···;··· ···;···;·· │ +│ ;·+·+·+·+·+·;·: · · :·;·+·=·=·=·=·+·+·+·+·;·;·: · :·;·;·;·;·: : · │ +│ ·······+······ ····+···=···+···+······· ········ │ +│ : :·;·;·;·;·; : · · : ;·+·+·+·+·+·+·+·+·+·;·: · : :·;·;·;·: : · · │ +│ ······· ·;···+···+···+···+··· ·;···;·· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > fractal-tree 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ⢀⣄⣤⣤⣠⣤⣤⣤⣤⣤⣠⣤⣄⣄ │ +│ ⢀⣀⣤⣶⣾⣿⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣶⣦⣄⣀ │ +│ ⢀⣴⣿⣽⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣽⣷⣄ │ +│ ⣴⣾⡿⣿⣿⣟⢡⣿⣟⣿⠻⣿⣿⣿⣿⣻⣿⣿⣿⡿⢻⣟⣿⣧⢙⣿⣿⡿⣿⣶⡄ │ +│ ⢀⣾⣿⣿⣿⣿⣿⣽⡿⣿⣿⣿⡰⠿⡿⣿⣿⣹⣿⡿⡿⠷⣸⣿⣿⡿⣿⣽⣿⣿⣿⣿⣿⣆ │ +│ ⢰⣿⡙⣿⠛⠻⢍⣟⡿⣛⡞⠛⠺⢄⢸⡏⢏⣏⠏⣿⢀⠼⠚⠛⣞⡻⣟⣏⠽⠛⢻⡟⣹⣷ │ +│ ⢾⣟⣿⡿⠖⠒⠊⠉⠑⠒⠻⢄ ⢹⢇⠈⡏⢀⢿⠁ ⢀⠼⠓⠒⠉⠉⠒⠒⠾⣿⣟⣿⠆ │ +│ ⠁ ⠘⡄ ⡇ ⡜ ⠁ │ +│ ⠱⡀⡇⡰⠁ │ +│ ⠱⡷⠁ │ +│ ⡇ │ +│ ⡇ │ +│ ⡇ │ +│ ⡇ │ +│ ⡇ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > galaxy 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀⢀⣀⠤⠐⠂⠃⠁ ⠁ ⠄ │ +│ ⠁ ⡀ ⠚ ⡀⡤⠤⠤⠶⠲⠖⠁⠢⠒⠦⠦⠦⠄⢠⢀ ⢄ │ +│ ⠞⠋ ⢀⣠⡴⢶⠈⠁⠁ ⡀⣀⡀⣀⣀⢠ ⠁⠘⠒⠤⢀⡀ │ +│ ⠠ ⡐⠃ ⡀⢠⠺⠟⠉⠁ ⣒⢄⠤⢶⡒⢻⠛⠛⡺⠋⠁⡈⠩⠈ ⠐⠾⠦⣤⡀ ⠉⠑⠠⣀ │ +│ ⢀⠞⠁ ⢤⠟⢃ ⡂⢀⣡⢾⠍⠛⠐⢠⣄⣥⡭⣢⣴⣧⢴⢦⢤⣰⣄⣄⢐ ⡀⠌⠉⠲⣠⡀ ⠈⠁⢀ ⡀ │ +│ ⡌ ⠄ ⡖⠃⡀ ⢽⢛⠩⡀⡐⢭⡵⡾⠈⠋⢑ ⠠⠁⣠⢐⢊⠑⠜⠊⣻⡿⡬⣀⠁⡄⠈⠙⠷⡄ ⠈⠠ │ +│ ⠠ ⢰⠁ ⠐⡇⠪⢢⠄⢠⡏⢆⣀⠕⣠⡿⡈⢒ ⣨⣶⣛⡛⠌⢹⡟⠛⠷⢷⣄⠨⡀⠐⡉⢳⣔ ⠈⢣⡀ ⠘⣀ ⠄ │ +│ ⠠ ⠁⢸⡄ ⢸⡇⠰ ⠈⠼⣧⡔ ⣂⡾⣯⣖⣠⣾⣿⣿⣿⣿⣿⣧⣧⡊⠨⢐⠭⠷⣧⡁⠄ ⠸⣧⡀ ⢲ ⠈⡄ │ +│ ⠐ ⠈⢣⡀⢀ ⠙⣹⣮⣌⠐⡀⡈⠉⠳⠧⣬⣶⣈⣃⣬⡨⢿⡏⠤⠌⢈⣿⠗⠢⡤⢀⣾⠃⠃⡀⠄⣸⡅ ⡇ │ +│ ⢀ ⠈⢦ ⠦⡀ ⠐⢉⠟⢻⠤⣠⣢⠒⡄⢈⢄⢥⠩ ⠁⠄⣂⣴⣵⠟⢣⡀⢒⢀⡾⠏⠘⢃⣀⢡⡶⠁ ⡸ ⠐ ⠄ │ +│ ⠁ ⠠⡀ ⠉⠻⣦⢀⢀⡀⠅⡈⣙⢽⡓⠺⠟⠗⠶⠇⢺⢻⠏⡉⠂⢐⢌⣴⡾⡯⢡⠈⠒ ⢔⠛ ⡴ │ +│ ⠁⠱⢤⢀ ⠈⠘⠛⠃⠪⣷⣉⢈⡨⣉⣠⣤⡤⣩⣠⢠⡴⠶⡞⠊⠉ ⣠⡜⠛ ⣄⠊ ⠄ │ +│ ⠈ ⠂ ⠄ ⠑⠒⠄⣄⢀ ⠁⠙⠁⠉⠁⠉⠉ ⣀⡤⠼⠖⠊ ⢠⠤⠁⠁ │ +│ ⠠⠐⠁ ⠉⠈⠈⠒⠂⠲⠰⠨ ⠦⠠⠰⠆⠒⠚⠋⠁⠁ ⣀⠠⠒⠋ │ +│ ⠄ ⢀⢤⠄ ⠐⠊⠉⠁ ⠄ │ +│ ⠁ ⠠ ⡀ ⠈ ⠈ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > gallop-fine 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ⢀⣤⣠⣤⣄ │ +│ ⣤⢾⣓⡋⣰⣾⠉⠛⠙⢻⣤ │ +│ ⢠⣞⠃⠈⣱⡿⠃ ⢳⡆⠈⠹⠳⢦⡄ │ +│ ⠿⣿⣿⣶⠻⠉ ⠠⣽⠓⠿⢶⣆ ⢽ │ +│ ⣠⣾⠾⠙⠚⡙⠚⠳⠿⠳⠂⠘⠋⠁ ⠘⠂ ⣼⠃ ⠈ │ +│ ⣀⡀⣄⣤⣦⠞⠃ ⠁ ⠘⣳⠄⢨⡏ │ +│ ⠰⠟⠉⠉⣽⡟⠉⠰⣷⠄⢀⡀ ⢠⡆ ⢸⣿⣾⣅ │ +│ ⠻⢷⣩⣀⡀ ⣠⡏ ⠘⢿⣿⡄ ⣿⠿⣾⡿⣴⢶⡴⠾⢿⣗⣠⡌⢹⣿⠚⠛⠺⣿⠍ │ +│ ⠟⠿⣿⢾⠞⠋ ⢰⣿⣯ ⢯ ⢀⡀⢀⠙⠓⢿⣿⢿⠆ ⠈⣹⣷ │ +│ ⢼⠟⢿⣷⣜⣷⣤⣄⡀ ⠹⠞⢿⣿⣷⣷⡿⠋⠈ ⢽⣿⡆ │ +│ ⠈⠳⣇⣤⡈⠙⢹⣟⣹⢷⡀ ⠟⠛⠋ ⢩⣿⣿ │ +│ ⠘⣿⢇⡀ ⠈⠿⠂⠙ ⠛ │ +│ ⠻⣿⣷⣆⡀ │ +│ ⠓⠛⠓⠛⠙⠛⠙⠛⠁ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > golden-gate 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ●●●•·· ~ ˙·•●•●•●●●•●●●••●•··· ˙ ˙ • ˙ ˙~•••●•~●••••· ˙ ˙ │ +│ ●●••●·˙ ˙ ▀ ··▀• · · ˙ ˙ ˙ ··●●●••●·▀· ˙▀ ˙ •●●●•˙˙ ˙ ˙ ˙ │ +│ ·• ˙ ˙ ˙ ˙ ˙ ˙██████ ˙ ·●•●●●•●●██████˙ ˙ ˙ ˙ •••˙ ˙ ˙ ˙ │ +│ ˙ ˙ ˙ ˙ ••██│ ██ ˙ ˙ ˙ ˙ ˙ ˙██•│██•••••˙ ˙ ˙ ˙ ˙ │ +│ ˙ ••••│ ██████•••˙ ˙ ˙ ˙ ˙ ˙ ••██████˙│ │ •••••~ │ +│ ˙ ˙ ˙ ••••│ │ │ ██│ ██│ │•• ˙ ˙ ˙ ˙ •• │██ │██ │ │ │ │ │•••••• ˙ ˙ │ +│ · ░░•••░│·│ · │ │ ██│˙·█│·· ·••••░ ˙ ˙ ░░˙░•••• │ │·█ │██ │ │ │ │ │˙│ │ │••••• ˙ ˙ │ +│ ░░•·░•│░│░│░░░░░│░··░██░██│ │ │ │ │••••••••·•░• │ │ │ │·█████ │ │ │ │░│░│░│░│░│░│░•••••░░░ │ +│ ▀░▀·▀░▀▀····░▀▀▀▀·▀▀████░█▀▀▀▀░·▀▀▀▀·▀▀▀░▀▀░·▀░··░▀▀▀▀▀██████▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ +│ ░ · ··█░· ˙ ██╲╱██·· ˙ ~ ░ ·░·│·~~ ···· ░˙ ██╲╱██· · · · ·· ~ · │ +│ ˙ ░ · ˙░~˙ ˙ ˙· ██·•██˙··░ ░ ~·▲│▲ · ·· █░ ██• ·~ ˙ ˙ │ +│ ▒ ˙ ██╲╱██ · ˙·▄▄▄ ˙ ˙ · ██╲╱██ ˙ ~ · ˙ │ +│ ▒▒˙ · · ˙ · ··· · ˙ · · · ˙ · · ·· ˙ ˙ ˙ ˙ ·˙ ˙ │ +│ ▒▒▒░░ ˙ · · · ˙·˙ ˙· ˙ · ·˙· · ·˙·˙ · │ +│ ▒░ ▒ ░░░˙˙ ˙ · · · ··˙ · · · ·˙ ˙ ·˙· ˙ · · ˙ ·˙˙ ·· │ +│ ░ ░▒░ ▒ · ˙ ˙ ˙˙·˙ ˙ ˙ ˙ ·˙ ·· · ˙ · │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > goniometer 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀ ⡇ ⡀ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄ ⡇ ⡠⠊ │ +│ ⠑⢄⡧⠊ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⡠⠊ ⡇ ⠑⢄ │ +│ ⠈ ⡇ ⠁ │ +│ ⡇ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > harmonograph 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀ ⢀ ⢀ ⠄ ⢀ ⠂ ⠄⠐ ⠠ ⠄ ⡀⢀ │ +│ ⢀⠂⠁ ⠈⢀ ⠤ ⠠ ⠄ ⡀ ⠠ ⢀⢀ ⢀ ⠂⢀ ⠁ ⠄⡀ ⠠⠐⢱ │ +│ ⠄ ⡀⡨⠂⠂⠂⠄ ⡈ ⢀ ⡀ ⠔ ⡀⢀⢀ ⠰ ⢀⠨ ⢀ ⠠⠈ ⠐⠐ ⠁ ⠁ ⠁ ⠐ ⠠⢀ ⠄⠶⡀⡀ ⠁ ⠂ │ +│ ⠐ ⠂ ⠐ ⢰⢒⠠⠂ ⠁ ⢀ ⢀⢀ ⢉⢂⠦⢌⢠⢀ ⠁⡀⠐⠐ ⠄ ⢀ ⠄ ⠊⠈⡀⠬⠠⠢⠠⢀⠁ ⠂ ⡠⡈⠐⠂ ⠁ │ +│ ⠂ ⠂ ⠈⡀ ⠄⠡ ⠁⠨⠳⡄⣀⠠⡂⢐⠁⠂⢠⠁⢄⠠⠄⠤⢄⠨⠊⠔⠔⡄⣠ ⡀ ⠐⠁⢀⢠ ⠢⢘⣙⡠⢁⠁⠪⡠ ⡒ ⠂ ⠆ ⠈⠂ │ +│ ⠐ ⠈ ⠄ ⠂ ⠈⢀ ⠵⢃⠺⣊⠌⠢⡨ ⡂ ⠄⠁⠔ ⢀ ⠠⠋ ⡤⠄⠌⢈⡀⠨⠈⠈⠂⠄⢘ ⠡ ⢂ ⠐⡂⠡ ⢀⠈ ⠄⠁ ⠐⠄ │ +│ ⠠⠁ ⢀⠑ ⠠⠄⠃⢠ ⠰⠛⡄⠈⠄⠁⠠ ⠄ ⠂⠠⠡⡠⠈⠂⢘⠚⠄⢀ ⠂⠈⢄⠍ ⠅⠄ ⠠ ⠐⡂ ⠁⠐ ⠄⡀ │ +│ ⠢ ⡀ ⠐⢀⠠ ⠐ ⡈⠁⠠⡅⠁⠁⠏⡀⡄⠤⠈ ⠌ ⢀ ⠈ ⠄ ⠅⡀⡀⠨⠒ ⡐ ⠙ ⡔ ⢜ ⠄⠡⢁⡁ ⠂ ⠌ ⠁⠂ ⠂ ⡀ │ +│ ⠂ ⡀ ⠨ ⠆ ⠄⢂⠁ ⡐⠨⠐ ⠐ ⠠⠐⣂⠠⠆⠂⠄⡘⠐⠄⢐⢀ ⠐⠁ ⡄⠎⢁⡀⢃⣀⠆⠁⠄⠠⠂ ⠄ ⠂ ⠈ ⠐ │ +│ ⠂ ⠂⠐⠠⠂⠐ ⠡⠐⡀⠁⢂⢀⠡⢀⡀ ⠐⡈⠐⢠⡀⠨⠁⠸⠁⣠⣀⢂ ⠂⠠⢄ ⣄⢰⢀⠁⡀⠔⢈⠰⠪⡐⢬⠗⡄ ⡀⠐ ⠂ ⠐ ⠂ ⠁ ⠠ │ +│ ⠄⠐⠆ ⠁⠈⢅ ⠲⠂⢄⢈⠒⠤⡐⠵⢉⠒⠨ ⠉ ⠂ ⡄ ⠂⠑⠜⢁⣅ ⡄⠠⡠⠠ ⠂⢨⢁⠂⠄⠁⠻⡀⠐⢇⣄ ⠡ ⡀⠁ ⠂ ⠂ ⠠ │ +│ ⠄⠠⢀⠁ ⠂ ⢩⡐⡈⣀⡀⠘⠈⠈⠊⠂ ⠁ ⠐ ⠠⡀ ⠈ ⠈ ⠔ ⠄ ⠇ ⢢ ⠨⠂⠦ ⠠ ⡐ ⠂⠉⠐ ⡀ ⢡ ⢈ ⠐ ⢀ │ +│ ⠂⠠ ⡂ ⡀⠐⠁ ⠄ ⠈ ⠔ ⠐ ⠂ ⠁ ⢐ ⠰ ⠠ ⠄ ⢀⠂ ⠐ ⠈ ⠄⠐⢀ ⣀ ⡀⠈⠄⠠⠉⠁ ⢀⠄ ⡀⠁ ⡀ │ +│ ⡀⡂⠂ ⠈⠁⠁ ⠈ ⠐ ⠅ ⠤ ⢂ ⡀ ⢀ ⠂ ⠈ ⠄⠄ ⠈ ⠐ ⠂⢁ ⠠ ⠠ │ +│ ⠒⠂ ⠈ ⠁⠁ ⠌ ⢀ ⢀ ⠠ ⠁ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > hex-pulse 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ··▒░░ ░░░░ ░░░ ░░▒▒····▒▒█░░░░████░░░░████░░░░█▒▒····▒▒░░ ░░░ ░░░░ ░░▒·· │ +│ ·▒▒░░░ ░░░░ ░░░░░ ░░▒▒···▒▒▒██░░░████░░░░████░░░██▒▒▒···▒▒░░ ░░░░░ ░░░░ ░░░▒▒· │ +│ ▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒██████████████████████████▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒ │ +│ ░░···░░░░░ ░░░░░ ░░▒▒▒··▒▒▒██░░░█████░░░████░░░█████░░░██▒▒▒··▒▒▒░░ ░░░░░ ░░░░░···░░ │ +│ ░·····░░░ ░░░ ░▒▒····▒▒██░░░░███░░░░████░░░░███░░░░██▒▒····▒▒░ ░░░ ░░░·····░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒██████████████████████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ │ +│ ▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒███████████████▓▓▓▓▓▓███████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒ │ +│ ·▒▒░░ ░░░░ ░░▒▒···▒▒██░░░░████░░░██▓▓····▓▓██░░░████░░░░██▒▒···▒▒░░ ░░░░ ░░▒▒· │ +│ ▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒███████████████▓▓▓▓▓▓███████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒ │ +│ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒██████████████████████████████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░ │ +│ ░·····░░░ ░░░ ░▒▒····▒▒██░░░░███░░░░████░░░░███░░░░██▒▒····▒▒░ ░░░ ░░░·····░ │ +│ ░░···░░░░░ ░░░░░ ░░▒▒▒··▒▒▒██░░░█████░░░████░░░█████░░░██▒▒▒··▒▒▒░░ ░░░░░ ░░░░░···░░ │ +│ ▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒██████████████████████████▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░▒▒ │ +│ ·▒▒░░░ ░░░░ ░░░░░ ░░▒▒···▒▒▒██░░░████░░░░████░░░██▒▒▒···▒▒░░ ░░░░░ ░░░░ ░░░▒▒· │ +│ ··▒░░ ░░░░ ░░░ ░░▒▒····▒▒█░░░░████░░░░████░░░░█▒▒····▒▒░░ ░░░ ░░░░ ░░▒·· │ +│ ▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒███████▒██████▒███████▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > hologram 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒█▒▒▒▒▒▒░░░░ ▓ ▓ │ +│ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░ │ +│ ░▓░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░█░░░░░░ │ +│ ░░░░░░░ │ +│ ░░░░ ▓ ▓ ░░░░ │ +│ ░░░░░▒▒▒░░░░ ▓ ░░░█▒░░░░ ░░░░▒▒▒░░░░░ │ +│ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░▓ ░░▒▒▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░ │ +│ ░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒█░░▓ ░░░░░ ▓░▒▓▓▓▒░ ░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒░░ │ +│ ░░█▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░░░░▓ ░▒▓▓▓▒░ ░░░░░ ░░░▒▒█▓▓▓▓▓▓▓▒▒▒▒░░ │ +│ ░░░█▒▒█▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░█░ │ +│ ▓ ░░░░░▒▒▒░░░░ ░░░░▒░░░░ ▓ ▓ ░░░░█▒▒░░░░░ │ +│ ░░▓░ ▓░░░░ │ +│ ░░░░░░░ │ +│ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ │ +│ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░█░ │ +│ ▓▓ ▓ ░▓░░▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > julia 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ······························ │ +│ ······░░░░░░░░················░░░░░░░░······ │ +│ ················································ │ +│ ·····░░░░░░··························░░░░░░····· │ +│ ············································ │ +│ ········································ │ +│ ···································· │ +│ ·································· │ +│ ···································· │ +│ ········································ │ +│ ············································ │ +│ ·····░░░░░░··························░░░░░░····· │ +│ ················································ │ +│ ······░░░░░░░░················░░░░░░░░······ │ +│ ······························ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > kaleidoscope 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ████◆✦*∘· ··∘*✦◆◆◆✦✦*******✦◆◆◆████████◆✦****✦◆████████◆◆◆✦*******✦✦◆◆◆✦*∘·· ·∘*✦◆████ │ +│ ████◆✦*∘· ··∘**✦✦✦✦✦✦****✦✦◆◆███████◆◆◆✦*∘····∘*✦◆◆◆███████◆◆✦✦****✦✦✦✦✦✦**∘·· ·∘*✦◆████ │ +│ ███◆◆✦*∘····∘∘***✦********✦✦◆◆███◆◆◆◆✦✦**· ·**✦✦◆◆◆◆███◆◆✦✦********✦***∘∘····∘*✦◆◆███ │ +│ ███◆✦**∘∘·∘∘∘*****∘∘∘∘**✦✦◆◆◆✦✦✦✦**∘∘∘∘*∘· ·∘*∘∘∘∘**✦✦✦✦◆◆◆✦✦**∘∘∘∘*****∘∘∘·∘∘**✦◆███ │ +│ ███◆✦**∘∘∘∘*****∘∘∘··∘∘*✦◆███◆✦*∘· ··∘∘··∘∘··∘∘·· ·∘*✦◆███◆✦*∘∘··∘∘∘*****∘∘∘∘**✦◆███ │ +│ ◆◆◆◆✦***********∘·····∘*✦◆████✦*∘· ·∘***✦✦***∘· ·∘*✦████◆✦*∘·····∘***********✦◆◆◆◆ │ +│ ◆◆◆✦✦*****✦✦✦✦**∘· ··*✦◆████◆✦*· ∘*✦***✦✦***✦*∘ ·*✦◆████◆✦*·· ·∘**✦✦✦✦*****✦✦◆◆◆ │ +│ ◆◆✦✦*****✦✦◆◆✦✦*∘· ·∘*✦◆███◆✦✦*∘∘∘∘∘∘**◆◆∘∘◆◆**∘∘∘∘∘∘*✦✦◆███◆✦*∘· ·∘*✦✦◆◆✦✦*****✦✦◆◆ │ +│ ◆◆◆✦✦*****✦✦✦✦**∘· ··*✦◆████◆✦*· ∘*✦***✦✦***✦*∘ ·*✦◆████◆✦*·· ·∘**✦✦✦✦*****✦✦◆◆◆ │ +│ ◆◆◆◆✦***********∘·····∘*✦◆████✦*∘· ·∘***✦✦***∘· ·∘*✦████◆✦*∘·····∘***********✦◆◆◆◆ │ +│ ███◆✦**∘∘∘∘*****∘∘∘··∘∘*✦◆███◆✦*∘· ··∘∘··∘∘··∘∘·· ·∘*✦◆███◆✦*∘∘··∘∘∘*****∘∘∘∘**✦◆███ │ +│ ███◆✦**∘∘·∘∘∘*****∘∘∘∘**✦✦◆◆◆✦✦✦✦**∘∘∘∘*∘· ·∘*∘∘∘∘**✦✦✦✦◆◆◆✦✦**∘∘∘∘*****∘∘∘·∘∘**✦◆███ │ +│ ███◆◆✦*∘····∘∘***✦********✦✦◆◆███◆◆◆◆✦✦**· ·**✦✦◆◆◆◆███◆◆✦✦********✦***∘∘····∘*✦◆◆███ │ +│ ████◆✦*∘· ··∘**✦✦✦✦✦✦****✦✦◆◆███████◆◆◆✦*∘····∘*✦◆◆◆███████◆◆✦✦****✦✦✦✦✦✦**∘·· ·∘*✦◆████ │ +│ ████◆✦*∘· ··∘*✦◆◆◆✦✦*******✦◆◆◆████████◆✦****✦◆████████◆◆◆✦*******✦✦◆◆◆✦*∘·· ·∘*✦◆████ │ +│ █████◆*∘· ·∘**✦✦✦✦✦**∘∘∘∘∘∘∘***✦✦◆██████◆✦✦◆██████◆✦✦***∘∘∘∘∘∘∘**✦✦✦✦✦**∘· ·∘*◆█████ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > lava-lamp 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ░▒▒▒▒▒░░ │ +│ ░▒▓█████▓▒░ ░░░░░░░░░░░░░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > leds 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ 150 Hz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□] │ +│ 400 Hz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□] │ +│ 1 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□] │ +│ 2.5 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□] │ +│ 6 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ 15 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ 20 kHz │ [■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 9 │ [■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 11 │ [■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 12 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 13 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 14 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 15 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 16 │ [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 17 │ [■■■■■■■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ Band 18 │ [■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□] │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > liftoff 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·· · · · + · + + · ++ │ +│ + · · ·+ · · · · · + · │ +│ · + · · · ·· · · ·· │ +│ · · · + ·· · ·· · + · · │ +│ ··· · + · · · ·· + ·· · · · + │ +│ · · · · · + + · · │ +│ + · · · · · · · · │ +│ · · ·· · · · │ +│ · + · + ░░░░ ▒▒▒▒▒▓·▓·▓██ ██▲\\ █ ▓▓▓ ▒·▒▒·░ ░░░░ · ·· · │ +│ · · · · · ·░·░░▒ ▒▒▓▓▓▓█ █/██\\ █ ▓·▒▒▒▒░░ · + · · │ +│ + · · + · · ·· ░░ ░▒▒▒═▓▓██|██|█ ▓ ▒▒░░ · · · · · │ +│ · ··· · ░ ░▒▒ ▓▓▓█|▓▓|▓▓▓▒▒▒░░ · · · · · · · │ +│ · · · ·░║═ ▓▓█|██| ▓▓ · + · + + + · │ +│ · ░ ░▒ ▓▓/| █|\\ ░ · + · · │ +│ · · · ░═ /_|██|_\\ ░ · │ +│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/__|__|__\\▀▀▀▀▀▀▀▀▀▀▀▀▀▀▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > lissajous 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·························· ·· ●● ●●●●●●●●●●●●●●●●●●●●●●●●●● │ +│ · ●●·●●●●●●●·●●●●● • │ +│ ···· ●● ●●●●●●● ······· ·· •••• │ +│ ······ ●●●●●●● ······· •••••• │ +│ ●●●●●●··· ••••••··· │ +│ ●●●●● ······· ••••••• ····· │ +│ ●●● ······· ••••••• ··· │ +│ ● ······ •••••• · │ +│ ●●● ••••••• ●●●●●●● ··· │ +│ ●●●●● ••••••• ●●●●●●● ····· │ +│ ●●●●●●••• ●●●●●●··· │ +│ •••••• ●●●●●●● ••••••• ●●●●●● │ +│ •••• ●● ●●●●●●● ••••••• •• ●●●● │ +│ • ●●•●●●●●●●•●●●●● ● │ +│ •••••••••••••••••••••••••• •• ●● ●●●●●●●●●●●●●●●●●●●●●●●●●● │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > lorenz 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀ ⠂⠐⠐⠠ │ +│ ⠄ ⠈ ⠈ │ +│ ⢀ ⠁ ⡀⡀⣀ ⢀⠁ │ +│ ⢠⠔⠂⠂⠄⠄⡀ ⡀ ⠂ ⡀⠠ ⠂⠁ ⢑ │ +│ ⠨ ⢠⠊⣑⠴⣂⡌⡐⡠ ⡀ ⠠ ⠈ ⡀⠠ ⠁ ⡂ ⠈ │ +│ ⠡⠘⡀⡧⣫⣻⣿⣾⣮⡴⣠⢂⠄⡀ ⡀⠠ ⠂ ⠁ ⠂ ⠐ │ +│ ⠡⠈⢕⠿⣿⣿⣿⣿⣿⣿⣷⣕⣦⠅⡢⣀⡠⠐⠐ ⠁ ⠠⠈ ⠄ │ +│ ⠈⡀⠪⠻⢿⣿⣿⣯⣿⣿⣿⣿⣯⣫⡢⡁⠢⢄ ⡀⠂ ⠠ │ +│ ⠡⢈⠪⢙⠽⣻⡿⠿⠵⣣⠇⡽⠈⡇ ⠈⣆ ⠄⠁ ⡀ │ +│ ⠐⠄⡉⠒⠬⠭⠭⠤⠞⠁⢀⡇ ⠘⡄ ⡀⠄⠁ ⢀ │ +│ ⠈⠈⠒⠠⠤⠤⠤⠴⠊ ⠙⢄ ⣀⠠⠂⠁ ⠄ │ +│ ⠉⠒⠒⠒⠊⠈ ⠐ │ +│ ⢀⠠ ⠁ │ +│ ⢀⠠⠐ │ +│ ⣀⣀⣀⣀⠤⠤⠒⠈ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > manhattan 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ˙ │ ˙ • │ ˙˙ │ +│ │ │ ▀▀▀▀ ˙ •• ˙ │ +│ • • │ │·│· ˙•● │ +│ • │ ╱◆╲ │▪│░ •• ˙ │ +│ ••• ▀▀▀▀▀ ╱◆╲ ▀▀▀▀▀▀ ▄·▄▄ ˙ ˙˙˙ │ +│ ▀▀▀ ▀▀▀▀▀▀▀ ˙ █ █ ╱╱◆╲╲ ˙ · •│ │ ││ ▀▀▀ │ │ +│ █·█ ▪│ █ █ ╱╱╱◆╲╲╲ █▪░ │· █ │·││ █·█ ▀▀▀▀ │ +│ █ █░ ░ •│· █▄▄ █ █· ││░█ █·▄•▄• █ ▪▄·▄▪ ░ ░│ █░█ █░ █ │ +│ █·█░ ░ ▀▀▀▀▀▀ ░ █· ▪│░ █ █ •█░ █▄▄│▄▄█░ █· ·│ █ ▪░ ││ ░ ░ ░│• █░█ █▄▄█ │ +│ █ █░▀▀▀ │ ░ ░ ░█ │ ·█ █▄▄ █░ █ •│·█░·█▄▄░▄· █ ░ •▄░▄▄░ ░ ░ █ ░ │ █░█ █ █ ░█ │ +│ █·█░█ █ █ ·│ █░ ░ ░█·▄ ▄• ▄█░ ░█ ░█░ ░░█▪ │·█░ █ │ █ ░█ ░·││ █░ ░ █•▄ ▄▄ █░█•█ █ █ │ +│ ░░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░░░ ░ ░ ░ ░ │░ │ ░ ░ ░ ░░ ░ ░ │ +│ •• • ─• • ˙ •• •˙• • • • ˙ • • •• ˙• ˙•••• ˙ ─• •˙ │ +│ ˙── ·· · ˙· ─ ·· ·· ˙ · · · ˙ · · · ˙·─ · ˙···˙ · │ +│ ˙ · · ˙ ─ ─ ─ ─ ˙˙ ˙ ˙ · · ─ · ˙ │ +│ ˙ ˙˙ ˙˙˙· ·· ˙ ·─ ˙ ─ ˙ · ˙ ˙ ˙ ˙ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > matrix 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ # ( ) F R T 2 4 T ( H ) │ +│ S # 4 ) ( 4 2 I E 1 % * ) │ +│ G 2 * ) Q * ( % 9 ) 6 W │ +│ T 4 R E % D J * ) L X 8 │ +│ G ( E $ ( 0 % 9 O 8 M 8 │ +│ F * I @ * 6 9 ^ & Z B W │ +│ R 2 U * I 0 L ) A 7 C ) K * │ +│ 2 R 5 % ( B L ) ) ^ Z % 5 I * │ +│ A * E 0 V 6 ^ 8 ) C Q ) G # 2 Q 5 │ +│ A ) ) ) ) ) ) ( ( ( * * & & │ +│ A I 6 Y & ( R G # Q ) ^ A │ +│ A * C M Z ( $ 2 % V 7 N & │ +│ U 9 & C ( 3 Q 9 ) N 6 │ +│ @ ) V I ( 2 C K 8 K │ +│ X E ) 5 │ +│ ) R X * │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > mesh 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ────── │ +│ ────────── ───────────────── │ +│ ────────────────── ──────── ───────── ──────────────────────────── │ +│ ───────────────── ────────────────── ─────── ──────────────────────────────────────── │ +│ ──────────────────────────────────────── ────── ───────────────────────────────────── │ +│ ──────────────────────── ────── ────── ──────────────────────────────── │ +│ ──────────────────────────────────────── ───── ────────────────── ─────── ──── │ +│ ── ───── ───────────────────── ─────── ─────── ─────────────── ────── ─── │ +│ ─── ───── ───────────────────────── ────── ─────────── ────── ──── ── │ +│ ─── ────── ──────────────────────── ─────── ─────────────── ──── ─── │ +│ ── ─── ───────────────────── ──────────── ────── ─── ─── │ +│ ── ───────────────────────────────────────────── ──── ─── │ +│ ── ─────────────────────── ───── ── │ +│ ────────────── ───────────────── ──── │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > moire 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ··░▓▓▒░·░▒▓▒░··░▒▒░· ·░▒░· ·░▒▒▒░░░░░░░▒▒▒░░·· ·░▒▒▒░···░▒▓▓▒░·░▒▓▓▒░░▒▓█▓▒░░▓██▒░░ │ +│ ·░▓▓▒░·░▒▓▓░··░▒▓▒· ·░▒▒░· ·░░░·· ··░▒▓█████▓▓▒░░· ·░▒▓▒░··░▒▓▓▓░░░▒██▒░░▒▓█▓▒░▒▓█▓▒░ │ +│ ░▒▓▓▒·░▒▓▓▒··░▓▓▒···▒▓▒░· ·░▒▒· ·░▒▓▓▓▓▓▓▓▒▒░░░░░▒░░····░▓▓▓▒░░▒▓█▓▒░░▓██▒░░▒██▓░░▒██▓░ │ +│ ▒▓▓▒░░░▓▓▒░·░▓▓▒░·░▒▓▓░··░▒▒▒· ·░░░░····░░▒▒░·· ·░▒▒▒░··░▒▓▓▒░░▒▓█▓▒░▒▓█▓▒░░▓██▒░░▓██▒ │ +│ ▒█▓▒░░▒█▓▒░░▒▓▓▒░░▒▓▓▒··░▓▓▒···░▒▒░· ·░▒▓███▓▒░·· ··▒▓▓▒░░░▓██▒░░▒██▓░░▒██▓░░▒██▓░░▒██▓ │ +│ ▓█▓░░▒▓█▓░░▒▓█▓░░▒▓█▓░·░▓▓▓░·░▒▓▒░· ·░░▒▓▓██▓▒░░░▒▒▒░·░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓ │ +│ ▓█▓░░▒▓█▓░░▒██▒░░▒██▒░░▒▓▓▒░░▒▓▓▒··░▒▒▒░··░░▒░· ·░▓▓▓▒░░▒██▓░░▒██▓░░▒██▓░░▓██▒░░▓██▒░░▓██ │ +│ ██▓░░▒██▒░░▓██▒░░▓█▓▒░░▓█▓▒░░▓█▓▒░░▓▓▓░· ░▒░· ·░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒██▓░░▒██▓░░▒██▒░░▓██ │ +│ ██▓░░▒██▒░░▓██▒░░▓██▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░· ·░▒░ ·░▓▓▒░░▒▓█▓░░▒▓█▓░░▒▓█▓░░▒██▒░░▒██▒░░▓██ │ +│ ▓█▓░░▒██▓░░▒██▓░░▒██▓░░▓██▒░░▒██▓░░▒▓▓▓▒· ·░▒▒░░░░▒▒░··░▒▓▓▒░░▒▓▓▒░░▒█▓▒░░▒█▓▒░░▓█▓▒░░▓█▓ │ +│ ▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▓█▓▒░··░░▒▒▒▒▓████▓▒░· ·░▒▒░··░▓▓▒░·░▓▓▓░░░▓█▓░░░▓█▓░░▒▓█▓ │ +│ ▓██▒░░▓██▒░░▓██▒░░▒██▓░░▒▓█▓▒░░▒▓▓▒░· ··░▒▓▓▓▓▒░· ·░▒░· ·░▒▓▒░·░▒▓▓▒·░▒▓▓▒░░▒▓▓▒░░▒▓▓▒ │ +│ ▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▓██▒░░▒▓▓▓▒··░░▒▒▒░· ··░░▒░░░░░░▒▒░· ░▒▒▒···░▓▓▒··░▓▓▒░·░▓▓▓░·░▓▓▓░ │ +│ ░▓██▒░░▒██▓░░▒▓█▓▒░░▒▓▓▒░·░▒▓▓▒░· ··░░░▒▒▒▒▓▓▓████▓▓▒░· ·░▒░░ ·░▒▒░··░▒▓▒░·░▒▓▓▒·░▒▓▓▒░ │ +│ ░▒▓█▓▒░░▓██▒░░▒▓█▓░░░▒▓▓▒░··░▒▒▒░· ·░░▒▓▓███▓▓▒░· ··░░░· ·░▒▒░· ·▒▓▒░··░▓▓▒··░▓▓▒░· │ +│ ░░▒▓█▓▒░░▓█▓▒░░▒▓▓▓▒··░▒▓▓▒· ·░░▒░░·· ··░░░░▒░░░░▒▒▒▒▒░· ░░▒░· ·▒▒▒···░▒▓▒··░▒▓▒░·░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > motion-contour 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ •• •• •• •• │ +│ • ····· •••• ·········· •••• ····· • │ +│ •· ∙∙ ∙∙∙ · ∙∙∙∙∙∙∙∙∙∙ · ∙∙∙ ∙∙ ·• │ +│ •· ∙·· ···· ·· ···· ··∙ ·• │ +│ ••··∙· • ∙ • ∙ ∙ • ∙ • ·∙··•• │ +│ ••··∙∙ · • •••••••••• • · ∙∙··•• │ +│ •• ·· ∙∙ ·· • ∙∙•• ••∙∙ • ·· ∙∙ ·· •• │ +│ •• ··· ∙∙ ·· • ∙ • • ∙ • ·· ∙∙ ··· •• │ +│ • · ∙ ·· ••••∙ ∙•••• ·· ∙ · • │ +│ ••·· ∙∙∙∙∙∙∙ · • • · ∙∙∙∙∙∙∙ ··•• │ +│ •••••••••·····∙ ∙·····••••••••• │ +│ ••·∙ ·· ∙·•• │ +│ · ∙∙ · │ +│ • ·· • │ +│ •••• │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > nebula 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·····░░░░░░░▒▒▒▒▒▒▒▒▒▒▒✦▒▒▒░░░░░░░······ │ +│ ✦······░░░░✦░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░·····✦ │ +│ ·······░░░░░░░░░░░░░░░░░░░░░░░░░░░······ ··· │ +│ ···········░░░░░░░░░░░░░░░░░░░░░········ ··· │ +│ ··················░░░░░░░░············ ✦ ·· │ +│ ✦ ································ ✦ │ +│ · ·························· │ +│ ·· ····························· ✦ │ +│ ··· ··········░░░░░░░░░░░░░░······✦·· │ +│ ··· ✦ ··········░░░░░✦░░░░░░░░░░░········ │ +│ ···········░░░░░░░░░░░░░░░░░········ │ +│ ····················· ························░░░░░░░░░·········· │ +│ ·································································· │ +│ ··········░░░░░░░░░✦░✦░░░░░········✦······················ │ +│ ······░░░░░░░░░░░░░░░░░░░░░░░░░░··········· ✦ │ +│ ·····░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░······· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > neural-net 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ⢠⣤⣀⣀⣀⣀⣀⡠⠤⠤⠤⠤⠤⠤⠒⠒⠒⣒⢶⡾⠿⢶⣶⣶⠶⣒⣒⡒⠒⠒⠒⠒⣒⣒⡲⢶⣶⡶⠾⠿⠶⡶⣒⡒⠒⠒⠒⠒⠒⠒⠒⠤⠤⠤⣤⣤⣤⣴⣶⠤⢄⣀⣀⣀⣀⡀ │ +│ ⠈⠉⠻⣿⣫⠶⣒⡒⠒⠤⠤⠤⢤⣠⣔⣊⣩⢒⠕⢁⣀ ⢙⣳⣫⣶⠦⢾⡟⠋⠛⣛⠦⢶⣮⣓⣉ ⢀⣀ ⠈⣑⣪⣉⠵⠶⠤⢖⣒⠒⣉⡩⠝⠒⠉⡱⠁ ⠈⠉⠉⠉⠉⠑⠒⠒⠒⠒⠒⢴⣶ │ +│ ⠈⢷⡫⡒⢎⣉⠶⠲⢍⣁⢀⣔⡪⡴⢭⣻⠟⠛⠻⠿⣭⠭⢯⣝⣯⢝⠛⢛⢭⣟⣉⠭⠭⠝⠛⠛⠛⠛⠛⠒⠒⠤⢵⣦⢤⣔⣊⣉⣑⠒⠤⣤⣊ │ +│ ⣀⡠⣝⣮⡵⢬⡓⠲⣮⡛⠉⣱⠾⢶⢕⣁⢀⣀⣀⣠⣔⡫⠽⠦⣗⣿⡋⠻⢗⡢⠬⠭⣒⣤⣀⣀⣀⣀⡠⠤⠒⠉⠁ ⠉⠢⢤⡀⢉⠝⠉⠉⢛⡿⠿ │ +│ ⠸⠿⢿⣿⡿⣒⣒⡻⣲⡞⠻⣶⢞⠟⢭⣝⣉⠭⡽⠟⠛⠉⠉⠉⡩⠝⠋⠉⠉⠉⠻⣯⢍⣛⠭⡉⠉⣉⠝⠛ ⡸⠓⢤⣠⠶⠋ │ +│ ⠉⠚⠵⣲⢮⣩⡻⣻⣖⢷⠶⡯⢭⣝⣚⣤⣀⣀⡠⠔⠉ ⠑⢄⠉⡺⠿⢤⣀⣀ ⢠⠊⡠⢒⠕⠉⠑⠤⣀⣀ ⢀⣀ │ +│ ⢠⣤⣾⣿⣮⣓⣒⣲⣫⣋⣩⣶⣽⣄⠙⢍⠒⠬⢦⣄⣀ ⠉⠢⢄⡀ ⣠⡲⠕⣒⠭⠊⠉⠒⢄⣀ ⠈⠒⠤⣀⡴⠋⡠⣊⡡⠔⣒⠭⠊⠁ ⢀⡠⠔⠊ │ +│ ⠈⠉ ⢀⡴⢋⠔⠊ ⠈⠻⡭⣙⢍⠉⠙⠛⠛⠲⢖⡢⢤⡬⣖⣶⡋⣠⣔⡪⠤⠤⣒⠶⠚⠛⠛⠛⠫⣭⣕⢚⡾⡷⠺⢟⡢⠴⠮⠤⠤⠤⢄⣀⣠⣤⡀ ⢀⠤⠒⠁ │ +│ ⣴⡫⠊⠁ ⠈⢆⠑⠳⢄⣀⣠⣤⠶⠭⡽⢖⡷⢝⡉⣙⠵⣖⠚⠭⠤⢄⣀⣀⣀⣤⣮⣓⣝⠽⢿⣶⠴⢟⡛⠒⢶⣉⠭⠝⠛⠉⠉⠉⠈⠉⠉⠑⠒⡲⠴⠮⢅⣀⣀ │ +│ ⡠⠞⠁ ⠑⢄⠘⠛⠉⢙⣿⡻⣿⡭⣝⣋⡉⣉⣛⡭⣽⡿⣛⠛⠉⠙⠛⠉⣩⢞⡳⠒⢥⣤⠤⣔⣚⣉⠓⠪⢕⣢⢄ ⢀⠤⠒⠉ ⢉⣉⣑⣒⣒⣢⢴⣶ │ +│ ⠸⠿ ⢣⣤⣴⣿⣷⣭⣒⣒⣊⣉⣉⣉⣉⣒⣒⣪⣵⣿⣦⣤⣤⣿⣯⣓⣊⣉⣁⣀⣀⣀⣀⣀⠭⠭⠭⠶⠿⠿⢶⣶⠮⠕⠒⠒⠒⠒⠊⠉⠉⠉⠉⠁ │ +│ ⠈⠉ ⠈⠉ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > newton 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ███████████████████████████████████████████████████████████▓▓▓▒░▓▓▓▓██▓▓▓░▒▒▒▓▓▓▓▓████████ │ +│ ████████████████████████████████████████████████████████▓▓▓▒▒▒▒░▒▒░░▒▓▓▓▓▓▓▓██████████████ │ +│ ██████████████████████████████████████████████████████▓▒·▓▓▓▓▓▓░▓▓▓▒▓▓████████████████████ │ +│ █████████████████████████████████████████████████████▓▒▒▓█████▓▓▒▓▓███████████████████████ │ +│ ███████████████████████████████████████████████▓▒▓▓▓▓▒░░▒▒▓▓▓█████████████████████████████ │ +│ █████████████████████████████████████████████▒▓▒██████▓▓██▒▓██████████████████████████████ │ +│ ▓▓▓▓▓█████▓▓███████████████████████████████▓▓▒▓███████▒▓▓█████████████████████████████████ │ +│ ▓▓▒░▒▒▒▒▒▓▓▓▓▒▓██▓▓▒▒▓▓▓░▓▓█▓▓██████▓▓▓▒▒▒▓▒▒▒▒▓▒▒▒▓▓█████████████████████████████████████ │ +│ ▓▓▓▓▓█████▓▓███████████████████████████████▓▓▒▓███████▒▓▓█████████████████████████████████ │ +│ █████████████████████████████████████████████▒▓▒██████▓▓██▒▓██████████████████████████████ │ +│ ███████████████████████████████████████████████▓▒▓▓▓▓▒░░▒▒▓▓▓█████████████████████████████ │ +│ █████████████████████████████████████████████████████▓▒▒▓█████▓▓▒▓▓███████████████████████ │ +│ ██████████████████████████████████████████████████████▓▒·▓▓▓▓▓▓░▓▓▓▒▓▓████████████████████ │ +│ ████████████████████████████████████████████████████████▓▓▓▒▒▒▒░▒▒░░▒▓▓▓▓▓▓▓██████████████ │ +│ ███████████████████████████████████████████████████████████▓▓▓▒░▓▓▓▓██▓▓▓░▒▒▒▓▓▓▓▓████████ │ +│ ███████████████████████████████████████████████████████████▓▓▓▒▓▒▓▓▓██▓▓▓▒▓▓▓▓▒▒▓▓▓▓▓▓▓▓██ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ordered-dither 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ wOkwd0qCLvYxvfvjufcrcrYvUz0Cm0dZqQZYXfr1(]|1ffJCddW*Wka0Cjf[[<]-(|XUpp&#&hoOQnn))-)[\\|zc0L │ +│ ?(}t/vrXvUxv/t-?li^I:~<)\\zcOQqJLnx]-;I "'!l})vvQLwJLuu{{<~,!;~i[}f\\uxXrvjn|t)\\[)])-}-{+[-) │ +│ [{xnQCZJQux[[I; ^ :,?]fjJYQXCnx1)~~:i:>l]-){j/x/ntj(j)\\}|[1_}+-i->-~1}t\\cuYuUxx1)<>^;',^~+ │ +│ xCCwCOYUff{1~?~]+{}t(rfvjvrcjvjvtx/r1|[1+]+}_)1xjYYZCOUCjj}[l!,!:+-tfJCdZdOZun1}l!"l,~_\\\\z │ +│ 0JmQwQpZp0p0ZYLvctx\\j\\vuLCkbWo%o*mmzu)|]{?ttXUkh8W@W&ppYc|/[1]t/zXqwok&aowdCCuzjntcxzuCYQU │ +│ rJUpb&#BM&pqUX||]1-|\\zXqb8M@&%bbCJtf1|[//zvOZad*k*wdQOzUuXrcnYnYzLzLJZCwObmbwk0mJCxn\\j)f/c │ +│ JLdpMhomwXv(\\?[~{[/\\zcQCq0mCOXUxzjx\\x/r\\x/r/ujcnJY0Cw0mJ0cc\\t[}+}?||zXmm*h*pbCY//--l_<}{uc │ +│ (rtn\\j|t}([)_[_[<->-i_~}-()xtvnXjx||~~:I "'!I]{nxCJOXUrf-+,, . :,-[rjUY0zYrr]]!>^;"iI_-|1f │ +│ /YUpp*dhZZnr{}i}1vzOOkqdLQnr}1~_!?+{{xrXcQYLcJuvtn\\t1t)\\{/1\\{t(j/cnUz0UQzJxr{)+_l_>[[ │ +│ )vnJXCnu||~~:l\`;,_i_l+>]+11j/vxXjn||~~,; \`.I:?[rrJUOXUrf-+,, ,"+?f │ +│ ujvjcrufufx|r(\\[(?[~[_}]t/vuQJ0UQvu))~<"l,ii1)ucmOdOqUX\\|~<"I"!l}{rxQCZCZXXtf[}-~1[\\(nf │ +│ (nuQCppopkZZvu))~_>]-/fCCbk&a#ppXc||+-<[-\\tYXmmaqkmwYYrn(/)t)ftvjzvCcCYQXQU0XLXJxvtr1|{\\{f │ +│ ZwWW@B@&Wwwzv|f1/(vvQQakWo8aowdLQcJuXnUcJX0JOCq0pZkqhqaqdQmYYrvtr/cvQQaa%W@&8bbCY/f{(}ffUJ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > phosphene 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ·░░░░░░░····░░▒▓████████▓▓▓▓▓▓▓▒▒▒▒▒░░░░▒░░░░░░░░░············░░░░▓▓▓▓▓▓▓▓▓▒▒▒▒░░░▒▒▒░▒▒▒░ │ +│ ░░░····░░░▓▓▓▓▓█▓▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒········░░░░░░░░░░░░░░░·░░░▒▒▒▒▓███████▒▒▒░░░·░░░ │ +│ ···░░░▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░··░░░░▒▒▒▒▓▓▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▓▓▓█████▓▒▒░░·· │ +│ ░▒▒▓████▓▒▒▒░░░▒░░░░░····░░░░▓▓▓▓███████████▓▓▓▓██████████████████▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓███▓▓▒░░ │ +│ ▓█████▓▓▒▒▒▓▒▒▒▒▒░░░▒▒▒▓▓▓▓▒▒▒▒░░░░▒▒▒▒▓▓▓█▓▓▓▒▒░░░░░▒▒▒▓▓██████████▓▓▓▒▒▓▒▒▒▒▒░▒▒▒▒▓▓▓▓▓▓ │ +│ ████▓▓▓▒▒▓▓▓▓▓▓▓▓▓▓▓█████▓▒░░···░░░░░▒▓▓███▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▓██████▒▒▒░░▒░░░░▒░░░▒▒▓▓▓▓ │ +│ ███▓▒▒▒▒▓▓▓▓▓▓▓█████████▓▓▒░░░░░░░░·░░▒▓▓████▓░░░▒▒▒▒▒░▒░░░░░····░▒▒▒▓▒▒░░░··░░░░░░░░▒▒███ │ +│ ▓▓▒▒░░░▒▒▒▒▒▒▒▓▓███████▓▓▒▒▒▒▒▒▒▒░▒▒▓████████▒██████▓▒▒░░░░░░░░··░░░▓▓▓▓▒░░░··░░░░▒░▒▒▓▓██ │ +│ ▒▒▒·····░░░░░··░░▒▓█████▓▒▒▒░▒▒▒▓▓▓▓▓████▓▓▓███▓▒▒░░··░▒▒▒▓▓▓▒▓▓▓███████▓▒▒░░▒▒▒▒▒▒▒▓▓████ │ +│ ▒▒▒▒░····░░░░░░··░░▒███████▓▓▓▓▓█▓▓▓▓▒▒░░░░··░░▒▒▒▒░░░░░░▒░░▒▒▓▓██████▒▒▒░░░░░░▒▒░▒▒▓█████ │ +│ ▓▓▓▓▓▓░░···░░░░░░·░░▒▒▓████████▓▓▒▒░░·····░░░░▒▒░░▒▒▒▒▒▒▒▓▓███████▓▒░░░··░░░░░░░░▒▒▓██████ │ +│ ▒▒▓█████▓▒▒▒░░░░░░░░▒░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░▒▒▒▓▓█████████████▓▒▒░░···░░░░░░····░▒▒▓████▓▓▓ │ +│ ▒▒▓▓▓███████▓▓▓▒▒▒░▒▒▒▒▒░░░·░░░░░░░░░░░░▒▓▓██████████████▓▓▒▒░░░░░░░░░░······░▒▓▓▓▓▓▒▒▒▒▒░ │ +│ ▒▒▓▒▒▓▓████████████▓▓▒▒▒░░▒▒░░░░░░░░░░░░▒░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒░░········░▒▒▒▒▒▒▒░░░░░░░░░░ │ +│ ░▒▒▒▒▓▓▓▒▓▓▓████████████▓▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░···░░░░░░░· │ +│ ▒▒▒░░▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▓▓▓█████████▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓█████████████▓▓▓▒▒░░░░░···░░░░░░░░·░░░░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > phyllotaxis 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⢀⢀ ⢀ │ +│ ⠠⠠ ⠌⠌⡀⠂⠌⡠⢈⠠⢐⠄⠄ │ +│ ⢀ ⠅⠅⠌⠨⠨⢀⠂⢌⠂⠄⠢⠐⠄⢂⠡⠡⠐ │ +│ ⢀⠨⠐⡀⠅⠅⠅⠅⡂⠂⠌⠂⡈⠄⠡⡈⠌⠄⢂⠡⠡⠨⠈⠄ │ +│ ⠨⠐⡀⠅⢂⢂⢈⢈⠐⠠⢁⢂⠑⠈⠌⠄⠄⠡⠨⢐ ⡂⡁⠅⡁⡂ │ +│ ⠅⠡⠐⡈⠄⠂⡐⡐⠠⢑⢀⠂⡐⠡⠂⡁⠌⠌⢈⠠⢀⠂⡂⡂⡂⠄⠄ │ +│ ⠠⢈⢂⠑⢈⠄⡂⠅⠄⠄⠨⠐⡐⡐⢀⠡⠡⠐⡈⡀⡂⢊⠂⡐⢐ ⠄⠅⡂⠄ │ +│ ⠠⢀⠂⠔⠠⢁⠠⢐⠈⢌⠂⡁⠄⢂⠂⠌⠌⡐⢀⠂⢄⠡⠈⠄⢂⠌⡐⠠⢐ │ +│ ⠈⢐⠠⠡⢁⠂⡂⡐⠠⢂⠂⠂⢂⠠⡐⠠⢐⢐⢐⠐⡀⠂⡂⡁⠄⠢⠐⠄⠢ │ +│ ⠠⡈⢐⢐⢐⢐ ⡁⠂⠌⠨⠠⢂⠄⢂⠂⡐⡐⠠⠠⠁⠄⠂⢊⠠⡈⠌⠄ │ +│ ⠐⠐⠠⠠⠐⡀⡂⠅⠅⢁⠨⠠⡐⠠⠂⠔⠠⠨⢈⠈⡈⠌⠄⢂⢐⠈ │ +│ ⠈⠌⠌⠄⠄⢂⠡⠈⠄⠡⠡⢀⠢⠁⠂⠅⡁⡂⡂⡂⢁⠡⢐ │ +│ ⠐⡈⠌⠄⠂⡑⢈⠠⡁⢂⠐⠌⢈⢐⢀⢂⠂⡂⡐⠈ │ +│ ⠁⠑⢈⠠⢐⠠⢁⠊⠠⢂⢂⠂⠐ │ +│ ⠈ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > pixel-crush 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓▓▓▓████████████████████████████████████████████▓▓▓▓████▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓ │ +│ ▓▓▓▓████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓▓▓▓▓████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓▓▓▓▓████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒ │ +│ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░ │ +│ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▓▓████████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░ │ +│ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▓▓████████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░ │ +│ ▒▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░ │ +│ ▒▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░ │ +│ ░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓████▓▓▓▓▒▒▒▒▓▓▓▓░░░░░░░░░░░░ ░░░░ ░░░░ │ +│ ░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓████▓▓▓▓▒▒▒▒▓▓▓▓░░░░░░░░░░░░ ░░░░ ░░░░ │ +│ ▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░▒▒ │ +│ ▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > planetarium 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ······░░░░░░░░░░░░░░░░░░░░░··························▓█▓······+ * ˙ ●· ····+·· │ +│ ·····░░░░░░░░░░░░░░░░░··░················ ··˙· ·· · · ····· ···· ·· · ·˙······· │ +│ ····░░░░░░░░░░░········░········ ·· ··········* ···+···* ······· ·· ···· ·· · · ·*···░░░· │ +│ ····░░+░░░·········░·········· ······+···· ····*···················▓███▓════····░·░░░░░░░░ │ +│ ··········░···░·····▒▓███▓▒················▒▒▒·········*······════▓█████▓════·······░░˙˙░˙ │ +│ ···················▓███·█▓··········+·▒·+▓▒·▒▒··▓······+····═════·▓·██▓════··········░░·░ │ +│ ·· ·· ······+··▒▓███▓▒·++···· ●···▒▓▒▓▓▓▓▓▓▒▓▒······*·····═════···············*····░░ │ +│ · · ····+··░·····░······· ·· ·▒▓▒▒▓▓███▓▓▒▓▒·+░··░······✦·· ················+····· │ +│ ·······█·█▓···░···░····░░···●····+·█·█·▓▓▒▓▓▓▓▓▓▒▓▓▒········+·······················*····· │ +│ · ····▒▓▒·░···░░····░·············+·····▓▓···▒▓·+···+········˙········▒▓▒·══··········· │ +│ ·········+···*·····+···········+····▒▓·▒·█·█·+●·····✦·······══·▓·██▓·══······ │ +│ · ····*+········· ······ ·······*····+·······═·˙·· ········═══·▒▓▒······ + │ +│ ···········░*···········˙···· ··+ ··········· ··*··+·· ·˙ ·· ···· ········ · + │ +│ · ·*·*································ · · ·· ··✦··· · ······· ·· ·· ˙ ● ˙ ˙ · │ +│ ·˙✦ ·· ··˙ · ˙˙ ·✦·· · · ·· · · · ·✦·*· · + · ˙ · │ +│ +· ˙˙ * · · · · ˙ ·˙ * ˙ + │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > pulse-grid 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ · ∘ ● ◉ ◉ ◉ ● ∘ · · · ∘ • ● ◉ ◉ ◉ ◉ ● • • ∘ ∘ ∘ ∘ • ● ● ◉ ◉ ◉ ◉ ● • · · · · • ● ◉ ◉ ◉ • ∘ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > radial-eq 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ⠐⣄⢤ │ +│ ⠙⣌⢎⢧⢧⢠⢀ ⡄⡠ │ +│ ⠘⢮⣫⣣⣯⣟⣾⣱⡇⡄ ⣠⣼⣜⡝⠁ │ +│ ⠐⢕⢵⡽⣻⣿⣿⣇⣷ ⣶⣟⢯⡮⡊ │ +│ ⠠⣜⣒⠭⣗⡬⣶⣕⡷⣵⣿⣿⣿⣿⠿⠿⣿⣿⣿⣿⣮⢄⣀⣀ │ +│ ⢒⡪⠭⢖⣻⣽⣿⣾⣿⣿⡟⠉ ⠉⢻⣿⣿⣷⣿⣯⣗⡀ │ +│ ⠐⠫⠭⠭⠵⠾⠿⢿⣿⡟ ⢻⣿⡿⠿⠷⠮⠭⠭⠔⠒ │ +│ ⠉⠉⠈⢉⣿⣿⣧⣀ ⣀⣼⣿⣿⡿⣿⣟⣯⠵⣒⡪⠭⢍⡁ │ +│ ⢀⡠⡷⡻⣿⣿⣿⣿⣶⣶⣿⣿⣿⣿⢟⢾⢝⡿⣚⠽⠒⠉⠉ │ +│ ⢀⡴⡪⡪⡺⠳⢽⣿⣿⠏⣿⣿⣿⣯⣞⢗⢕⢕⢯⡺⠁ │ +│ ⠊⠘⠁ ⠁⠁ ⢸⢏⡿⣽⣻⢫⡋⠁⠁⠁ │ +│ ⠈⢸⢱⢣⢳⢳⡀ │ +│ ⠘⠈⡎⣇⢧ │ +│ ⠑⠸⠈ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ribbon 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒▒▒▓▓▓▓▓▓████████ │ +│ ▒▒▒▒▓▓▓▓████████████ │ +│ ▒▒▒▓▓▓▓███████████████ │ +│ ▒▒▒▓▓▓▓████████████▓▓▓▓▓ │ +│ ▒▒▒▓▓▓██████████▓▓▓▓▓▓▓▒▒▒ │ +│ ▒▒ ▒▒▒▓▓▓█████████▓▓▓▓▓▒▒▒▒▒▒▒▒ │ +│ ▓▓▒▒▒▒▒ ▒▒▓▓▓█████████▓▓▓▓▓▒▒▒▒▒▒▒ │ +│ ██▓▓▓▒▒▒▒▒▒▒▒ ▒▒▒▓▓██████████▓▓▓▓▓▒▒▒▒▒▒ │ +│ ▓██████▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓██████████████████▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒ │ +│ ▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓████████▓▓▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▓▓████████████▓▓▓▓▓█████████████▓▓▒▒ │ +│ ▒▒▓▓████████████████████████▓▓▓▒▒ │ +│ ▒▒▓▓▓███████████████████▓▓▓▒▒▒ │ +│ ▒▒▓▓▓▓▓███████████▓▓▓▓▒▒▒ │ +│ ▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒ │ +│ ▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ripple-tank 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⡏ ⢠⣿⠃ ⢠⣾⠁ ⠸⠿⠛ ⠰⣿⡷ ⠈⠉⠁ ⠿⠟ ⣴⣦⡀ ⢀⣀ ⢀ ⠘ │ +│ ⢀⣿⡏ ⢠⣿⠃ ⢀⣄ ⣀⡀ ⢀⣀ ⣿⣿ ⠉⠛⠁ ⠈⢿⣷ ⠸⣿⡄ │ +│ ⣼⡿ ⢀⣿⡏ ⢠⣿⠃ ⢀⡀ ⣿⡗ ⢸⣿ ⢀⣤⡀ ⢠⣤⡀ ⠹⣿⡀ │ +│ ⢠⣿⠇ ⣼⡿ ⢠⣿⡏ ⢀⣤ ⠐⢿⠿ ⠈⠁ ⢸⣿⠃ ⢠⣤⡀ ⢿⣷ ⠙⠃ │ +│ ⢸⣿ ⢠⣿⠇ ⣼⡿ ⢠⣿⡏ ⣰⣿⠆ ⠘⠛⠃ ⣶⣄ ⠛⠃ │ +│ ⣿⡏ ⢸⣿ ⢀⣿⡇ ⣾⡿ ⢀⡀ ⢀⣿⡄ ⣰⡇ ⣀ ⠉⠉ ⠹⣿ │ +│ ⣿⡇ ⣸⣿ ⢸⣿ ⣿⡇ ⢸⠟ ⢸⣿⠁ ⣿⡇ ⢰⣿ ⣠⡀ ⢀⣠⡀ │ +│ ⣿⡇ ⢹⡟ ⢸⣿ ⢿⡇ ⣾⡟ ⢰⣿⠃ ⣿⡏ ⢰⣿⠁ ⢀⡀ ⠘⠿⠟ ⢠⡀ │ +│ ⠘⠁ ⠁ ⢻⣿⠆ ⢠⣿⠃ ⣾⡟ ⢰⣿⠃ ⣿⡏ ⢀⡀ ⢸⣿ ⣿⡇ ⣿⡧ │ +│ ⣀ ⠁ ⣿⠃ ⢸⡿ ⢰⣿⠇ ⠿⠇ ⢸⣿ ⢠⣿⡇ ⣿⡇ │ +│ ⢿⣷ ⢀⣴⣦ ⠘⠃ ⠈⠟ ⢀⣿⡏ ⢸⣿ ⢀⣿⡇ │ +│ ⠐⣷⡄ ⠉ ⢸⣿⡆ ⠘⠛⠃ ⣠⡀ ⢀⣀ ⣾⡟ ⢀⣿⡏ ⣸⣿ ⢀ │ +│ ⢰⣦⡀ ⠹⣿⡄ ⠉ ⢰⣿⠇ ⣴⡄ ⢠⣄ ⠿⠿⠃ ⠁ ⣼⡟ ⢀⣿⡏ ⣸ │ +│ ⢰⣦ ⢿⣷ ⠈ ⣤⡀ ⣠⣤ ⠿⠇ ⠻⡿ ⣸⠟ ⣼⡟ ⢀⣿ │ +│ ⠈⣿⣧ ⠈⠻⠇ ⠻⣿⠆ ⠠⣶⣦ ⠻⠟ ⢀⡀ ⣀⣤⡀ ⢀⡀ ⣼⡿⠁ ⣾⡟ │ +│ ⠘⢿⡆ ⢀⡀ ⠉⠉ ⢿⡿ ⢰⣶⡄ ⠻⠟⠃ ⢠⣾⡿⠃ ⠰⠟ ⣼⡿⠁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > rotozoomer 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ │ +│ ▓▓███████▓▓▓▒▒▒░░░·······░░░▒▒▒▓▓████████▓▓▓▒▒░░░········░░▒▒▒▓▓▓███████▓▓▓▒▒▒░░░·······░░ │ +│ ▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓▓▒▒░░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░ │ +│ ▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓▓▒▒░░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░ │ +│ ▓▓████████▓▓▒▒▒░░░·······░░░▒▒▓▓▓████████▓▓▓▒▒░░░········░░░▒▒▓▓▓███████▓▓▓▒▒▒░░········░░ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ░░········░░▒▒▒▓▓▓███████▓▓▓▒▒░░░········░░░▒▒▓▓▓████████▓▓▓▒▒░░░·······░░░▒▒▒▓▓████████▓▓ │ +│ ░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░░▒▒▓▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓ │ +│ ░·········░░░▒▒▓▓█████████▓▓▒▒░░░·········░░▒▒▓▓█████████▓▓▓▒▒░░·········░░▒▒▓▓▓█████████▓ │ +│ ░░·······░░░▒▒▒▓▓▓███████▓▓▓▒▒▒░░········░░░▒▒▓▓▓████████▓▓▒▒▒░░░·······░░░▒▒▒▓▓▓███████▓▓ │ +│ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > silk 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠉⠓⠦⠤⣄⣀⣀⣀⣀⣀⣠⠤⠴⠒⠒⠋⠉⣉⣉⡭⠭⠭⣽⣛⣛⣯⣭⣭⠭⠭⠤⠴⠒⠒⠚⠉⠉ ⠉⠓⠦⣄⡀ ⠈⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠓⠒⠲⠤⢤⣀⣀⣀⣀⣀⣀⡤⠤⠖⢋⣡⠤ │ +│ ⠉⠓⠲⢤⣀⡀ ⢀⣀⡤⠴⠚⢉⣡⠴⠒⣋⣩⠥⠤⠖⠒⠦⠬⢭⣉⣉⠓⠒⠒⠒⠒⠒⠒⠒⠋⠉⠉⠉⠉⠉⠉⠉⠓⠒⠦⣄⣀ ⠉⠓⠲⠤⣄⣀⣀⣀⣀⣀⣀⣠⠤⠤⠴⠒⠒⠒⠒⢒⣒⣒⣒⣒⣒⣒⣒⣒⣒⣒⣒⣋⡭⠤⠴ │ +│ ⠒⠦⢤⣀⣀⠉⠙⠒⠒⠒⠒⠒⠋⠉⣀⡤⠴⢚⣩⠴⠚⣉⣡⠤⠴⠒⠒⠒⠒⠒⠦⢤⣈⡉⠓⠒⠦⠤⣄⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡀ ⠈⠙⠲⢤⣀⡀ ⣀⣠⠤⠖⢚⣉⡭⠴⣒⣺⠭⠭⠭⠭⠭⠭⣗⣒⡒⠒⠒⠒ │ +│ ⠤⠤⠤⠤⢬⣉⣓⣒⡒⠒⠒⣒⣚⣩⠥⢖⣚⡭⠴⠒⠋⢁⣀⣠⠤⠤⠤⠤⠤⠤⠤⣄⣀⠉⠓⠦⢤⣀ ⠉⠉⠓⠲⠤⣄⣀⠉⠙⠒⠒⠒⠒⠒⠒⠚⠉⣁⣠⠴⢚⣩⠴⢚⣩⠥⠖⠒⠚⠉⠉⠓⠒⠲⠤⣍⡉⠓⠒ │ +│ ⠒⣒⣲⢯⣽⡿⠿⢿⣿⣿⣿⡷⠾⣟⣛⣛⡒⠒⠒⠋⠉⠉ ⣀⣀⣀⣀ ⠈⠙⠲⢤⣀⠈⠉⠓⠒⠦⠤⠤⠤⠤⠤⠤⠴⠒⠒⠒⠒⠒⠲⠤⠤⢬⣉⣓⡒⠲⠤⠤⠴⠒⣒⣋⡥⣖⣺⠭⠖⢋⣉⡤⠤⠖⠒⠒⠚⠙⠒⠒⠲⠤⣄⡉⠙⠲ │ +│ ⣩⢵⣺⠭⠖⠚⣉⣉⣀⣀⣀⣉⣉⠓⠲⠤⣍⣉⠉⠉⠉⠉⠉⠉⠉⠉⠁ ⠈⠉⠉⠙⠒⠦⣄⣈⠉⠓⠦⣄⣀⡀ ⢀⣀⣀⡤⠤⠴⠒⣒⣒⡯⣭⣿⠷⣿⣿⣛⣻⣿⣿⠷⢿⣛⣓⣒⣚⣉⣉⣀⣀⣀⣠⠤⠤⠤⠤⠤⠤⢤⣀⣀⠉⠙⠲ │ +│ ⣚⣩⠤⠖⠚⠉⠁ ⠈⠉⠓⠲⢤⣈⠉⠙⠒⠒⠦⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢤⣀⣈⠙⠲⠤⣄⣀⠉⠉⠉⠉⠉⠉⢀⣀⡤⠴⢒⣫⠵⣚⡭⠗⠚⣉⣁⡤⠤⠤⠤⣌⣉⡙⠒⠦⠤⣄⣀⡀ ⠈⠙⠒⠦ │ +│ ⠤⠤⠤⠴⠒⠒⠋⠉⠉⠉⠉⠉⠙⠒⠲⢤⣀⡈⠙⠒⠦⣄⣀⡀ ⢀⣀⣀⣀⣀⣈⣉⣓⠲⠤⢬⣉⣉⣓⠒⣋⣉⣩⠤⢖⣺⠭⢖⣋⡥⠖⠚⠉⠁ ⢀⣀⣀⣀⡀ ⠉⠙⠲⢤⣀⡀⠉⠉⠉⠙⠒⠒⠋⠉⠉⠉⠉⠉⠓⠒⠒⠦⢤ │ +│ ⣀ ⠈⠉⠙⠲⠤⣄⡀⠉⠙⠒⠦⠤⠤⠤⠤⠤⠖⠒⠋⠉⣁⣠⠴⠒⣋⡭⠵⠒⠒⢚⣉⣉⡓⠒⠒⠲⠤⠤⢤⣀⣀⣀⣀⣀⣀⣀⡤⠤⠤⠤⠤⠤⢤⣀⣀ ⠉⠓⠦⣄⡀ ⢀⣀⣀ │ +│ ⠈⠉⠓⠒⠦⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣄⣀⣀⠉⠙⠲⠤⢤⣀⣀⣀⣀⣠⠤⠴⠒⠋⣁⡤⠖⢋⣡⠤⠖⠋⠉⠉ ⠉⠉⠙⠒⠲⠤⣄⣀ ⠈⠉⠓⠲⢤⣀ ⠉⠙⠒⠲⠤⠤⠤⠴⠒⠒⠋⠉⠉⢀⣀ │ +│ ⠒⠦⣄⡀ ⠈⠉⠓⠒⠦⠤⢤⣀⣀⣀⡤⠤⠖⠚⣉⡥⠴⠚⠉⣀⡤⠴⠒⠚⠉⠉⠉⠉⠙⠒⠲⢤⣀⡀⠈⠉⠓⠒⠲⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣄⣀⡀⠈⠙⠒⠦⢤⣀⣀⣀⣀⣀⣀⡤⠴⠒⠋⢉⣠ │ +│ ⣀⡀ ⠉⠙⠒⠒⠲⠤⠤⠖⠒⠒⠒⠋⠉⠉⠉⠉⢉⣉⣉⣉⣓⣒⣒⣒⣒⣒⣒⣒⣋⣩⠥⠴⠒⠚⠉⠁ ⣀⣀⣠⠤⠤⠤⠤⠤⣄⣀ ⠉⠓⠦⣄⣀ ⠉⠙⠒⠲⠤⣄⣀⣀ ⢀⣀⣠⠤⠖⠚⣉⡤ │ +│ ⠉⠓⠦⢤⣀⡀ ⣀⣀⡤⠤⠖⠚⠉⣉⡤⠤⠖⣒⣚⡭⠭⠭⢷⣒⣒⣒⠒⠒⠒⠒⠒⠚⠉⠉⠁ ⠈⠉⠓⠦⢤⣀ ⠈⠉⠓⠒⠒⠦⠤⠤⠖⠒⠒⠒⠒⠋⠉⠉⠓⠒⠒⠒⠦⠤⠤⠬⣍⣉⣩⠤⠤⠴⢚⣉⣡⠤ │ +│ ⠙⠲⠤⣄⣀ ⠉⠉⠉⠉⠉⠉⠁ ⣀⣠⠴⠚⣉⡥⠖⢚⣉⡥⠴⠒⠒⠒⠒⠲⠤⢬⣉⡉⠓⠒⠒⠦⠤⠤⠤⠤⠖⠒⠒⠒⠒⠒⠒⠦⠤⣄⣀ ⠈⠙⠲⢤⣀⡀ ⢀⣀⣠⠤⠴⠒⠒⢋⣉⣩⠭⢭⣭⣭⣭⣭⣭⣭⡭⠭⠭⠤⠤⠖ │ +│ ⠲⠤⠤⣄⣈⡉⠓⠒⠒⠒⠒⠒⠚⢉⣁⡤⠖⣋⡥⠖⠚⣉⣠⠤⠤⠖⠒⠒⠒⠲⠤⢤⣀⡉⠓⠲⠤⣄⣀⡀ ⠈⠉⠓⠦⢤⣀ ⠉⠉⠉⠙⠋⠉⠉⠉ ⢀⣠⠤⠖⣋⡭⠴⣚⣩⠭⠖⠒⠒⠒⠒⠲⠭⣍⣙⡒⠒⠒ │ +│ ⠤⠖⠒⣲⣤⣭⣭⣽⣶⣶⣶⣯⣭⣭⣖⣚⡭⠥⠖⠚⠉⠁ ⠉⠓⠦⣄⡀ ⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠙⠒⠒⠲⠤⣄⣈⡉⠓⠒⠦⠤⠤⠤⠖⠒⢋⣉⡤⢖⣋⡥⠖⠋⠁ ⠉⠙⠒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > skyline 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · · ▀▀▀▀▀ · · │ +│ █▀▓▓█ · • │ +│ · █▒▓▓█ · · · •••· │ +│ + + █·▓▀█ · • · • • │ +│ · █▓▓·█ │ │ │ │ │ +│ ·+ · █▒▓▓█ │ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀▀ │ +│ · · • █▀▒▓█ ▀▀▀▀▀▀▀▀▀ ·█▒▓▒▓·▓·█ █·▓▓▓·█ ·▓▀▓· █·▓▀▓▓ │ +│ │ │ █·▓·█ █·▓·▓▓▓▓█ █·▓·▓▓▓▀█ █▓▓▓▒▓█ ▓▓▓▓▓ █·▓·▓▓ │ +│ ▀▀ █▀▓▒▓·█ █▓▓·▓·█ █▀▓▓█ █▀▓▓▓▓▓▒█ █▀▒▀▒▓▓▀█ █▓▓·▓▓█ █▀▓▓▓▀█ █·▓▀▓▀ │ +│ ▄▄ █▀▓▓▓▓█ █·▓·▓▀█ █·▒▓█ █·▓▀▓·▓▀█ █▓▓▀▓▀▓▀█ █▓▓▓▓·█ █▀▓·▓▓█ █·▒▓▓▒ │ +│ ·││ · │ █▓▓▓▓▀█ █·▓▀▓▓█ █▀▓▓█ █▓▓·▒▒▓·█ █▀▒▀▓▀▓▀█ █▀▓▓▓·█ █▀▓▓▒▀█ █▀▓▀▒· │ +│ ▀▀▀▀▀ ▀▀▀▀▀▀▀ █▀▓▒▓▀█ █▓▓▀▓▒█ █▓▓▓█ █▓▓▀▒▓▒·█ █▓▓▀▓▓▓▓█ █▀▓▀▓·█·█·▒·▒▓█ █▀▓▓▓· │ +│ █▀▒▀█ █▀▓·▓▀▓▓█ █▓▓▓▓▓█ █▀▓·▓▓█ █▓▓▓█ █·▓·▓▀▓▓█ █▓▓▀▓▒▓·█ █▓▓▀▓·█ █▀▓▀▓▓█ █·▓▀▓▀ │ +│ ·▄▀▄▀ ▀▄▀▄▀▄▄·▀ ▀▄▀▄▀▄▀ ▀·▀▄▀▄▀ ▄▄▀▄· ▀▄▀▄▀▄▀▄▀ · ▀▄▄▄▄▄▄▄▀ ·▀▄▀▄▄▄▄ ▀▄▀▄·▄▀ ▀▄▀▄▀▄ │ +│ • • • • •█ • ••█ • • │ +│ ────·· · ────˙· ·────· · ──── · ───˙ ·────· ·˙ ──── · ────· ──── ────··· │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > spectrogram 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ░░ ░░ │ +│ ░░ ░ ░░ ░ ░░░ ░░ │ +│ ░░ ░░░ ░░░ ░░░░░ ░░░░ ░░░░ ░░▒▒░░░░░▒▒▒░ │ +│ ░░░░ ░░ ░ ░░░░ ░░░ ░░░░ ░░▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░▒▒▓▓ │ +│ ░░░░░ ░░░░░ ░ ░░░░░░░▒▒▓▒▒▒▒▒▓▓▓▓▓▒▒▒▓▓▓▒▒▒▒▒▒▓▓▒▒▒▒▓▓▓▓▓▓▒▒▓▓▓▓▓▒▒▒▒▓ │ +│ ░░░░░░░░ ░░░░░ ░░▒▒▒░░░▒▒▓▓▒▒▒▒▒▓▓▓▒▒▒▒▒▓▓▓▒▒▒▒▓▓▓▓▓▒▒▒▓▓▓▓▒▒░░▒▒▒░░ ░░░░░░ ░░░▒▒▒░░░▒ │ +│ ▓▒▒▓▓████▓▓▓████▓▓▓▓▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓▓██▓▓▓▒▒▓▓▓▒░░░░░░░░ ░░░░░░░░░░▒▒░░░░░░░░░ ░░░ │ +│ ███████████████████████████▓▓▓▓▓▓▒▒░░░▒▒▒░░░░░▒▒▒░░░░░▒▒░░ ░░░░ ░░░ ░░░▒░░░░░░▒░░░ │ +│ ██████████████████████████████▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒░░ ░░░░ ░░░░░░░░▒▒▒▒░░░░▒▒▒░░░░▒▒▒ │ +│ ▓▓▓▓▓▓▒▒░▒▒▒▒▒▒▒▒▒▓▓███▓██████████████▓▒▒▒▒▒▒▒░░░▒▒▒▒▒░░░▒▒▒▒░░░░░░░░░ ░░░▒░░░░▒▒▓▓▓▓▓███ │ +│ ▒▒▒▒▒▓▓▒▒░░▒▒▒▒▒░░░░▒▒▒▒▒▒▒▓▓█████████████████▓▓▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▓▓▓▒▒▒▒▒▓▓▓▓▒▒▓▓███████ │ +│ ░░░░░░▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▓▒▒▒▓▓███████████████████▓▓▒▒▓▓▓▓▒▒▒▒▓▓▓▓▓▓▓██████████████▓ │ +│ ▓▒▒▒░░░▒▒▒░░░░░░▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒▓▓████▓█████████████████████████████████████████████▓ │ +│ ▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒░░░░░▒▒▒▒▒▒▒▓▓███▓▓████████████████████████████████████▓▓▒▒▒▒▒▒ │ +│ ░▒▒▒▒░░░░░▒▒▒▒░░▒▒▓▓▓▒▒▒▒▒▓▓▒▒░░░▒▒▒▒░░░░▒▒▒▒▒▒▓▓████████████████████▓▓▓████▓▓▓▓▓▓▓▓▓▒▒▒▒▓ │ +│ ▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒▒▒░░░▒▒▓▓▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒░░▒▒▒▓▓▒▒▒▓▓███▓▓▓█████▓▓▒▓▓▓▓▒▒░░▒▒▒▒▒░░▒▒ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > spinning-donut 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ~-:--;:-. =, .~=.,~;,-:-~ │ +│ ;;=;!=;..!;...*=. . *....!..:=!:=:; │ +│ !!*!-,*...#*....#..~..#.....#...!*-=*=! │ +│ #*;#~-#,,..$.,...$..#...$..,..$..,#-:*#*# │ +│ $@*@**@*!*!@**;;:@~~@~~~@;;*=*@**!@#*@@@@ │ +│ @@@@@@@@@@@**!!=@==@==@=!!**@$@@@@@@@@@ │ +│ @@@@@@@@@@@$#*@@$ $ @$#$$@@@@@@@@@@ │ +│ @@@@@@@@@ @@ $@@@@@@@@@@@ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > spirograph 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ⢠⢄ ⡞⢧ ⡼⢳ ⡀ │ +│ ⣀ ⠸⡀⠑⡇ ⢧⡸⠁⢸⢀⡴⠋⡇⢀⣀ │ +│ ⠱⡉⠒⠤⣇ ⡏⢢⡰⢇⢀⢼⠃⢀⡼⠛⢉⠞ │ +│ ⢀⡤⢄⣀⣙⣄ ⢸⠙⡧⣴⣑⣼⣧⢼⠋⢡⢃⣠⣇⣀⡠⢤⡀ │ +│ ⢑⣦⣀⣀⣘⢭⠭⣯⣿⠷⠛⠛⠿⣾⡭⡯⡽⣁⣀⣀⣴⠋ │ +│ ⠻⢭⣀⡈⠑⣤⡬⢳⣿⠃ ⠘⣿⠾⢥⡔⠊⠁⣀⠭⠟ │ +│ ⠸⠭⢤⡤⢮⠥⢤⠧⢼⣿⣶⣤⣤⡶⡿⡯⠽⡭⠶⡤⠤⠤⠬⠗ │ +│ ⢠⣋⡴⠥⢤⣛⠮⡟⢻⠝⢯⡝⣻⡕⢳⠤⣙⡄⠈⢢⡀ │ +│ ⠮⠖⢪⠏⢀⡰⡏⠙⣆⠜⢣⡇⠈⢹⢤⡀⠘⡕⠒⠵ │ +│ ⠯⠒⠁ ⡇⢠⢏ ⡜⠑⡄⢸ ⠈⠒⠽ │ +│ ⠷⠃⢸⣰⠃ ⠘⠾ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > starfield 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ +*✦ +++ *+ · + * + **━** │ +│ *++ * + ++ + ++ * ━** ** │ +│ + * ++ *++ + *+ + + ** ·· │ +│ +++* * **· ++ ++ * + ✦*++ ✦ * │ +│ *++ ━*━* *** ++*✦+ + *+ *** ++ ** │ +│ ***━* ++* ++ + **+ *+ · ·━ ++ + *++*++ │ +│ ━***+ + · + ·++ ━ + ***━********━**** │ +│ ++*✦✦✦*+ + ˙ · · ***━++✦✦*++· │ +│ ++*✦*+ ········· + ++ *·++·*+··*━****······ │ +│ + ·+++····+·+✦ + +··+ *+━·++·············+· +++*✦✦✦+ │ +│ ****━*** ············+· *+ ++* +·━+··········++····· │ +│ ** ···············+ ·* *+ ✦* ···+····+··*✦*+ * │ +│ ············*·✦+ * ++ +· ··✦····++ ** +++ ** │ +│ ━***·····✦**··*·+✦· + + + * + ✦*++ │ +│ ✦✦*++ **** ···+*++·**··+·· ✦ + * + + ━ ··· │ +│ ++* ········**····· + ++ * ·+ * ++ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > stormfront 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▓░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▓░░░░░░░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒ │ +│ ▓█▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒█▒▒▒▒█▒░░░░░░░░░░░░░░░▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒░▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓░░░░░░░░▒▒░▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▓░░░░░░░░█░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▓▒▒░▒▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░█░░░░░░░░░░█░█░░░░░▒▒▒▒▒▒░░░░░░░░▓░░░░░░░░░░░░░░░▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▓▒█▒▒▒▒▒█▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░▓░░░░░░░░░░░░░█░░░░░░░█░░░░░░░░░░▓░░░░░▒▒▒▒▒▒ │ +│ ▒▒▒▒▒█▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▓░░░░█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░ │ +│ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░░░░░░░░░░░░░░▓░█░░░░░░░░░░░░░░▓░░░░░░░░░░░░▓░░░░░░░░░░░░░░░░░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░··· █ █ ·····░░░░█░░░░░░·······▓····· █ ······░▓░ │ +│ ░░░░░░░░░░░░░░░░░░░░▓░░░░░░░··· ····░░░░░░··░······ █ ▓ │ +│ ·····░░▓░░░░░░░░░░░░█░░░░··░ ░ ······ ▓▓ ▓ ░ █ │ +│ ·······▓··░░░░░░░░░···· ▓ ▓ ▓▓ ▓ ▓░ ░ │ +│ ·█···········▓ ▓ ▓░░ ██ █ ▓ █ │ +│ ··· ··· ··· ░··· ···░ ··· █ ░▓··· ··· ··· ··· │ +│ █ █ █ ▓█▓ ░ ▓ │ +│ ░ ░ ⌁ ⌁ ⌁ ⌁ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > sumi-ocean 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ ▓ │ +│ · · ▓████▓ │ +│ ⌁ ▓██████▓ │ +│ ⌁ ▓████▓ │ +│ │ +│ ───·░·░──░·░░──···░──···───·░·───··░──·─··──░···───···───····──░░────···───···──· ·░──·░░░ │ +│ ─────────────░─────··───···─────·───────────────░·────··░────··───────────────·─────··──── │ +│ ░──━━░░───────━━░──────░░░━━───░░░░───━━░░░░░───░░░░─░────░━░────────━━━────░░░░─━━─░─░░░─ │ +│ ━·━▒▒━━━━━━━━━··━━━━━━━━━━▒····▒━━━━━━▒▒━━···━━━━━━━━·░░··━▒━━━━─░──··▒▒━━━━━━━━━···━━ ━━━ │ +│ ━━····▒▒▒▒▒▒·━━━···▒▒▒▒▒▒▒▒━━━━·▒▒▒▒▒▒░▒··━━━▒▒▒▒▒▒▒━━··━━━░▒▒▒───━━━━·▒▒▒▒▒▒▒▒··━━━▒▒━▒▒▒ │ +│ ▒▒━━━━━━━━━·━···━━━━━━━━━━━▒·▒▒━━░▒━━━━━━━▒▒▒━━━▒▒··▒▒━━▒▒▒━━━▒░···──▒─━━━━━▒····▒▒▒━━━━━━ │ +│ ···▒▒▒▒▒▀▀━━▓━━━▒▒▒▒▒▒▒▒····━━━━━━━▒▒▒····▓▓━▒▒━━━━━··▒▒▒▓▓━━━━·━━━━━▒▒▒▒▒▒▒·━━━━━━▒▒▒▒▒▒· │ +│ ━━━━━━━━━━▀▀▒▓▓▓━━━━━━━━━━━━▓▓▓▓▓▓▀━▓·━━━━━━▓━━▓▓▓▓▓▀━·▓▓━━▓▓▓▓━▓▓━▓━━━━▒▓▀▀▀▓▓▓▓▓▓━━━━━━▀ │ +│ ▓▓▓▓▓▓▓━██━━▀▀▒▒▓▓▓▓▓▀▀▀▓▓▀▓━━━━━━━▀▀━▓▓▓▓▓▓▓▓▓▀▀▀▀▀━━━━━▓▓▓▒ ▓▓▓▓━━━━▓▓━▀━━━▀▀▒▒▒▒▓▓▓▓▀▀━ │ +│ ▀▒▓▓▒━━█▒▓██━━━▓▓▒▓▓━━━━▀▀━━███████━━▀▀▓▓▓━━━━━━━━━━█████━▒━▀▀▀▀▀▀━━██━━━━███━━━━━▓▓▒▓▀━━█ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > tesseract 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ⠑⢄ ⡠⠊ │ +│ ⠑⢄ ⡠⠊ │ +│ ⠑⢄⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡠⠊ │ +│ ⢸⠑⣤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣤⠊⡇ │ +│ ⢸ ⡇⠑⢄ ⡠⠊⢸ ⡇ │ +│ ⢸ ⡇ ⢱⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⡎ ⢸ ⡇ │ +│ ⢸ ⡇ ⢸ ⡇ ⢸ ⡇ │ +│ ⢸ ⡇ ⢸ ⡇ ⢸ ⡇ │ +│ ⢸ ⡇ ⢸ ⡇ ⢸ ⡇ │ +│ ⢸ ⡇ ⡸⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢇ ⢸ ⡇ │ +│ ⢸ ⡇⡠⠊ ⠑⢄⢸ ⡇ │ +│ ⢸⡠⠛⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠛⢄⡇ │ +│ ⡠⠊⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠑⢄ │ +│ ⡠⠊ ⠑⢄ │ +│ ⡠⠊ ⠑⢄ │ +│ ⡠⠊ ⠑⢄ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > torus-knot 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ ⣀⡤⠖⠚⠉⠉⠉⠓⠲⠤⣀ │ +│ ⢀⣠⠤⠖⠒⠒⠒⠒⠒⠲⠤⢤⣀⡀ ⢀⠞⠁ ⠈⠙⢦⡀ │ +│ ⡴⠉ ⠉⣹⠻⢄⡀ ⠙⢦ │ +│ ⢧ ⣰⠃ ⠙⢦⡀ ⢳⡀ │ +│ ⠈⠳⣄ ⣰⠃ ⠙⢆ ⢳ │ +│ ⠈⠳⢄⡀ ⢀⣠⠞⠁ ⠘⣆⣀ ⠈⡇ │ +│ ⢀⡰⠚⠉⠙⠢⣄ ⠈⢳⡀ ⡇ │ +│ ⣠⠊ ⠙⢦⡀ ⢳⡀ ⢠⠃ │ +│ ⢀⡞⠁ ⠙⣄ ⢇ ⢠⠎ │ +│ ⢠⠎ ⠈⠳⣄ ⢸ ⡰⠋ │ +│ ⢀⠏ ⠈⠳⢤⣀ ⣀⡼⡎ │ +│ ⡜ ⠈⠙⠒⠒⠒⠒⠒⠚⠉ ⢰⠃ │ +│ ⢰⠃ ⢸ │ +│ ⢸ ⡎ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > tunnel 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ▒▒░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ │ +│ ░░░░░░░░░░░░░░ ░░░░░░░░▒▒▒▒▒▓▓▓▓▓███████████▓▓▓▓▓▓▒▒▒▒▒░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓███████████████████████▓▓▓▓▓▓▒▒▒▒░░░░░ │ +│ ░░░░░░░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░ │ +│ ░░░░░░░░▒▒▒▒▒▓▓▓▓███████▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒░░░ ░▒▒▒▓▓█████▓▓▓▓▓▒▒▒░░░░░░ │ +│ ░░░░░▒▒▒▒▓▓▓████████▓▓▒░ ░▒▓███▓▒░ ░░░▒▓████▓▒░░░░▒▒▓▓█████████▓▓▓▒▒▒▒░░░░░░░ │ +│ ░░░▒▒▒▓▓▓████████▓▓▒░ ░▒▓▓▒░░▒██░ ▓█▓░ ░▓▒ ▓▓░ ░▒██▓▒░░░░▒▓▓█████████▓▓▓▒▒▒▒▒░░░░ │ +│ ░░░▒▒▒▓▓▓██████▓▓▒░░░▒▓██▒ ▒█░▓▒▓▒ █▓█░░▓▓▓▒░▒▒▓▒█▓ ░▒▓▓▒ ░▒▓▓███████▓▓▓▒▒▒▒▒░░░░ │ +│ ░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ▒▓██▓░░▒██▒░██▓░░░ ░▓▓░▒▓▓░ ░▓███▒░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░ │ +│ ░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░▒▒▓▓▓▒░ ░░░░░░░░░▒▓▓▓▓▓▒░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░ │ +│ ░░░░░▒▒▒▒▓▓▓▓███████▓▓▓▒▒░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒▓▓▓███████▓▓▓▒▒▒░░░ │ +│ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓██████████████▓▓▓▒▒▒▒░░░░░ ░░░░▒▒▒▓▓███████████▓▓▓▒▒▒░░░ │ +│ ░░░░░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▓█████████████▓▓▓▓▓▓▓▓▓▓▓▓█████████████▓▓▓▓▒▒▒░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░ │ +│ ▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ │ +│ ▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > twister 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ██████████████████████████ │ +│ ████████████████████████████ │ +│ ███████████████████████████ │ +│ ▓▓▓▓▓▓██████████████ │ +│ ▓▓▓▓▓▓█████ │ +│ ▒ │ +│ ▓▓▓▓▓▓▒▒▒▒▒ │ +│ ▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ +│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓ │ +│ ▒▒▒▒▓▓▓▓▓█ │ +│ █▓▒ │ +│ ███████▓▓▓▓▓▓▓ │ +│ ████████████████▓▓▓▓▓ │ +│ ███████████████████████████ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > ultracode 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / full / unicode > xor-texture 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ █████████████████████████████████████████████ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`receiver baseline / micro / ascii > braille-wave 1`] = ` +"KEXP 90.3 FM . playing + + +.--::--:. +.--..-:. :-: +-- ..+-*. :-- .-+-+-: + .:: ::. +" +`; + +exports[`receiver baseline / micro / ascii > gallop-fine 1`] = ` +"KEXP 90.3 FM . playing + + .-+- + :-::.:#: + -: .**-*++ + +#:. .. +" +`; + +exports[`receiver baseline / micro / ascii > manhattan 1`] = ` +"KEXP 90.3 FM . playing + +## . oo o |o@o. +#### #||#. /|.. ###@..## +#..* ##..../*\\.o|.....## + -o. .oooo. oo o .o.o..o +" +`; + +exports[`receiver baseline / micro / ascii > pulse-grid 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`receiver baseline / micro / ascii > ultracode 1`] = ` +"KEXP 90.3 FM . playing + + + + + +" +`; + +exports[`receiver baseline / micro / unicode > braille-wave 1`] = ` +"KEXP 90.3 FM · playing + + +⢀⡤⠲⠤⣀⣠⢤⣀⡀ ⣰⢀⡤⣄⡀⢀⣠⣀⡀ ⣀⡤⠤ +⠴⠋ ⠈⠈⠳⣄⡼⠁ ⠉⠓⠋ ⠈⠙⢦⣠⠞⡤⠤ + ⠈⠉⠉ ⠉⠉⠁ +" +`; + +exports[`receiver baseline / micro / unicode > gallop-fine 1`] = ` +"KEXP 90.3 FM · playing + + ⢀⢤⢖⢤ + ⣀⡔⠒⠒⠈⠡⣼⠉ + ⠲⠤ ⠈⢧⣖⢒⣲⠗⢣ + ⣘⣮⠃⠈ ⠈⠁ +" +`; + +exports[`receiver baseline / micro / unicode > manhattan 1`] = ` +"KEXP 90.3 FM · playing + +▀▀ ˙ •• • │•●•˙ +██▀▀ ▀││▀░ ╱│░░ ▀▀▀●░˙▀▀ +█░░▪ ▀▀░░░˙╱◆╲░•│░░░░░██ + ─•˙ ·••••˙ •• • ˙•˙•˙˙• +" +`; + +exports[`receiver baseline / micro / unicode > pulse-grid 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`receiver baseline / micro / unicode > ultracode 1`] = ` +"KEXP 90.3 FM · playing + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ------------------------- +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ------------------------------ +1 receivers . mpv + +> Living Room . speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do. +REVIEW +> Save alarm . next Mon, Se. + Cancel + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +> Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK +> Volume: 70% [#######...]. + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM - . + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-picker' 1`] = ` +"Primary station ------------------------------- +Favorites, imports, recents, current station, a. + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------------------------------ +Favorites, imports, recents, current station, a. + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'alarm-ringing' 1`] = ` +"Alarm ringing --------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio +Started 1m ago . scheduled Mon, Sep 7, 11:59 AM. + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / ascii > 'alarms' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow. + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii > 'alarms/degraded' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Repair needed +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow. + + + +Scheduling needs repair . press r +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii > 'alarms/empty' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +No upcoming alarms. + +> Create alarm + Verify alarm setup . scheduler, terminal, pow. + + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations --------- +Rehearsing "Morning radio" on this machine +READY WITH LIMITATIONS . Review warnings before +relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / ascii > 'countries' 1`] = ` +"RADIOCLI Countries ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E . 2 stations + + . .:.:+** . .-. > KEXP 90.3 FM . * + ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM - . + *@@@%- .@*%#%@@@@@%+ + +#+ +@%#%%+%%@#:. + ..-+. +#%@%: : ::. + %@@- +@#. ..::. + :@+. :%:: .%%#. + ++ : . + .. :::.::::::. +::###%%@#***#%@@@@@@@@@@@@@@:" +`; + +exports[`screen baseline / compact / ascii > 'help' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Navigation + ^/v or n/p . Move selection + or Tab . Switch tabs + [ / ] . Page selection by 10 + 1-9 . Jump to an Overview option + Enter . Open / tune the selection + b or Esc . Back to home + : . Command palette + ? . Toggle this help +" +`; + +exports[`screen baseline / compact / ascii > 'help/commands' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Commands + :search . Search stations (alias :s) + :country . Open a country (alias . + :codec . Filter by codec +> :language . Filter by language (al. + :bitrate . Minimum bitrate filter + :clear . Clear search filters + :volume <0-100> . Set volume (alias :vol) + :mute . Toggle mute +" +`; + +exports[`screen baseline / compact / ascii > 'home' 1`] = ` +"RADIOCLI ######### + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent . 1 favorites . 1 imported +" +`; + +exports[`screen baseline / compact / ascii > 'library' 1`] = ` +"RADIOCLI Library ------------------------------ +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'library/empty' 1`] = ` +"RADIOCLI Library ------------------------------ +0 stations . playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'map' 1`] = ` +"RADIOCLI World map ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'nearby' 1`] = ` +"RADIOCLI Nearby ------------------------------- +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle . vol 70 + + + + + + + + + +################################################ +" +`; + +exports[`screen baseline / compact / ascii > 'search' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------+ + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'search/loading' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|* tokyo jazz | ++----------------------------------------------+ + +Searching station directories. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings' 1`] = ` +"RADIOCLI Settings ----------------------------- +Choose a category . Updates are available here + +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/agent' 1`] = ` +"RADIOCLI Settings ----------------------------- +Agent control & MCP . Enter changes setting . b. + +> Allow local agent con. off + Install or repair MCP. run repair + Open TUI for agent pl. on + Show Now Playing for . on + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ----------------------------- +Appearance . Enter changes setting . b categori. + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad sc. auto + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/data' 1`] = ` +"RADIOCLI Settings ----------------------------- +Data & library . Enter changes setting . b cate. + +> Export preferences an. JSON backup + Import preferences an. restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ----------------------------- +Discovery & providers . Enter changes setting .. + +> Nearby location off + Radio Garden adapter off + Share favorite votes . on + Refresh provider heal. + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ----------------------------- +Media keys . Enter changes setting . b categori. + +> Learn previous media . + Learn play/pause medi. + Learn next media key + Reset learned media k. prev 0 . play 0 . ne. + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/playback' 1`] = ` +"RADIOCLI Settings ----------------------------- +Playback & audio . Enter changes setting . b ca. + +> Audio output Automatic + AirPlay receiver Living Room . 1 rece. + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o. on + + +" +`; + +exports[`screen baseline / compact / ascii > 'settings/updates' 1`] = ` +"RADIOCLI Settings ----------------------------- +Updates . Enter changes setting . b categories + +> Automatically check f. on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'stations' 1`] = ` +"RADIOCLI Stations ----------------------------- +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii > 'stats' 1`] = ` +"RADIOCLI Listening stats ---------------------- +Stored locally on this machine + +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +Streak . 2 current . 2 best +..........................## . 2 active days + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ------------------------- +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ------------------------------ +1 receivers . mpv + +> Living Room . speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do. +REVIEW +> Save alarm . next Mon, Se. + Cancel + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +> Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang. +PLAYBACK +> Volume: 70% [#######...]. + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM - . + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------------------------------ +Local-first . native OS job installed on save .. + + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-picker' 1`] = ` +"Primary station ------------------------------- +Favorites, imports, recents, current station, a. + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------------------------------ +Favorites, imports, recents, current station, a. + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station na. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing --------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio +Started 1m ago . scheduled Mon, Sep 7, 11:59 AM. + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow. + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms/degraded' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Repair needed +Next . Mon, Sep 7, 1:30 PM . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays . KEXP 90.3 F + Verify alarm setup . scheduler, terminal, pow. + + + +Scheduling needs repair . press r +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms/empty' 1`] = ` +"Alarms ---------------------------------------- +Scheduler: Ready +No upcoming alarms. + +> Create alarm + Verify alarm setup . scheduler, terminal, pow. + + + + +Scheduler ready . safe to close +BETA . Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations --------- +Rehearsing "Morning radio" on this machine +READY WITH LIMITATIONS . Review warnings before +relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / ascii-no-color > 'countries' 1`] = ` +"RADIOCLI Countries ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E . 2 stations + + . .:.:+** . .-. > KEXP 90.3 FM . * + ##+%@%-#.--:..*%###@@@@@%#*+ KCRW 89.9 FM - . + *@@@%- .@*%#%@@@@@%+ + +#+ +@%#%%+%%@#:. + ..-+. +#%@%: : ::. + %@@- +@#. ..::. + :@+. :%:: .%%#. + ++ : . + .. :::.::::::. +::###%%@#***#%@@@@@@@@@@@@@@:" +`; + +exports[`screen baseline / compact / ascii-no-color > 'help' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Navigation + ^/v or n/p . Move selection + or Tab . Switch tabs + [ / ] . Page selection by 10 + 1-9 . Jump to an Overview option + Enter . Open / tune the selection + b or Esc . Back to home + : . Command palette + ? . Toggle this help +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'help/commands' 1`] = ` +"RADIOCLI Help --------------------------------- +Scroll for keys and commands + + Commands + :search . Search stations (alias :s) + :country . Open a country (alias . + :codec . Filter by codec +> :language . Filter by language (al. + :bitrate . Minimum bitrate filter + :clear . Clear search filters + :volume <0-100> . Set volume (alias :vol) + :mute . Toggle mute +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'home' 1`] = ` +"RADIOCLI ######### + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent . 1 favorites . 1 imported +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'library' 1`] = ` +"RADIOCLI Library ------------------------------ +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'library/empty' 1`] = ` +"RADIOCLI Library ------------------------------ +0 stations . playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'map' 1`] = ` +"RADIOCLI World map ---------------------------- +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'nearby' 1`] = ` +"RADIOCLI Nearby ------------------------------- +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing . vol 70 + +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o @ @ o o o o o . . o o o o +o o @ o . . . o @ o o o o o o @ o . . . o @ o o +o o o o o . . o o @ o o o o o @ o . . . o @ o o +o o o @ o . . . o o @ @ o @ @ o . . . . o @ o o +Bjork - Joga +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle . vol 70 + + + + + + + + + +################################################ +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'search' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------+ + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'search/loading' 1`] = ` +"RADIOCLI Search ++----------------------------------------------+ +|* tokyo jazz | ++----------------------------------------------+ + +Searching station directories. + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings' 1`] = ` +"RADIOCLI Settings ----------------------------- +Choose a category . Updates are available here + +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/agent' 1`] = ` +"RADIOCLI Settings ----------------------------- +Agent control & MCP . Enter changes setting . b. + +> Allow local agent con. off + Install or repair MCP. run repair + Open TUI for agent pl. on + Show Now Playing for . on + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ----------------------------- +Appearance . Enter changes setting . b categori. + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad sc. auto + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/data' 1`] = ` +"RADIOCLI Settings ----------------------------- +Data & library . Enter changes setting . b cate. + +> Export preferences an. JSON backup + Import preferences an. restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ----------------------------- +Discovery & providers . Enter changes setting .. + +> Nearby location off + Radio Garden adapter off + Share favorite votes . on + Refresh provider heal. + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ----------------------------- +Media keys . Enter changes setting . b categori. + +> Learn previous media . + Learn play/pause medi. + Learn next media key + Reset learned media k. prev 0 . play 0 . ne. + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/playback' 1`] = ` +"RADIOCLI Settings ----------------------------- +Playback & audio . Enter changes setting . b ca. + +> Audio output Automatic + AirPlay receiver Living Room . 1 rece. + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o. on + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'settings/updates' 1`] = ` +"RADIOCLI Settings ----------------------------- +Updates . Enter changes setting . b categories + +> Automatically check f. on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'stations' 1`] = ` +"RADIOCLI Stations ----------------------------- +2 stations . playing KEXP 90.3 FM + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deliberately long station na. + + + + + + + +" +`; + +exports[`screen baseline / compact / ascii-no-color > 'stats' 1`] = ` +"RADIOCLI Listening stats ---------------------- +Stored locally on this machine + +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +Streak . 2 current . 2 best +..........................## . 2 active days + + + +" +`; + +exports[`screen baseline / compact / unicode > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ───────────────────────── +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ────────────────────────────── +1 receivers · mpv + +> Living Room · speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do… +REVIEW +› Save alarm · next Mon, Se… + Cancel + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +› Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK +› Volume: 70% [███████···]… + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM — … + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-picker' 1`] = ` +"Primary station ─────────────────────────────── +Favorites, imports, recents, current station, a… + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────────────────────────────── +Favorites, imports, recents, current station, a… + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio +Started 1m ago · scheduled Mon, Sep 7, 11:59 AM… + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / unicode > 'alarms' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode > 'alarms/degraded' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Repair needed +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduling needs repair · press r +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode > 'alarms/empty' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +No upcoming alarms. + +› Create alarm + Verify alarm setup · scheduler, terminal, pow… + + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ───────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before +relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / unicode > 'countries' 1`] = ` +"RADIOCLI Countries ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E · 2 stations + + ⢀ ⠄⠤⠄⠤⣤⣴⣦ ⠠ ⢀⣠⡀ > KEXP 90.3 FM · ★ + ⢾⠿⠶⣷⣿⣷⡊⢾⡀⠙⠋⠑⠂⡀⣴⣽⣶⣶⣶⣿⣿⣿⣿⣿⡿⠿⡳⠗ KCRW 89.9 FM — … + ⢹⣿⣿⣿⡿⠋ ⠈●⠟⣟⣛⡿⣿⣿⣿⣿⣿⢿⢋ + ⠛⡟⡋ ⣰⣿⣷⣶⣾⣟⠛⢿⡿⣿⠿⠆⠁ + ⠈⠁⣠⣤⡀ ⠹⠿⢿⣿⣷⠅ ⠘ ⢈⢂⠁ + ⢿⣿⣿⡆ ⢸⣿⡯⡀ ⠈⠈⢁⡐⠂ + ⢠⣿⠏⠁ ⠘⡿⠃⠃ ⠐⡿⢿⡷⠈ + ⢸⢋ ⠃ ⠈ + ⢀⡀ ⣀⣀⣀⢀⣀⣀⣀⣀⣀⣀⡀ +⣀⣀⣶⣶⣶⣷⣾⣿⣭⣥⣴⣴⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣀" +`; + +exports[`screen baseline / compact / unicode > 'help' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Navigation + ↑/↓ or n/p · Move selection + ←/→ or Tab · Switch tabs + [ / ] · Page selection by 10 + 1-9 · Jump to an Overview option + Enter · Open / tune the selection + b or Esc · Back to home + : · Command palette + ? · Toggle this help +" +`; + +exports[`screen baseline / compact / unicode > 'help/commands' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Commands + :search · Search stations (alias :s) + :country · Open a country (alias … + :codec · Filter by codec +> :language · Filter by language (al… + :bitrate · Minimum bitrate filter + :clear · Clear search filters + :volume <0-100> · Set volume (alias :vol) + :mute · Toggle mute +" +`; + +exports[`screen baseline / compact / unicode > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent · 1 favorites · 1 imported +" +`; + +exports[`screen baseline / compact / unicode > 'library' 1`] = ` +"RADIOCLI Library ────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'library/empty' 1`] = ` +"RADIOCLI Library ────────────────────────────── +0 stations · playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'map' 1`] = ` +"RADIOCLI World map ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'nearby' 1`] = ` +"RADIOCLI Nearby ─────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle · vol 70 + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / compact / unicode > 'search' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────┘ + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'search/loading' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────────────────────────────┘ + +Searching station directories… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Choose a category · Updates are available here + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/agent' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Agent control & MCP · Enter changes setting · b… + +> Allow local agent con… off + Install or repair MCP… run repair + Open TUI for agent pl… on + Show Now Playing for … on + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Appearance · Enter changes setting · b categori… + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad sc… auto + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/data' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Data & library · Enter changes setting · b cate… + +> Export preferences an… JSON backup + Import preferences an… restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Discovery & providers · Enter changes setting ·… + +> Nearby location off + Radio Garden adapter off + Share favorite votes … on + Refresh provider heal… + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Media keys · Enter changes setting · b categori… + +> Learn previous media … + Learn play/pause medi… + Learn next media key + Reset learned media k… prev 0 · play 0 · ne… + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/playback' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Playback & audio · Enter changes setting · b ca… + +> Audio output Automatic + AirPlay receiver Living Room · 1 rece… + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o… on + + +" +`; + +exports[`screen baseline / compact / unicode > 'settings/updates' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Updates · Enter changes setting · b categories + +> Automatically check f… on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'stations' 1`] = ` +"RADIOCLI Stations ───────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode > 'stats' 1`] = ` +"RADIOCLI Listening stats ────────────────────── +Stored locally on this machine + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +Streak · 2 current · 2 best +··························■■ · 2 active days + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'airplay-code' 1`] = ` +"RADIOCLI AirPlay code ───────────────────────── +KEXP 90.3 FM + +Code: 1234 + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'airplay-settings' 1`] = ` +"RADIOCLI AirPlay ────────────────────────────── +1 receivers · mpv + +> Living Room · speaker.local + + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + +RELIABILITY + Run after wake within: 15m + Request OS wake: Yes + Alarm Guard: On + Test-tune station now (do… +REVIEW +› Save alarm · next Mon, Se… + Cancel + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM + Schedule: Recurring +› Time: 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + Time (24h): 06 : 30 + Repeat: weekdays + Timezone: America/Los_Ang… +PLAYBACK +› Volume: 70% [███████···]… + Fade in: 30s (mpv only) + Stop after: 60m + Fallback: KCRW 89.9 FM — … + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────────────────────────────── +Local-first · native OS job installed on save ·… + + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday +Select one or more days. At least one day must +remain checked. + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-picker' 1`] = ` +"Primary station ─────────────────────────────── +Favorites, imports, recents, current station, a… + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────────────────────────────── +Favorites, imports, recents, current station, a… + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station na… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio +Started 1m ago · scheduled Mon, Sep 7, 11:59 AM… + + ENTER KEEP PLAYING + SPACE SNOOZE 10 MIN + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms/degraded' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Repair needed +Next · Mon, Sep 7, 1:30 PM · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays · KEXP 90.3 F + Verify alarm setup · scheduler, terminal, pow… + + + +Scheduling needs repair · press r +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms/empty' 1`] = ` +"Alarms ──────────────────────────────────────── +Scheduler: Ready +No upcoming alarms. + +› Create alarm + Verify alarm setup · scheduler, terminal, pow… + + + + +Scheduler ready · safe to close +BETA · Keep a backup alarm for critical wakes +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ───────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before +relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + +Press Enter or b to return to Alarms. Run Verify + again whenever the OS, terminal, or audio setup + changes." +`; + +exports[`screen baseline / compact / unicode-no-color > 'countries' 1`] = ` +"RADIOCLI Countries ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'explore' 1`] = ` +"RADIOCLI Explore +48.9N, 2.4E · 2 stations + + ⢀ ⠄⠤⠄⠤⣤⣴⣦ ⠠ ⢀⣠⡀ > KEXP 90.3 FM · ★ + ⢾⠿⠶⣷⣿⣷⡊⢾⡀⠙⠋⠑⠂⡀⣴⣽⣶⣶⣶⣿⣿⣿⣿⣿⡿⠿⡳⠗ KCRW 89.9 FM — … + ⢹⣿⣿⣿⡿⠋ ⠈●⠟⣟⣛⡿⣿⣿⣿⣿⣿⢿⢋ + ⠛⡟⡋ ⣰⣿⣷⣶⣾⣟⠛⢿⡿⣿⠿⠆⠁ + ⠈⠁⣠⣤⡀ ⠹⠿⢿⣿⣷⠅ ⠘ ⢈⢂⠁ + ⢿⣿⣿⡆ ⢸⣿⡯⡀ ⠈⠈⢁⡐⠂ + ⢠⣿⠏⠁ ⠘⡿⠃⠃ ⠐⡿⢿⡷⠈ + ⢸⢋ ⠃ ⠈ + ⢀⡀ ⣀⣀⣀⢀⣀⣀⣀⣀⣀⣀⡀ +⣀⣀⣶⣶⣶⣷⣾⣿⣭⣥⣴⣴⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣀" +`; + +exports[`screen baseline / compact / unicode-no-color > 'help' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Navigation + ↑/↓ or n/p · Move selection + ←/→ or Tab · Switch tabs + [ / ] · Page selection by 10 + 1-9 · Jump to an Overview option + Enter · Open / tune the selection + b or Esc · Back to home + : · Command palette + ? · Toggle this help +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'help/commands' 1`] = ` +"RADIOCLI Help ───────────────────────────────── +Scroll for keys and commands + + Commands + :search · Search stations (alias :s) + :country · Open a country (alias … + :codec · Filter by codec +> :language · Filter by language (al… + :bitrate · Minimum bitrate filter + :clear · Clear search filters + :volume <0-100> · Set volume (alias :vol) + :mute · Toggle mute +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search + 5 Countries + 6 Nearby + 7 Stats + 8 Alarms (beta) + 9 Settings +1 recent · 1 favorites · 1 imported +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'library' 1`] = ` +"RADIOCLI Library ────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'library/empty' 1`] = ` +"RADIOCLI Library ────────────────────────────── +0 stations · playing KEXP 90.3 FM + +Your library is empty. +Press f on a station to save it. + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'map' 1`] = ` +"RADIOCLI World map ──────────────────────────── +2 countries + +> United States (US) 12,345 + Japan (JP) 456 + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'nearby' 1`] = ` +"RADIOCLI Nearby ─────────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'now-playing' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'now-playing/diagnostics' 1`] = ` +"RADIOCLI Now playing +KEXP 90.3 FM playing · vol 70 + +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ +◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ +◉ ◉ ◉ • ∘ · · ∘ • ● ◉ ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ +◉ ◉ ◉ ● ∘ · · · ∘ • ● ● ◉ ● ● ∘ · · · · • ● ◉ ◉ +Bjork - Joga +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'now-playing/idle' 1`] = ` +"RADIOCLI Now playing +No station tuned idle · vol 70 + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'search' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────┘ + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'search/loading' 1`] = ` +"RADIOCLI Search +┌──────────────────────────────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────────────────────────────┘ + +Searching station directories… + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Choose a category · Updates are available here + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/agent' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Agent control & MCP · Enter changes setting · b… + +> Allow local agent con… off + Install or repair MCP… run repair + Open TUI for agent pl… on + Show Now Playing for … on + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/appearance' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Appearance · Enter changes setting · b categori… + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad sc… auto + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/data' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Data & library · Enter changes setting · b cate… + +> Export preferences an… JSON backup + Import preferences an… restore JSON backup + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/discovery' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Discovery & providers · Enter changes setting ·… + +> Nearby location off + Radio Garden adapter off + Share favorite votes … on + Refresh provider heal… + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/media-keys' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Media keys · Enter changes setting · b categori… + +> Learn previous media … + Learn play/pause medi… + Learn next media key + Reset learned media k… prev 0 · play 0 · ne… + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/playback' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Playback & audio · Enter changes setting · b ca… + +> Audio output Automatic + AirPlay receiver Living Room · 1 rece… + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station o… on + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'settings/updates' 1`] = ` +"RADIOCLI Settings ───────────────────────────── +Updates · Enter changes setting · b categories + +> Automatically check f… on + Install update v0.3.0 available + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'stations' 1`] = ` +"RADIOCLI Stations ───────────────────────────── +2 stations · playing KEXP 90.3 FM + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deliberately long station na… + + + + + + + +" +`; + +exports[`screen baseline / compact / unicode-no-color > 'stats' 1`] = ` +"RADIOCLI Listening stats ────────────────────── +Stored locally on this machine + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +Streak · 2 current · 2 best +··························■■ · 2 active days + + + +" +`; + +exports[`screen baseline / full / ascii > 'airplay-code' 1`] = ` +"AirPlay Code ---------------------------------------------------------------------- Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits . Backspace edits . Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'airplay-settings' 1`] = ` +"AirPlay ----------------------------------------------------------------------------------- Ready +Choose where AirPlay playback should go + +Audio output: Automatic . Streaming: ready . Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected . speaker.local:7000 . AirPlay 2 . code shown on receiver +Enter selects . c opens code entry . r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/save' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW +> Save alarm . next Mon, Sep 7, 1:30 PM (you. + Cancel + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/time' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring +> Time: 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/volume' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK +> Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +WEEKDAYS + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday + * Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-picker' 1`] = ` +"Primary station --------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-picker/fallback' 1`] = ` +"Fallback station -------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarm-ringing' 1`] = ` +"Alarm ringing ----------------------------------------------------------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio + Started 1m ago . scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing . Space stops the radio until snooze ends . b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'alarms' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii > 'alarms/degraded' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Degraded . Native scheduler ready. +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM . needs repair . press x again to de + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii > 'alarms/empty' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +No enabled future alarm. + +> Create alarm + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ----------------------------------------------------------- +Rehearsing "Morning radio" on this machine +READY WITH LIMITATIONS . Review warnings before relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / ascii > 'countries' 1`] = ` +"Countries --------------------------------------------------------------------------------------- +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'explore' 1`] = ` +"Explore ----------------------------------------------------------------------------------------- +Saved and discovered stations + ++-------------------------------------------------------------+ +--------------------------------+ +| .::::.::::. | |Stations 2| +| .:-::#*%-:-*@@@@@%. .-- .: :-#*++.. .: | | | +|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM * | +| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM - A deliberately.| +| :#@@@@@@%@@#*. +@%@@@%%@@%@@@@@@@@@@@@@@+ . | | | +| -@@@@@@@%: #*::-##*%%*@@@@@@@@@@%#+.+ | | | +| -%@@###. .%@@%-*+#@%@@@@@@@@@@@% .: | | | +| . +@ :..: :@@@@@@@@%%@#*:*@@+%@%*- | | | +| .:+-.:: -@@@@@@@@@##. %. .#* | | | +| .#@@%* .+--@@@@@@* . + ---. | | | +| @@@@@%#: *@@@@: .:+-.::*-: | | | +| +@@@@@@. -@@@@-- :++-. | | | +| +@@@%- .@@@- + .#@@@@%. | | | +| +@%*. +#+ %*+#@@- | | | +| @#: :- ::| | | +| :@ .. | | | +| .. | | | +| .+. :::::--+#++.:+++***+++*++::: | | | +| .:+*######@###*--..: -+*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+. | | | +|##++*%@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*| | | ++-------------------------------------------------------------+ +--------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'help' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +Navigation +^/v or n/p Move selection + or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi. Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f / Ctrl+F Favorite the station (Ctrl+F while typing in Search) +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 . ^/v scroll" +`; + +exports[`screen baseline / full / ascii > 'help/commands' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ################## +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent . 1 favorites . 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'library' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'library/empty' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'map' 1`] = ` +"World map ------------------------------------------------------------------------------------- +Station density by country + +. .................. . . .................... . +. ............................ .... ....................................... +. ........................................ .............................................. +. ...................................... ............................................... +. ................US.......... . .....................................JP.... +................................................................................................ +. . ................. . ................ ....................... +. . ..... ......... ................ ................ +. . . .......... ............... ............. +. . . ........... ........... . . .......... +. . . ........... . .... . . .............. +. . . .......... . . . .......... +. . . ......... . . . . ... +. . . . . . . . +2 plotted . 0 unplaced . labels show highest station counts + +Highest station counts > US . United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / ascii > 'nearby' 1`] = ` +"Nearby ------------------------------------------------------------------------------------------ +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'now-playing' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o @ @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| . o @ o o o @ o . . . o o @ o o o o @ o o o o o o o @ @ o o o o @ o . . . . o @ o o o o o | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'now-playing/diagnostics' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o o o . . . o o @ @ @ o o . . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . o o o o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . . o @ o o @ o . . . o o @ o o o o @ o . . . o o o o o @ o . . . o o o o @ | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o o @ o . . . o @ o o o @ o . . . . . . . . . . . o o @ o o o o o . . . o @ o o @ o | +| | +| Bjork - Joga * Favorite | +| | +| Stream: none | +| Station time: 12:00 | +| Started: 11:30:00 AM . available mpv, airplay | +| | +| . Bjork - Joga | +| . Aphex Twin - Avril 14th | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'now-playing/idle' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| No station tuned IDLE | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| ########################################################################################## | +| | +| Playback backend ready. Choose a station to start tuning. * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii > 'search' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'search/loading' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +Searching station directories. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Choose a category + +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/agent' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba. on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/appearance' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/data' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/discovery' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B. on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/media-keys' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 . play 0 . next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/playback' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room . 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) . playing . volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'settings/updates' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'stations' 1`] = ` +"United States ----------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii > 'stats' 1`] = ` +"Listening stats --------------------------------------------------------------------------------- +Local listening history . never leaves this machine + ++------------------------------------------------------------------------------------------------+ +|Activity - 2026 | +| Dec Jan Feb Mar Apr May Jun Jul Aug Sep | +| | +|Mon | +| | +|Wed | +| | +|Fri | +| | +| | ++------------------------------------------------------------------------------------------------+ + ++------------------------------------------------------------------------------------------------+ +|Summary | +|Favorite station: KEXP 90.3 FM Total hours listened: 1.5h | +|Sessions: 2 Longest streak: 2 days | +|Current streak: 2 days Stations listened: 2 | +|Active days: 2/371 Stations counted after: 2 min | +| | +|Less . More | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'airplay-code' 1`] = ` +"AirPlay Code ---------------------------------------------------------------------- Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits . Backspace edits . Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'airplay-settings' 1`] = ` +"AirPlay ----------------------------------------------------------------------------------- Ready +Choose where AirPlay playback should go + +Audio output: Automatic . Streaming: ready . Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected . speaker.local:7000 . AirPlay 2 . code shown on receiver +Enter selects . c opens code entry . r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS +> Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW +> Save alarm . next Mon, Sep 7, 1:30 PM (you. + Cancel + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring +> Time: 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK + Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,. + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; chooses hours or. + Repeat: weekdays Enter opens the weekday checklist. Choose any comb. + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st. +PLAYBACK +> Volume: 70% [#######...] Sets player volume and raises/unmutes the local sy. + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star. + Stop after: 60m Playback stops automatically unless Keep playing i. + Fallback: KCRW 89.9 FM - A deliberately lo. The fallback is tried when the primary stream cann. + Output: Local speakers Scheduled alarms currently play through local spea. +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is . + Request OS wake: Yes Asks supported systems to wake the computer for th. + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre. + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm -------------------------------------------------------------------------------------- +Local-first . native OS job installed on save . terminal may close afterward + +WEEKDAYS + @ Monday + @ Tuesday +> @ Wednesday + @ Thursday + @ Friday + * Saturday + * Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-picker' 1`] = ` +"Primary station --------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station -------------------------------------------------------------------------------- +Favorites, imports, recents, current station, and alarm stations + +> None . No fallback + KEXP 90.3 FM . Now playing + KCRW 89.9 FM - A deliberately long station name . Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ----------------------------------------------------------------------------------- +Wake radio . playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM . Morning radio + Started 1m ago . scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing . Space stops the radio until snooze ends . b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms/degraded' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Degraded . Native scheduler ready. +Next wake . Mon, Sep 7, 1:30 PM (your time) . KEXP 90.3 FM + + Create alarm +> @ Morning radio . 06:30 weekdays Los Angeles . KEXP 90.3 FM . needs repair . press x again to de + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms/empty' 1`] = ` +"Alarms ------------------------------------------------------------------------------------------ +Scheduler: Ready +No enabled future alarm. + +> Create alarm + Verify alarm setup . full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA . Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / ascii-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ----------------------------------------------------------- +Rehearsing "Morning radio" on this machine +READY WITH LIMITATIONS . Review warnings before relying on wake behavior. + +@ Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / ascii-no-color > 'countries' 1`] = ` +"Countries --------------------------------------------------------------------------------------- +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'explore' 1`] = ` +"Explore ----------------------------------------------------------------------------------------- +Saved and discovered stations + ++-------------------------------------------------------------+ +--------------------------------+ +| .::::.::::. | |Stations 2| +| .:-::#*%-:-*@@@@@%. .-- .: :-#*++.. .: | | | +|:.:%%#####%#%+%**-%- +@%*+-- -*#%+++*+#%@@@@@@@@@%@@@@%##*| |> KEXP 90.3 FM * | +| .*-:-+%@@@@@#:.**+. :. - :#-#%@@@@@@@@@@@@@@@@@%++-%.: | | KCRW 89.9 FM - A deliberately.| +| :#@@@@@@%@@#*. +@%@@@%%@@%@@@@@@@@@@@@@@+ . | | | +| -@@@@@@@%: #*::-##*%%*@@@@@@@@@@%#+.+ | | | +| -%@@###. .%@@%-*+#@%@@@@@@@@@@@% .: | | | +| . +@ :..: :@@@@@@@@%%@#*:*@@+%@%*- | | | +| .:+-.:: -@@@@@@@@@##. %. .#* | | | +| .#@@%* .+--@@@@@@* . + ---. | | | +| @@@@@%#: *@@@@: .:+-.::*-: | | | +| +@@@@@@. -@@@@-- :++-. | | | +| +@@@%- .@@@- + .#@@@@%. | | | +| +@%*. +#+ %*+#@@- | | | +| @#: :- ::| | | +| :@ .. | | | +| .. | | | +| .+. :::::--+#++.:+++***+++*++::: | | | +| .:+*######@###*--..: -+*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+. | | | +|##++*%@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##*| | | ++-------------------------------------------------------------+ +--------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'help' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +Navigation +^/v or n/p Move selection + or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi. Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f / Ctrl+F Favorite the station (Ctrl+F while typing in Search) +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 . ^/v scroll" +`; + +exports[`screen baseline / full / ascii-no-color > 'help/commands' 1`] = ` +"Help -------------------------------------------------------------------------------------------- +Keyboard shortcuts and : commands . b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ################## +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent . 1 favorites . 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'library' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'library/empty' 1`] = ` +"Library ----------------------------------------------------------------------------------------- +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'map' 1`] = ` +"World map ------------------------------------------------------------------------------------- +Station density by country + +. .................. . . .................... . +. ............................ .... ....................................... +. ........................................ .............................................. +. ...................................... ............................................... +. ................US.......... . .....................................JP.... +................................................................................................ +. . ................. . ................ ....................... +. . ..... ......... ................ ................ +. . . .......... ............... ............. +. . . ........... ........... . . .......... +. . . ........... . .... . . .............. +. . . .......... . . . .......... +. . . ......... . . . . ... +. . . . . . . . +2 plotted . 0 unplaced . labels show highest station counts + +Highest station counts > US . United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'nearby' 1`] = ` +"Nearby ------------------------------------------------------------------------------------------ +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'now-playing' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o @ @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . . o @ o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o @ o o . . o o o o o @ o . . . o o o o @ | +| o @ o o @ o . . . o @ o o @ o . . . . o @ @ o @ @ o o . . . o @ o o o o o . . o o o o o @ | +| o @ o o @ o . . . o @ o o o @ o . . . . o o o o o o . . . o o @ o o @ o . . . o @ o o o o | +| o @ o o o o o . . o o o o o o o o . . . . . . . . . . . . o @ o o o @ o . . . o @ o o o o | +| o o o o o @ o . . . o @ o o o @ o o . . . . . . . . . . o @ o o o @ o o . . o o o o o @ o | +| . o @ o o @ o . . . o o @ o o o @ o o o . . . . . . o o @ o o o o @ o . . . o @ o o o @ o | +| . o @ o o o @ o . . . o o @ o o o o @ o o o o o o o @ @ o o o o @ o . . . . o @ o o o o o | +| | +| Bjork - Joga * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'now-playing/diagnostics' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | +| | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o @ o . . . o @ o o o o o . . . o o @ @ @ o o . . . . o @ o o o o o . . o o o o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o @ o . . o o o o o o o o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . o o o o o o o . . o o o o o o @ o o @ o . . . o @ o o @ o . . . o @ o o @ | +| o o o o @ o . . . o o o o @ o . . . o @ o o o o o o o o . . . o o o o @ o . . . o @ o o @ | +| o o o o @ o . . . o @ o o @ o . . . o o @ o o o o @ o . . . o o o o o @ o . . . o o o o @ | +| o @ o o o o . . . o o o o o @ o . . . . . o o o o . . . . o o o o o @ o . . . o @ o o o o | +| o @ o o o @ o . . . o @ o o o @ o . . . . . . . . . . . o o @ o o o o o . . . o @ o o @ o | +| | +| Bjork - Joga * Favorite | +| | +| Stream: none | +| Station time: 12:00 | +| Started: 11:30:00 AM . available mpv, airplay | +| | +| . Bjork - Joga | +| . Aphex Twin - Avril 14th | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'now-playing/idle' 1`] = ` +"Now playing ------------------------------------------------------------------------------------- ++------------------------------------------------------------------------------------------------+ +| | +| No station tuned IDLE | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| | +| ########################################################################################## | +| | +| Playback backend ready. Choose a station to start tuning. * Favorite | +| | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / ascii-no-color > 'search' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'search/loading' 1`] = ` +"Search ------------------------------------------------------------------------------------------ +Radio Browser + ++----------------------------------------------------------------------------------------------+ +|> tokyo jazz | ++----------------------------------------------------------------------------------------------+ + +Searching station directories. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Choose a category + +> Playback & audio 7 settings > + Appearance 6 settings > + Discovery & providers 4 settings > + Data & library 2 settings > + Agent control & MCP 4 settings > + Media keys 4 settings > + Updates 2 settings > + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/agent' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba. on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/appearance' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display on + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/data' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/discovery' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B. on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/media-keys' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 . play 0 . next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/playback' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room . 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) . playing . volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'settings/updates' 1`] = ` +"Settings ---------------------------------------------------------------------------------------- +Settings > Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'stations' 1`] = ` +"United States ----------------------------------------------------------------------------------- +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM * indie + KCRW 89.9 FM - A deliberately long sta. Santa Monica, United States . MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / ascii-no-color > 'stats' 1`] = ` +"Listening stats --------------------------------------------------------------------------------- +Local listening history . never leaves this machine + ++------------------------------------------------------------------------------------------------+ +|Activity - 2026 | +| Dec Jan Feb Mar Apr May Jun Jul Aug Sep | +| | +|Mon | +| | +|Wed | +| | +|Fri | +| | +| | ++------------------------------------------------------------------------------------------------+ + ++------------------------------------------------------------------------------------------------+ +|Summary | +|Favorite station: KEXP 90.3 FM Total hours listened: 1.5h | +|Sessions: 2 Longest streak: 2 days | +|Current streak: 2 days Stations listened: 2 | +|Active days: 2/371 Stations counted after: 2 min | +| | +|Less . More | ++------------------------------------------------------------------------------------------------+" +`; + +exports[`screen baseline / full / unicode > 'airplay-code' 1`] = ` +"AirPlay Code ────────────────────────────────────────────────────────────────────── Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits · Backspace edits · Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'airplay-settings' 1`] = ` +"AirPlay ─────────────────────────────────────────────────────────────────────────────────── Ready +Choose where AirPlay playback should go + +Audio output: Automatic · Streaming: ready · Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver +Enter selects · c opens code entry · r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/save' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW +› Save alarm · next Mon, Sep 7, 1:30 PM (you… + Cancel + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/time' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring +› Time: 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/volume' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK +› Volume: 70% [███████···] ←/→ Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +WEEKDAYS + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday + ○ Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-picker' 1`] = ` +"Primary station ───────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-picker/fallback' 1`] = ` +"Fallback station ──────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarm-ringing' 1`] = ` +"Alarm ringing ─────────────────────────────────────────────────────────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio + Started 1m ago · scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing · Space stops the radio until snooze ends · b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'alarms' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode > 'alarms/degraded' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Degraded · Native scheduler ready. +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM · needs repair · press x again to de + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode > 'alarms/empty' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +No enabled future alarm. + +› Create alarm + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ─────────────────────────────────────────────────────────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / unicode > 'countries' 1`] = ` +"Countries ─────────────────────────────────────────────────────────────────────────────────────── +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'explore' 1`] = ` +"Explore ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +┌─────────────────────────────────────────────────────────────┐ ┌────────────────────────────────┐ +│ ⢀⣀⣀⣀⣀⢀⣀⣀⣀⣀⢀ │ │Stations 2│ +│ ⢀⡠⠴⡄⢠⣏⢵⡿⡉⠑⠚⠻⣿⣿⣿⣿⣿⡿⠁ ⠈⠋⠋ ⢀⠤ ⣀⣠⣭⣥⣤⣤⡀⢀ ⠠⠤ │ │ │ +│⠤⠄⠰⣾⣷⣶⣶⣶⣶⣶⣾⣭⣯⣤⣽⡮⡏⠙⣷⡤ ⢸⣿⡿⠟⠛⢨⣠ ⣠⣴⢾⣷⡲⣤⣤⣬⣤⣶⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣷⣶⣶⡶│ │> KEXP 90.3 FM ★ │ +│ ⠈⠻⠋⠉⠙⠛⢿⣿⣿⣿⣿⣿⣧⣀⡀⣸⣦⣤⡀ ⠉⠁ ⣠ ⠘⠿⠇⣭⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠛⠋⣽⠈⠉ │ │ KCRW 89.9 FM — A deliberately…│ +│ ⠘⢻⣿⣿⣿⣿⣿⣿⣷⣿⣿⡟⡫⠄ ⠹●⣾⣿⣿⣿⡿⡿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛ ⠁ │ │ │ +│ ⠸⣿⣿⣿⣿⣿⣿⣿⡿⠃ ⣶⠟⠉⠑⠍⡟⣧⣦⣽⣷⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡻⡋⠁⡎ │ │ │ +│ ⠙⣻⣿⣿⠿⠿⢟⠁ ⢀⣾⣿⣿⣷⣄⣦⣤⣼⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷ ⠐⠉ │ │ │ +│ ⠠ ⠹⣿ ⡠⠈⠄⣀ ⢠⣿⣿⣿⣿⣿⣿⣿⣿⣯⢿⣿⣶⡏⠉⠻⣿⣿⠛⢿⣿⡿⠟⠋ │ │ │ +│ ⠈⠉⠳⡆⢀⣀⣀ ⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⣛⠁ ⢿⠁ ⠈⡟⠷ │ │ │ +│ ⠈⣹⣿⣿⣷⣦ ⠈⠛⠋⠙⣿⣿⣿⣿⣿⣿⠟ ⠁ ⢴ ⣠⡆⠙⡀ │ │ │ +│ ⣿⣿⣿⣿⣿⣷⣶⡄ ⢹⣿⣿⣿⣿⠃ ⠈⠆⣉⠙⠁⠊⠒⣦⡤⠢ │ │ │ +│ ⠹⣿⣿⣿⣿⣿⣿⠁ ⢨⣿⣿⣿⣿⠇⣠ ⢠⣰⣆⢰⠈ │ │ │ +│ ⢸⣿⣿⣿⡿⠋ ⠈⣿⣿⣿⠇ ⠏ ⢀⣶⣿⣿⣿⣿⣷⡀ │ │ │ +│ ⢸⣿⡿⠟⠁ ⠹⠿⠏ ⡿⠟⠛⢻⣿⣿⠇ │ │ │ +│ ⣿⡟⠉ ⠉⠍ ⡠⠃│ │ │ +│ ⠰⣿ ⠈⠁ │ │ │ +│ ⠈⠁ │ │ │ +│ ⢀⣔⠁ ⣀⣀⣀⣀⣀⣄⣠⣤⣶⣤⣤⠄⣀⣤⣤⣤⣴⣦⣴⣤⣤⣤⣦⣤⣤⣀⣀⣀ │ │ │ +│ ⠠⠤⢴⣴⣶⣶⣶⣶⣶⣶⣿⣶⣶⣶⡶⠚⠋⡀⢀⣀ ⢤⣤⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠁ │ │ │ +│⣶⣶⣤⣤⣬⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⣦│ │ │ +└─────────────────────────────────────────────────────────────┘ └────────────────────────────────┘" +`; + +exports[`screen baseline / full / unicode > 'help' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +Navigation +↑/↓ or n/p Move selection +←/→ or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi… Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f / Ctrl+F Favorite the station (Ctrl+F while typing in Search) +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 · ↑/↓ scroll" +`; + +exports[`screen baseline / full / unicode > 'help/commands' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ██████████████████ +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent · 1 favorites · 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'library' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'library/empty' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'map' 1`] = ` +"World map ───────────────────────────────────────────────────────────────────────────────────── +Station density by country + +· ·················· · · ···················· · +· ···························· ···· ······································· +· ········································ ·············································· +· ······································ ··············································· +· ················US·········· · ·····································JP···· +································································································ +· · ················· · ················ ······················· +· · ····· ········· ················ ················ +· · · ·········· ··············· ············· +· · · ··········· ··········· · · ·········· +· · · ··········· · ···· · · ·············· +· · · ·········· · · · ·········· +· · · ········· · · · · ··· +· · · · · · · · +2 plotted · 0 unplaced · labels show highest station counts + +Highest station counts > US · United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / unicode > 'nearby' 1`] = ` +"Nearby ────────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'now-playing' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ · ∘ ● ◉ ◉ ◉ ● ∘ · · · ∘ • ● ◉ ◉ ◉ ◉ ● • • ∘ ∘ ∘ ∘ • ● ● ◉ ◉ ◉ ◉ ● • · · · · • ● ◉ ◉ ◉ • ∘ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode > 'now-playing/diagnostics' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • ∘ · · · ∘ • ● ● ● • ∘ · · · · • ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ● ∘ · · · · · · · · · · · ∘ • ● ◉ ◉ ◉ • ∘ · · · • ● ◉ ◉ ● • │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +│ Stream: none │ +│ Station time: 12:00 │ +│ Started: 11:30:00 AM · available mpv, airplay │ +│ │ +│ · Bjork - Joga │ +│ · Aphex Twin - Avril 14th │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode > 'now-playing/idle' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No station tuned IDLE │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ +│ │ +│ Playback backend ready. Choose a station to start tuning. ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode > 'search' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'search/loading' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +Searching station directories… + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Choose a category + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/agent' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba… on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/appearance' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/data' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/discovery' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B… on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/media-keys' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 · play 0 · next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/playback' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room · 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) · playing · volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'settings/updates' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'stations' 1`] = ` +"United States ─────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode > 'stats' 1`] = ` +"Listening stats ───────────────────────────────────────────────────────────────────────────────── +Local listening history · never leaves this machine + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Activity — 2026 │ +│ Dec Jan Feb Mar Apr May Jun Jul Aug Sep │ +│ │ +│Mon │ +│ │ +│Wed │ +│ │ +│Fri │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Summary │ +│Favorite station: KEXP 90.3 FM Total hours listened: 1.5h │ +│Sessions: 2 Longest streak: 2 days │ +│Current streak: 2 days Stations listened: 2 │ +│Active days: 2/371 Stations counted after: 2 min │ +│ │ +│Less · More │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`screen baseline / full / unicode-no-color > 'airplay-code' 1`] = ` +"AirPlay Code ────────────────────────────────────────────────────────────────────── Not requested +Enter the code shown on the receiver + +Receiver: Living Room +Playback: airplay / loading +Tune a station with AirPlay first; this screen is used once the receiver asks for a code. + +Code +**** +Enter submits · Backspace edits · Esc returns to AirPlay + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'airplay-settings' 1`] = ` +"AirPlay ─────────────────────────────────────────────────────────────────────────────────── Ready +Choose where AirPlay playback should go + +Audio output: Automatic · Streaming: ready · Receivers: 1 +1 receiver visible on this network. +Selected: Living Room +Code: if the receiver shows a code while tuning, RadioCLI will ask for it + +Receivers +> Living Room selected · speaker.local:7000 · AirPlay 2 · code shown on receiver +Enter selects · c opens code entry · r refreshes receivers + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS +› Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW +› Save alarm · next Mon, Sep 7, 1:30 PM (you… + Cancel + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring +› Time: 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK + Volume: 70% [███████···] Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +BASICS + Label: Morning radio + Enabled: Yes + Station: KEXP 90.3 FM Choose the primary stream from current, favorites,… + Schedule: Recurring + Time (24h): 06 : 30 Enter opens the time control; ←/→ chooses hours or… + Repeat: weekdays Enter opens the weekday checklist. Choose any comb… + Timezone: America/Los_Angeles An explicit IANA timezone keeps civil schedules st… +PLAYBACK +› Volume: 70% [███████···] ←/→ Sets player volume and raises/unmutes the local sy… + Fade in: 30s (mpv only) Fade-in requires mpv; other playback backends star… + Stop after: 60m Playback stops automatically unless Keep playing i… + Fallback: KCRW 89.9 FM — A deliberately lo… The fallback is tried when the primary stream cann… + Output: Local speakers Scheduled alarms currently play through local spea… +RELIABILITY + Run after wake within: 15m After sleep or logout, run only when the alarm is … + Request OS wake: Yes Asks supported systems to wake the computer for th… + Alarm Guard: On Alarm Guard prevents idle sleep until this occurre… + Test-tune station now (does not fire alarm) +REVIEW + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ────────────────────────────────────────────────────────────────────────────────────── +Local-first · native OS job installed on save · terminal may close afterward + +WEEKDAYS + ● Monday + ● Tuesday +› ● Wednesday + ● Thursday + ● Friday + ○ Saturday + ○ Sunday +Select one or more days. At least one day must remain checked. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-picker' 1`] = ` +"Primary station ───────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ──────────────────────────────────────────────────────────────────────────────── +Favorites, imports, recents, current station, and alarm stations + +› None · No fallback + KEXP 90.3 FM · Now playing + KCRW 89.9 FM — A deliberately long station name · Imported + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ─────────────────────────────────────────────────────────────────────────────────── +Wake radio · playing now + ALARM ACTIVE + + 11:59 AM + + KEXP 90.3 FM · Morning radio + Started 1m ago · scheduled Mon, Sep 7, 11:59 AM (your time) + + ENTER KEEP PLAYING SPACE SNOOZE 10 MIN + Enter opens Playing · Space stops the radio until snooze ends · b return + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms/degraded' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Degraded · Native scheduler ready. +Next wake · Mon, Sep 7, 1:30 PM (your time) · KEXP 90.3 FM + + Create alarm +› ● Morning radio · 06:30 weekdays Los Angeles · KEXP 90.3 FM · needs repair · press x again to de + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + +Background scheduling needs repair; keep the alarm visible and press r. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms/empty' 1`] = ` +"Alarms ────────────────────────────────────────────────────────────────────────────────────────── +Scheduler: Ready +No enabled future alarm. + +› Create alarm + Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal + + + + + + + + + + + + + + + + +Native scheduling is ready; the terminal does not need to stay open after save. + BETA · Experimental. Use a secondary device for critical wake-ups. +" +`; + +exports[`screen baseline / full / unicode-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verified with limitations ─────────────────────────────────────────────────────────── +Rehearsing “Morning radio” on this machine +READY WITH LIMITATIONS · Review warnings before relying on wake behavior. + +● Native scheduler + Disposable job registered and removed. +! Sleep protection + Closed-lid policy cannot be overridden. + + + + + + + + + + + + + + + +Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup +changes." +`; + +exports[`screen baseline / full / unicode-no-color > 'countries' 1`] = ` +"Countries ─────────────────────────────────────────────────────────────────────────────────────── +Browse the worldwide country directory + +Showing 1-2 of 2 +> United States (US) 12,345 stations + Japan (JP) 456 stations + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'explore' 1`] = ` +"Explore ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +┌─────────────────────────────────────────────────────────────┐ ┌────────────────────────────────┐ +│ ⢀⣀⣀⣀⣀⢀⣀⣀⣀⣀⢀ │ │Stations 2│ +│ ⢀⡠⠴⡄⢠⣏⢵⡿⡉⠑⠚⠻⣿⣿⣿⣿⣿⡿⠁ ⠈⠋⠋ ⢀⠤ ⣀⣠⣭⣥⣤⣤⡀⢀ ⠠⠤ │ │ │ +│⠤⠄⠰⣾⣷⣶⣶⣶⣶⣶⣾⣭⣯⣤⣽⡮⡏⠙⣷⡤ ⢸⣿⡿⠟⠛⢨⣠ ⣠⣴⢾⣷⡲⣤⣤⣬⣤⣶⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣷⣶⣶⡶│ │> KEXP 90.3 FM ★ │ +│ ⠈⠻⠋⠉⠙⠛⢿⣿⣿⣿⣿⣿⣧⣀⡀⣸⣦⣤⡀ ⠉⠁ ⣠ ⠘⠿⠇⣭⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠛⠋⣽⠈⠉ │ │ KCRW 89.9 FM — A deliberately…│ +│ ⠘⢻⣿⣿⣿⣿⣿⣿⣷⣿⣿⡟⡫⠄ ⠹●⣾⣿⣿⣿⡿⡿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛ ⠁ │ │ │ +│ ⠸⣿⣿⣿⣿⣿⣿⣿⡿⠃ ⣶⠟⠉⠑⠍⡟⣧⣦⣽⣷⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡻⡋⠁⡎ │ │ │ +│ ⠙⣻⣿⣿⠿⠿⢟⠁ ⢀⣾⣿⣿⣷⣄⣦⣤⣼⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷ ⠐⠉ │ │ │ +│ ⠠ ⠹⣿ ⡠⠈⠄⣀ ⢠⣿⣿⣿⣿⣿⣿⣿⣿⣯⢿⣿⣶⡏⠉⠻⣿⣿⠛⢿⣿⡿⠟⠋ │ │ │ +│ ⠈⠉⠳⡆⢀⣀⣀ ⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⣛⠁ ⢿⠁ ⠈⡟⠷ │ │ │ +│ ⠈⣹⣿⣿⣷⣦ ⠈⠛⠋⠙⣿⣿⣿⣿⣿⣿⠟ ⠁ ⢴ ⣠⡆⠙⡀ │ │ │ +│ ⣿⣿⣿⣿⣿⣷⣶⡄ ⢹⣿⣿⣿⣿⠃ ⠈⠆⣉⠙⠁⠊⠒⣦⡤⠢ │ │ │ +│ ⠹⣿⣿⣿⣿⣿⣿⠁ ⢨⣿⣿⣿⣿⠇⣠ ⢠⣰⣆⢰⠈ │ │ │ +│ ⢸⣿⣿⣿⡿⠋ ⠈⣿⣿⣿⠇ ⠏ ⢀⣶⣿⣿⣿⣿⣷⡀ │ │ │ +│ ⢸⣿⡿⠟⠁ ⠹⠿⠏ ⡿⠟⠛⢻⣿⣿⠇ │ │ │ +│ ⣿⡟⠉ ⠉⠍ ⡠⠃│ │ │ +│ ⠰⣿ ⠈⠁ │ │ │ +│ ⠈⠁ │ │ │ +│ ⢀⣔⠁ ⣀⣀⣀⣀⣀⣄⣠⣤⣶⣤⣤⠄⣀⣤⣤⣤⣴⣦⣴⣤⣤⣤⣦⣤⣤⣀⣀⣀ │ │ │ +│ ⠠⠤⢴⣴⣶⣶⣶⣶⣶⣶⣿⣶⣶⣶⡶⠚⠋⡀⢀⣀ ⢤⣤⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠁ │ │ │ +│⣶⣶⣤⣤⣬⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⣦│ │ │ +└─────────────────────────────────────────────────────────────┘ └────────────────────────────────┘" +`; + +exports[`screen baseline / full / unicode-no-color > 'help' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +Navigation +↑/↓ or n/p Move selection +←/→ or Tab Switch tabs +[ / ] Page selection by 10 +1-9 Jump to an Overview option +Enter Open / tune the selection +b or Esc Back to home +: Command palette +? Toggle this help +q / Ctrl+C twi… Quit cleanly +Playback +space or F8 Pause / resume +, / . or F7/F9 Previous / next station ++ / - Volume up / down +m Mute / unmute +f / Ctrl+F Favorite the station (Ctrl+F while typing in Search) +O Open station homepage +y Copy stream URL +s Sleep timer (Now Playing) +d Diagnostics + recent tracks (Now Playing) +a Schedule the selected / playing station +Showing 1-21 of 67 · ↑/↓ scroll" +`; + +exports[`screen baseline / full / unicode-no-color > 'help/commands' 1`] = ` +"Help ──────────────────────────────────────────────────────────────────────────────────────────── +Keyboard shortcuts and : commands · b or Esc to close + +v Cycle receiver style +o Cycle audio output +/ Edit search / country filter +w Toggle country list / world map +WASD Move the Explore map cursor +r Refresh provider health +Commands (press : then type) +:search Search stations (alias :s) +:country Minimum bitrate filter +:clear Clear search filters +:volume <0-100> Set volume (alias :vol) +:mute Toggle mute +:sleep Skip broken streams +:location 'home' 1`] = ` +"RADIOCLI ██████████████████ +Live public radio from around the world + + +> 1 Playing Receiver display and controls + 2 Library Favorites, recent stations, imported streams + 3 Explore Move a map cursor through geotagged stations + 4 Search Find stations by name, genre, language, place + 5 Countries Browse by country list with a world-map toggle + 6 Nearby Approximate IP location for local stations + 7 Stats Listening graph, stations, streaks, hours + 8 Alarms (beta) Wake to radio and schedule station playback + 9 Settings Audio output, colors, providers + +1 recent · 1 favorites · 1 imported + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'library' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'library/empty' 1`] = ` +"Library ───────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +Your library is empty. +Press f on any station to save it here. + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'map' 1`] = ` +"World map ───────────────────────────────────────────────────────────────────────────────────── +Station density by country + +· ·················· · · ···················· · +· ···························· ···· ······································· +· ········································ ·············································· +· ······································ ··············································· +· ················US·········· · ·····································JP···· +································································································ +· · ················· · ················ ······················· +· · ····· ········· ················ ················ +· · · ·········· ··············· ············· +· · · ··········· ··········· · · ·········· +· · · ··········· · ···· · · ·············· +· · · ·········· · · · ·········· +· · · ········· · · · · ··· +· · · · · · · · +2 plotted · 0 unplaced · labels show highest station counts + +Highest station counts > US · United States +US United States 12,345 +JP Japan 456 + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'nearby' 1`] = ` +"Nearby ────────────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'now-playing' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · · ∘ ● ● ◉ ● ● • ∘ · · · ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ ● ∘ · · · · ∘ • • • ∘ ∘ · · · ∘ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ◉ • ∘ · · · · · · · · · · · · • ● ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ • ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · · · · · · · · · ∘ ● ◉ ◉ ◉ ● • ∘ · · ∘ • ◉ ◉ ◉ ● • │ +│ · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ● • ∘ ∘ · · · · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ ● ◉ ◉ ◉ ● ∘ │ +│ · ∘ ● ◉ ◉ ◉ ● ∘ · · · ∘ • ● ◉ ◉ ◉ ◉ ● • • ∘ ∘ ∘ ∘ • ● ● ◉ ◉ ◉ ◉ ● • · · · · • ● ◉ ◉ ◉ • ∘ │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode-no-color > 'now-playing/diagnostics' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ +│ │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ • ● ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • ∘ · · · ∘ • ● ● ● • ∘ · · · · • ● ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ ◉ ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · ∘ • ◉ ◉ ◉ • ∘ · · ∘ • ◉ ◉ ◉ • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● • · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ◉ ◉ ◉ • ∘ · · · • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● │ +│ • ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ● • · · · ∘ • ● ◉ ◉ ◉ ◉ ● ∘ · · · ∘ • ◉ ◉ ◉ ● ∘ · · · • ◉ ◉ ◉ ● │ +│ ∘ ● ◉ ◉ ◉ • · · · ∘ • ◉ ◉ ◉ ● • · · · · · ∘ ∘ ∘ ∘ · · · · ∘ • ◉ ◉ ◉ ● • · · · ∘ ● ◉ ◉ ◉ • │ +│ ∘ ● ◉ ◉ ◉ ● ∘ · · · • ● ◉ ◉ ◉ ● ∘ · · · · · · · · · · · ∘ • ● ◉ ◉ ◉ • ∘ · · · • ● ◉ ◉ ● • │ +│ │ +│ Bjork - Joga ★ Favorite │ +│ │ +│ Stream: none │ +│ Station time: 12:00 │ +│ Started: 11:30:00 AM · available mpv, airplay │ +│ │ +│ · Bjork - Joga │ +│ · Aphex Twin - Avril 14th │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode-no-color > 'now-playing/idle' 1`] = ` +"Now playing ───────────────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No station tuned IDLE │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ +│ │ +│ Playback backend ready. Choose a station to start tuning. ★ Favorite │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`screen baseline / full / unicode-no-color > 'search' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'search/loading' 1`] = ` +"Search ────────────────────────────────────────────────────────────────────────────────────────── +Radio Browser + +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│› tokyo jazz │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ + +Searching station directories… + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Choose a category + +> Playback & audio 7 settings › + Appearance 6 settings › + Discovery & providers 4 settings › + Data & library 2 settings › + Agent control & MCP 4 settings › + Media keys 4 settings › + Updates 2 settings › + +Selected +Output, volume, AirPlay, and startup behavior + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/agent' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Agent control & MCP + +> Allow local agent control off + Install or repair MCP integrations run repair + Open TUI for agent playback on + Show Now Playing for agent playba… on + +Selected +Press Enter to set up or remove agent and Codex Voice control across detected clients. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/appearance' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Appearance + +> Display color green + Receiver style pulse-grid + Transparent background off + ASCII-safe display off + Reduce motion off + Mouse and trackpad scrolling auto + +Selected +Press Enter to change this setting. + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/data' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Data & library + +> Export preferences and library JSON backup + Import preferences and library restore JSON backup + +Selected +Library file: /fixture/radiocli.json + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/discovery' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Discovery & providers + +> Nearby location off + Radio Garden adapter off + Share favorite votes with Radio B… on + Refresh provider health + +Selected +Uses approximate IP location only when you open Nearby. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/media-keys' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Media keys + +> Learn previous media key + Learn play/pause media key + Learn next media key + Reset learned media keys prev 0 · play 0 · next 0 + +Selected +Press Enter to change this setting. + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/playback' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Playback & audio + +> Audio output Automatic + AirPlay receiver Living Room · 1 receiver + Volume up + Volume down + Mute or unmute vol 70 + Skip broken streams on + Resume last station on launch on + +Selected +Active: This device (mpv) · playing · volume 70 + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'settings/updates' 1`] = ` +"Settings ──────────────────────────────────────────────────────────────────────────────────────── +Settings › Updates + +> Automatically check for updates on + Install update v0.3.0 available + +Selected +When enabled, RadioCLI checks the registry once whenever the app launches. + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'stations' 1`] = ` +"United States ─────────────────────────────────────────────────────────────────────────────────── +Saved and discovered stations + +1-2 of 2 +> KEXP 90.3 FM ★ indie + KCRW 89.9 FM — A deliberately long sta… Santa Monica, United States · MP3 / 128 kbps + + + + + + + + + + + + + + + + + + +" +`; + +exports[`screen baseline / full / unicode-no-color > 'stats' 1`] = ` +"Listening stats ───────────────────────────────────────────────────────────────────────────────── +Local listening history · never leaves this machine + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Activity — 2026 │ +│ Dec Jan Feb Mar Apr May Jun Jul Aug Sep │ +│ │ +│Mon │ +│ │ +│Wed │ +│ │ +│Fri │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘ + +┌────────────────────────────────────────────────────────────────────────────────────────────────┐ +│Summary │ +│Favorite station: KEXP 90.3 FM Total hours listened: 1.5h │ +│Sessions: 2 Longest streak: 2 days │ +│Current streak: 2 days Stations listened: 2 │ +│Active days: 2/371 Stations counted after: 2 min │ +│ │ +│Less · More │ +└────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`screen baseline / micro / ascii > 'airplay-code' 1`] = ` +"RC / AirPlay code . KEX. + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / ascii > 'airplay-settings' 1`] = ` +"RC / AirPlay . 1 receiv. + +> Living Room . speaker. + + + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +BASICS +> Label: Morning radio + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +REVIEW +> Save alarm . next Mon. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + Schedule: Recurring +> Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +PLAYBACK +> Volume: 70% [#######. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + @ Tuesday +> @ Wednesday + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-picker' 1`] = ` +"Primary station ------- +Favorites, imports, rec. + +> KEXP 90.3 FM . Now pl. + KCRW 89.9 FM - A deli. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------ +Favorites, imports, rec. + +> None . No fallback + KEXP 90.3 FM . Now pl. + +" +`; + +exports[`screen baseline / micro / ascii > 'alarm-ringing' 1`] = ` +"Alarm ringing --------- +Wake radio . playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM . Morning . + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / ascii > 'alarms' 1`] = ` +"Alarms ---------------- +Scheduler ready +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii > 'alarms/degraded' 1`] = ` +"Alarms ---------------- +Repair scheduler +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii > 'alarms/empty' 1`] = ` +"Alarms ---------------- +Scheduler ready +No upcoming alarms. + +> Create alarm +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii > 'alarms/verification' 1`] = ` +"Alarm setup verifie. -- +Rehearsing "Morning rad. +READY WITH LIMITATIONS . + Review warnings before + Disposable job regis. +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / ascii > 'countries' 1`] = ` +"RC / Countries . 2 coun. + +> United St. 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii > 'explore' 1`] = ` +"RC / Explore . 48.9N, 2. + .:+#+:: @++*#*:. + .+:. +%%-+#-. + :#: -- --. + .::--:-+++++++- +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli." +`; + +exports[`screen baseline / micro / ascii > 'help' 1`] = ` +"RC / Help . Scroll for . + + Navigation + ^/v or n/p . Move sel. + or Tab . Switch t. + [ / ] . Page selectio. +" +`; + +exports[`screen baseline / micro / ascii > 'help/commands' 1`] = ` +"RC / Help . Scroll for . + + :country . + :codec . . +> :language . + :bitrate . Min. +" +`; + +exports[`screen baseline / micro / ascii > 'home' 1`] = ` +"RADIOCLI ######### + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / ascii > 'library' 1`] = ` +"RC / Library . 2 statio. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii > 'library/empty' 1`] = ` +"RC / Library . 0 statio. + +Your library is empty. +Press f on a station to. + + +" +`; + +exports[`screen baseline / micro / ascii > 'map' 1`] = ` +"RC / World map . 2 coun. + +> United St. 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii > 'nearby' 1`] = ` +"RC / Nearby . 2 station. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii > 'now-playing' 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii > 'now-playing/idle' 1`] = ` +"No station tuned . idle + + + + +######################## +" +`; + +exports[`screen baseline / micro / ascii > 'search' 1`] = ` +"SEARCH ++----------------------+ +|> tokyo jazz | ++----------------------+ + +> KEXP 90.3 FM . indie * +" +`; + +exports[`screen baseline / micro / ascii > 'search/loading' 1`] = ` +"SEARCH ++----------------------+ +|* tokyo jazz | ++----------------------+ + +Searching station direc. +" +`; + +exports[`screen baseline / micro / ascii > 'settings' 1`] = ` +"RC / Settings . Choose . + +> Playback . 7 settin. + Appearance 6 settin. + Discovery. 4 settin. + Data & li. 2 settin. +" +`; + +exports[`screen baseline / micro / ascii > 'settings/agent' 1`] = ` +"RC / Settings . Agent c. + +> Allow loc. off + Install o. run repa. + Open TUI . on + Show Now . on +" +`; + +exports[`screen baseline / micro / ascii > 'settings/appearance' 1`] = ` +"RC / Settings . Appeara. + +> Display c. green + Receiver . pulse-gr. + Transpare. off + ASCII-saf. on +" +`; + +exports[`screen baseline / micro / ascii > 'settings/data' 1`] = ` +"RC / Settings . Data & . + +> Export pr. JSON bac. + Import pr. restore . + + +" +`; + +exports[`screen baseline / micro / ascii > 'settings/discovery' 1`] = ` +"RC / Settings . Discove. + +> Nearby lo. off + Radio Gar. off + Share fav. on + Refresh p. +" +`; + +exports[`screen baseline / micro / ascii > 'settings/media-keys' 1`] = ` +"RC / Settings . Media k. + +> Learn pre. + Learn pla. + Learn nex. + Reset lea. prev 0 .. +" +`; + +exports[`screen baseline / micro / ascii > 'settings/playback' 1`] = ` +"RC / Settings . Playbac. + +> Audio out. Automatic + AirPlay r. Living R. + Volume up + Volume do. +" +`; + +exports[`screen baseline / micro / ascii > 'settings/updates' 1`] = ` +"RC / Settings . Updates. + +> Automatic. on + Install u. v0.3.0 a. + + +" +`; + +exports[`screen baseline / micro / ascii > 'stations' 1`] = ` +"RC / Stations . 2 stati. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii > 'stats' 1`] = ` +"RC / Listening stats . . + +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'airplay-code' 1`] = ` +"RC / AirPlay code . KEX. + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'airplay-settings' 1`] = ` +"RC / AirPlay . 1 receiv. + +> Living Room . speaker. + + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +BASICS +> Label: Morning radio + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +REVIEW +> Save alarm . next Mon. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + Schedule: Recurring +> Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + +PLAYBACK +> Volume: 70% [#######. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ------------ +Local-first . native OS. + + @ Tuesday +> @ Wednesday + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-picker' 1`] = ` +"Primary station ------- +Favorites, imports, rec. + +> KEXP 90.3 FM . Now pl. + KCRW 89.9 FM - A deli. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ------ +Favorites, imports, rec. + +> None . No fallback + KEXP 90.3 FM . Now pl. + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing --------- +Wake radio . playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM . Morning . + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms' 1`] = ` +"Alarms ---------------- +Scheduler ready +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms/degraded' 1`] = ` +"Alarms ---------------- +Repair scheduler +Next . Mon 1:30 PM + +> @ Morning radio . 06:3 +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms/empty' 1`] = ` +"Alarms ---------------- +Scheduler ready +No upcoming alarms. + +> Create alarm +BETA . Keep backup +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verifie. -- +Rehearsing "Morning rad. +READY WITH LIMITATIONS . + Review warnings before + Disposable job regis. +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / ascii-no-color > 'countries' 1`] = ` +"RC / Countries . 2 coun. + +> United St. 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'explore' 1`] = ` +"RC / Explore . 48.9N, 2. + .:+#+:: @++*#*:. + .+:. +%%-+#-. + :#: -- --. + .::--:-+++++++- +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli." +`; + +exports[`screen baseline / micro / ascii-no-color > 'help' 1`] = ` +"RC / Help . Scroll for . + + Navigation + ^/v or n/p . Move sel. + or Tab . Switch t. + [ / ] . Page selectio. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'help/commands' 1`] = ` +"RC / Help . Scroll for . + + :country . + :codec . . +> :language . + :bitrate . Min. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'home' 1`] = ` +"RADIOCLI ######### + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'library' 1`] = ` +"RC / Library . 2 statio. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'library/empty' 1`] = ` +"RC / Library . 0 statio. + +Your library is empty. +Press f on a station to. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'map' 1`] = ` +"RC / World map . 2 coun. + +> United St. 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'nearby' 1`] = ` +"RC / Nearby . 2 station. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'now-playing' 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM . playing + +. o @ o o o o o o @ o . +. o @ o o @ @ o o o o o +. o @ o o @ @ o o o o o +. o @ o o o o o o @ o . +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'now-playing/idle' 1`] = ` +"No station tuned . idle + + + + +######################## +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'search' 1`] = ` +"SEARCH ++----------------------+ +|> tokyo jazz | ++----------------------+ + +> KEXP 90.3 FM . indie * +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'search/loading' 1`] = ` +"SEARCH ++----------------------+ +|* tokyo jazz | ++----------------------+ + +Searching station direc. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings' 1`] = ` +"RC / Settings . Choose . + +> Playback . 7 settin. + Appearance 6 settin. + Discovery. 4 settin. + Data & li. 2 settin. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/agent' 1`] = ` +"RC / Settings . Agent c. + +> Allow loc. off + Install o. run repa. + Open TUI . on + Show Now . on +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/appearance' 1`] = ` +"RC / Settings . Appeara. + +> Display c. green + Receiver . pulse-gr. + Transpare. off + ASCII-saf. on +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/data' 1`] = ` +"RC / Settings . Data & . + +> Export pr. JSON bac. + Import pr. restore . + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/discovery' 1`] = ` +"RC / Settings . Discove. + +> Nearby lo. off + Radio Gar. off + Share fav. on + Refresh p. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/media-keys' 1`] = ` +"RC / Settings . Media k. + +> Learn pre. + Learn pla. + Learn nex. + Reset lea. prev 0 .. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/playback' 1`] = ` +"RC / Settings . Playbac. + +> Audio out. Automatic + AirPlay r. Living R. + Volume up + Volume do. +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'settings/updates' 1`] = ` +"RC / Settings . Updates. + +> Automatic. on + Install u. v0.3.0 a. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'stations' 1`] = ` +"RC / Stations . 2 stati. + +> KEXP 90.3 FM . indie * + KCRW 89.9 FM - A deli. + + +" +`; + +exports[`screen baseline / micro / ascii-no-color > 'stats' 1`] = ` +"RC / Listening stats . . + +Listening . 1.5h +Favorite . KEXP 90.3 FM +Sessions . 2 +Stations (2+ min) . 2 +" +`; + +exports[`screen baseline / micro / unicode > 'airplay-code' 1`] = ` +"RC / AirPlay code · KEX… + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / unicode > 'airplay-settings' 1`] = ` +"RC / AirPlay · 1 receiv… + +> Living Room · speaker… + + + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +BASICS +› Label: Morning radio + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +REVIEW +› Save alarm · next Mon… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + Schedule: Recurring +› Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +PLAYBACK +› Volume: 70% [███████… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + ● Tuesday +› ● Wednesday + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-picker' 1`] = ` +"Primary station ─────── +Favorites, imports, rec… + +› KEXP 90.3 FM · Now pl… + KCRW 89.9 FM — A deli… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────── +Favorites, imports, rec… + +› None · No fallback + KEXP 90.3 FM · Now pl… + +" +`; + +exports[`screen baseline / micro / unicode > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────── +Wake radio · playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM · Morning … + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / unicode > 'alarms' 1`] = ` +"Alarms ──────────────── +Scheduler ready +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode > 'alarms/degraded' 1`] = ` +"Alarms ──────────────── +Repair scheduler +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode > 'alarms/empty' 1`] = ` +"Alarms ──────────────── +Scheduler ready +No upcoming alarms. + +› Create alarm +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode > 'alarms/verification' 1`] = ` +"Alarm setup verifie… ── +Rehearsing “Morning rad… +READY WITH LIMITATIONS · + Review warnings before + Disposable job regis… +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / unicode > 'countries' 1`] = ` +"RC / Countries · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode > 'explore' 1`] = ` +"RC / Explore · 48.9N, 2… + ⠠⠤⢴⣶⣢⡐⠒ ●⣤⣤⣴⣶⣦⠤⠠ + ⠈⠫⢁⡀ ⢸⣾⣟⠜⠛⠿⡃⠁ + ⢈⠿⠃ ⠸⠇ ⠴⠥⢀ + ⢀⣀⣀⣈⣄⣀⣠⣤⣤⣤⣤⣤⣤⣤⣄ +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli…" +`; + +exports[`screen baseline / micro / unicode > 'help' 1`] = ` +"RC / Help · Scroll for … + + Navigation + ↑/↓ or n/p · Move sel… + ←/→ or Tab · Switch t… + [ / ] · Page selectio… +" +`; + +exports[`screen baseline / micro / unicode > 'help/commands' 1`] = ` +"RC / Help · Scroll for … + + :country … + :codec · … +> :language … + :bitrate · Min… +" +`; + +exports[`screen baseline / micro / unicode > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / unicode > 'library' 1`] = ` +"RC / Library · 2 statio… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode > 'library/empty' 1`] = ` +"RC / Library · 0 statio… + +Your library is empty. +Press f on a station to… + + +" +`; + +exports[`screen baseline / micro / unicode > 'map' 1`] = ` +"RC / World map · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode > 'nearby' 1`] = ` +"RC / Nearby · 2 station… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode > 'now-playing' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode > 'now-playing/idle' 1`] = ` +"No station tuned · idle + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / micro / unicode > 'search' 1`] = ` +"SEARCH +┌──────────────────────┐ +│› tokyo jazz │ +└──────────────────────┘ + +> KEXP 90.3 FM · indie ★ +" +`; + +exports[`screen baseline / micro / unicode > 'search/loading' 1`] = ` +"SEARCH +┌──────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────┘ + +Searching station direc… +" +`; + +exports[`screen baseline / micro / unicode > 'settings' 1`] = ` +"RC / Settings · Choose … + +> Playback … 7 settin… + Appearance 6 settin… + Discovery… 4 settin… + Data & li… 2 settin… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/agent' 1`] = ` +"RC / Settings · Agent c… + +> Allow loc… off + Install o… run repa… + Open TUI … on + Show Now … on +" +`; + +exports[`screen baseline / micro / unicode > 'settings/appearance' 1`] = ` +"RC / Settings · Appeara… + +> Display c… green + Receiver … pulse-gr… + Transpare… off + ASCII-saf… off +" +`; + +exports[`screen baseline / micro / unicode > 'settings/data' 1`] = ` +"RC / Settings · Data & … + +> Export pr… JSON bac… + Import pr… restore … + + +" +`; + +exports[`screen baseline / micro / unicode > 'settings/discovery' 1`] = ` +"RC / Settings · Discove… + +> Nearby lo… off + Radio Gar… off + Share fav… on + Refresh p… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/media-keys' 1`] = ` +"RC / Settings · Media k… + +> Learn pre… + Learn pla… + Learn nex… + Reset lea… prev 0 ·… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/playback' 1`] = ` +"RC / Settings · Playbac… + +> Audio out… Automatic + AirPlay r… Living R… + Volume up + Volume do… +" +`; + +exports[`screen baseline / micro / unicode > 'settings/updates' 1`] = ` +"RC / Settings · Updates… + +> Automatic… on + Install u… v0.3.0 a… + + +" +`; + +exports[`screen baseline / micro / unicode > 'stations' 1`] = ` +"RC / Stations · 2 stati… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode > 'stats' 1`] = ` +"RC / Listening stats · … + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'airplay-code' 1`] = ` +"RC / AirPlay code · KEX… + +Code: 1234 + + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'airplay-settings' 1`] = ` +"RC / AirPlay · 1 receiv… + +> Living Room · speaker… + + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +BASICS +› Label: Morning radio + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/save' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +REVIEW +› Save alarm · next Mon… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/time' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + Schedule: Recurring +› Time: 06 : 30 + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/volume' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + +PLAYBACK +› Volume: 70% [███████… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-editor/weekdays' 1`] = ` +"Edit alarm ──────────── +Local-first · native OS… + + ● Tuesday +› ● Wednesday + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-picker' 1`] = ` +"Primary station ─────── +Favorites, imports, rec… + +› KEXP 90.3 FM · Now pl… + KCRW 89.9 FM — A deli… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-picker/fallback' 1`] = ` +"Fallback station ────── +Favorites, imports, rec… + +› None · No fallback + KEXP 90.3 FM · Now pl… + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarm-ringing' 1`] = ` +"Alarm ringing ───────── +Wake radio · playing now + ALARM ACTIVE + 11:59 AM +KEXP 90.3 FM · Morning … + SPACE SNOOZE 10 MIN +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms' 1`] = ` +"Alarms ──────────────── +Scheduler ready +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms/degraded' 1`] = ` +"Alarms ──────────────── +Repair scheduler +Next · Mon 1:30 PM + +› ● Morning radio · 06:3 +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms/empty' 1`] = ` +"Alarms ──────────────── +Scheduler ready +No upcoming alarms. + +› Create alarm +BETA · Keep backup +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'alarms/verification' 1`] = ` +"Alarm setup verifie… ── +Rehearsing “Morning rad… +READY WITH LIMITATIONS · + Review warnings before + Disposable job regis… +Press Enter or b to +return to Alarms. Run" +`; + +exports[`screen baseline / micro / unicode-no-color > 'countries' 1`] = ` +"RC / Countries · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'explore' 1`] = ` +"RC / Explore · 48.9N, 2… + ⠠⠤⢴⣶⣢⡐⠒ ●⣤⣤⣴⣶⣦⠤⠠ + ⠈⠫⢁⡀ ⢸⣾⣟⠜⠛⠿⡃⠁ + ⢈⠿⠃ ⠸⠇ ⠴⠥⢀ + ⢀⣀⣀⣈⣄⣀⣠⣤⣤⣤⣤⣤⣤⣤⣄ +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli…" +`; + +exports[`screen baseline / micro / unicode-no-color > 'help' 1`] = ` +"RC / Help · Scroll for … + + Navigation + ↑/↓ or n/p · Move sel… + ←/→ or Tab · Switch t… + [ / ] · Page selectio… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'help/commands' 1`] = ` +"RC / Help · Scroll for … + + :country … + :codec · … +> :language … + :bitrate · Min… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'home' 1`] = ` +"RADIOCLI █████████ + +> 1 Playing + 2 Library + 3 Explore + 4 Search +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'library' 1`] = ` +"RC / Library · 2 statio… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'library/empty' 1`] = ` +"RC / Library · 0 statio… + +Your library is empty. +Press f on a station to… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'map' 1`] = ` +"RC / World map · 2 coun… + +> United St… 12,345 + Japan (JP) 456 + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'nearby' 1`] = ` +"RC / Nearby · 2 station… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'now-playing' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'now-playing/diagnostics' 1`] = ` +"KEXP 90.3 FM · playing + +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ● ● ◉ ◉ ◉ • ∘ +· ∘ ● ◉ ◉ ◉ ◉ ◉ ◉ ● • · +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'now-playing/idle' 1`] = ` +"No station tuned · idle + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'search' 1`] = ` +"SEARCH +┌──────────────────────┐ +│› tokyo jazz │ +└──────────────────────┘ + +> KEXP 90.3 FM · indie ★ +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'search/loading' 1`] = ` +"SEARCH +┌──────────────────────┐ +│⣾ tokyo jazz │ +└──────────────────────┘ + +Searching station direc… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings' 1`] = ` +"RC / Settings · Choose … + +> Playback … 7 settin… + Appearance 6 settin… + Discovery… 4 settin… + Data & li… 2 settin… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/agent' 1`] = ` +"RC / Settings · Agent c… + +> Allow loc… off + Install o… run repa… + Open TUI … on + Show Now … on +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/appearance' 1`] = ` +"RC / Settings · Appeara… + +> Display c… green + Receiver … pulse-gr… + Transpare… off + ASCII-saf… off +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/data' 1`] = ` +"RC / Settings · Data & … + +> Export pr… JSON bac… + Import pr… restore … + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/discovery' 1`] = ` +"RC / Settings · Discove… + +> Nearby lo… off + Radio Gar… off + Share fav… on + Refresh p… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/media-keys' 1`] = ` +"RC / Settings · Media k… + +> Learn pre… + Learn pla… + Learn nex… + Reset lea… prev 0 ·… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/playback' 1`] = ` +"RC / Settings · Playbac… + +> Audio out… Automatic + AirPlay r… Living R… + Volume up + Volume do… +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'settings/updates' 1`] = ` +"RC / Settings · Updates… + +> Automatic… on + Install u… v0.3.0 a… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'stations' 1`] = ` +"RC / Stations · 2 stati… + +> KEXP 90.3 FM · indie ★ + KCRW 89.9 FM — A deli… + + +" +`; + +exports[`screen baseline / micro / unicode-no-color > 'stats' 1`] = ` +"RC / Listening stats · … + +Listening · 1.5h +Favorite · KEXP 90.3 FM +Sessions · 2 +Stations (2+ min) · 2 +" +`; diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index f7f2109..1d18785 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -1,15 +1,71 @@ -import {mkdtempSync, rmSync} from 'node:fs'; +import {act} from 'react'; +import {chmodSync, mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {join} from 'node:path'; import {tmpdir} from 'node:os'; -import {afterEach, describe, expect, it, vi} from 'vitest'; -import {render} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render as inkRender} from 'ink-testing-library'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {PlayerController} from '../player/player-controller.js'; +import * as backendInstall from '../player/backend-install.js'; +import * as airplayDiscovery from '../player/airplay-discovery.js'; +import * as updates from '../update-check.js'; +import * as session from '../agent/session.js'; +import * as presence from '../alarms/tui-presence.js'; +import * as systemActions from './system-actions.js'; +import * as pageFooter from './page-footer.js'; import {JsonLibraryStore} from '../storage/store.js'; -import type {Alarm, Station} from '../types.js'; +import type {Alarm, IcyNowPlaying, PlaybackState, Station} from '../types.js'; import type {AlarmTuiService, TuiActiveAlarm} from './alarm-tui-service.js'; import {App} from './App.js'; const directories: string[] = []; +const pendingInputs: Array<() => void> = []; +// Exercise App and its alarm controller without host discovery or network races. +// Individual tests can still override the injected alarm/update/MCP services. +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // ink-testing-library sets columns but omits rows. Give Ink's fallback the + // fixture size so every layout avoids host terminal commands such as tput. + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '24'); + vi.stubEnv('LC_ALL', 'en_US.UTF-8'); + vi.stubEnv('TERM', 'xterm-256color'); + vi.stubEnv('RADIOCLI_ASCII', undefined); + vi.stubEnv('RADIOCLI_UNICODE', undefined); + const testHome = mkdtempSync(join(tmpdir(), 'radiocli-alarm-tui-home-')); + directories.push(testHome); + vi.stubEnv('RADIOCLI_HOME', testHome); + vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv']); + vi.spyOn(airplayDiscovery, 'discoverAirPlayDevices').mockResolvedValue([]); + vi.spyOn(ProviderManager.prototype, 'health').mockResolvedValue({}); + vi.spyOn(updates, 'checkForUpdate').mockImplementation(async ({currentVersion = '0.2.4'} = {}) => ({ + checkedAt: new Date().toISOString(), currentVersion, updateAvailable: false + })); + vi.spyOn(session, 'startRadioSession').mockResolvedValue({close: async () => undefined}); + vi.spyOn(presence, 'registerTuiPresence').mockReturnValue(() => undefined); + // These keyboard journeys repeat the same expensive footer layout many times. + // Keep the real layout for every distinct input; its wrapping and appearance + // also have dedicated page-footer and visual-baseline coverage. + const layoutFooter = pageFooter.balancedFooterLegendRows; + const footerLayouts = new Map(); + vi.spyOn(pageFooter, 'balancedFooterLegendRows').mockImplementation((...args) => { + const key = JSON.stringify(args); + let rows = footerLayouts.get(key); + if (!rows) { + rows = layoutFooter(...args); + footerLayouts.set(key, rows); + } + return [...rows]; + }); +}); afterEach(() => { + pendingInputs.length = 0; + act(() => cleanup()); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); vi.unstubAllEnvs(); for (const path of directories.splice(0)) rmSync(path, {recursive: true, force: true}); }); @@ -37,9 +93,275 @@ function callsFor() { return { verifySetup:vi.fn(async (alarm:Alarm|undefined,_settings:unknown,onUpdate:(report:unknown)=>void)=>{const report={state:'passed' as const,alarmLabel:alarm?.label,startedAt:new Date().toISOString(),finishedAt:new Date().toISOString(),steps:[{id:'scheduler',label:'Native scheduler',state:'passed' as const,detail:'Disposable job registered and removed.',critical:true}]};onUpdate(report);return report;}) }; } function addAlarm(store: JsonLibraryStore): Alarm { return store.addAlarm({label: 'Morning', enabled: true, station, schedule: {type: 'recurring', time: '06:30', weekdays: [1,2,3,4,5], timezone: 'America/Los_Angeles'}, playback: {volume: 70, fadeSeconds: 30, stopAfterMinutes: 60}, reliability: {missedRunGraceMinutes: 15, wakeIfSupported: true, keepAwakeUntilAlarm: false}}); } -async function settle(): Promise { await new Promise(resolve => setTimeout(resolve, 25)); } +// Preserve rapid duplicate keys and multi-character input as one React batch, +// matching the existing tests' write/write/settle boundaries. +function render(tree: Parameters[0]): ReturnType { + let app!: ReturnType; + act(() => { app = inkRender(tree); }); + const write = app.stdin.write; + app.stdin.write = input => { pendingInputs.push(() => write(input)); }; + const unmount = app.unmount; + app.unmount = () => { act(() => unmount()); }; + return app; +} +async function settle(elapsed = 0): Promise { + await act(async () => { + for (const write of pendingInputs.splice(0)) write(); + if (elapsed > 0) await vi.advanceTimersByTimeAsync(elapsed); + }); +} async function moveDown(app: ReturnType, count: number): Promise { for (let index = 0; index < count; index += 1) app.stdin.write('\u001B[B'); await settle(); } +function simulatedPlayer() { + let state: PlaybackState = {backend: 'mpv', state: 'idle', ready: false, volume: 70, muted: false}; + const listeners = new Set<(state: PlaybackState) => void>(); + const metadataListeners = new Set<(metadata: IcyNowPlaying) => void>(); + const change = (next: Partial) => { + state = {...state, ...next}; + for (const listener of listeners) listener({...state}); + }; + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...state})); + vi.spyOn(PlayerController.prototype, 'onChange').mockImplementation(listener => { + listeners.add(listener); + listener({...state}); + return () => { listeners.delete(listener); }; + }); + vi.spyOn(PlayerController.prototype, 'onMetadata').mockImplementation(listener => { + metadataListeners.add(listener); + return () => { metadataListeners.delete(listener); }; + }); + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (selected, url) => { + change({state: 'playing', ready: true, stationName: selected.name, streamUrl: url}); + }); + const stop = vi.spyOn(PlayerController.prototype, 'stop').mockImplementation(async () => { + change({state: 'idle', ready: false}); + }); + vi.spyOn(PlayerController.prototype, 'adjustVolume').mockImplementation(async delta => { + change({volume: state.volume + delta}); + return {ok: true}; + }); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async selected => ({url: selected.streamUrl ?? 'https://example.test/live'})); + return {play, stop, state: () => state, metadata: (metadata: IcyNowPlaying) => { + for (const listener of metadataListeners) listener(metadata); + }}; +} + +async function tuneLibrary(app: ReturnType): Promise { + app.stdin.write('2'); + await settle(); + app.stdin.write('\r'); + await settle(); +} + +describe('Persistence failure boundaries', () => { + const failedSave = () => { throw new Error('Library is read-only. Set RADIOCLI_HOME to a writable directory.'); }; + // Windows ACLs and a privileged POSIX user do not enforce this chmod fixture. + const nativePermissions = process.platform !== 'win32' && process.getuid?.() !== 0; + + it.skipIf(!nativePermissions)('tunes and navigates with a readable read-only library without inventing saved history', async () => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const original = readFileSync(store.filePath, 'utf8'); + chmodSync(store.filePath, 0o400); + try { + const app = render(); + await settle(); + await tuneLibrary(app); + expect(player.play).toHaveBeenCalledOnce(); + expect(player.state()).toMatchObject({state: 'playing', ready: true}); + expect(app.lastFrame()).toContain('Library not saved'); + expect(app.lastFrame()).not.toContain('Skipping'); + expect(readFileSync(store.filePath, 'utf8')).toBe(original); + app.stdin.write('b'); + await settle(); + expect(app.lastFrame()).toContain('Overview'); + app.stdin.write('q'); + await settle(); + expect(player.state().state).toBe('idle'); + app.unmount(); + } finally { + chmodSync(store.filePath, 0o600); + } + }); + + it.each([':stop\r', 'q'])('still stops playback through %j when the listening-history save fails', async input => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + vi.spyOn(store, 'finishActiveListeningSession').mockImplementation(failedSave); + + app.stdin.write(input); + await settle(); + expect(player.state().state).toBe('idle'); + expect(player.stop).toHaveBeenCalledTimes(2); + app.unmount(); + }); + + it('honors the sleep timer when the listening-history save fails', async () => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + vi.spyOn(store, 'finishActiveListeningSession').mockImplementation(failedSave); + + app.stdin.write(':sleep 1\r'); + await settle(); + await settle(60_000); + expect(player.state().state).toBe('idle'); + expect(app.lastFrame()).toContain('Library not saved'); + app.unmount(); + }); + + it.each(['stop', 'alarm-preempt'] as const)('honors agent %s when the listening-history save fails', async type => { + const {store, service} = fixture(); + store.updateSettings({agentControl: {...store.snapshot().settings.agentControl!, enabled: true}}); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + vi.spyOn(store, 'finishActiveListeningSession').mockImplementation(failedSave); + const handle = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + + await act(async () => { + const result = await handle({type}); + expect(result).toMatchObject({ok: true, status: {station: null, playback: {state: 'idle'}}}); + }); + expect(player.state().state).toBe('idle'); + expect(app.lastFrame()).toContain('Library not saved'); + app.unmount(); + }); + + it('keeps successful search results usable when search history cannot be saved', async () => { + const {store, service} = fixture(); + vi.spyOn(ProviderManager.prototype, 'search').mockResolvedValue([station]); + vi.spyOn(store, 'addSearch').mockImplementation(failedSave); + const app = render(); + await settle(); + app.stdin.write(':search jazz\r'); + await settle(); + expect(app.lastFrame()).toContain('KEXP'); + expect(app.lastFrame()).toContain('Library not saved'); + expect(store.snapshot().searchHistory).toEqual([]); + app.unmount(); + }); + + it.each(['metadata', 'checkpoint'] as const)('reports failed %s persistence while keeping playback active', async source => { + const {store, service} = fixture(); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + const before = store.snapshot(); + if (source === 'metadata') { + vi.spyOn(store, 'recordTrack').mockImplementation(failedSave); + act(() => player.metadata({title: 'A song', raw: '', updatedAt: new Date().toISOString()})); + await settle(); + } else { + vi.spyOn(store, 'checkpointActiveListeningSession').mockImplementation(failedSave); + await settle(30_000); + } + expect(app.lastFrame()).toContain('Library not saved'); + expect(player.state().state).toBe('playing'); + expect(store.snapshot()).toEqual(before); + app.unmount(); + }); + + it('reports update-check persistence failure without rejecting the background check', async () => { + vi.stubEnv('CI', 'false'); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '0'); + const {store, service} = fixture(); + vi.spyOn(store, 'updateCheckState').mockImplementation(failedSave); + const app = render(); + await settle(); + expect(updates.checkForUpdate).toHaveBeenCalledOnce(); + expect(app.lastFrame()).toContain('Library not saved'); + expect(store.snapshot().updateCheck).toBeUndefined(); + app.unmount(); + }); + + it('reports a rejected background update check', async () => { + vi.stubEnv('CI', 'false'); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '0'); + const {store, service} = fixture(); + vi.mocked(updates.checkForUpdate).mockRejectedValueOnce(new Error('Update service unavailable.')); + const app = render(); + await settle(); + expect(app.lastFrame()).toContain('Update service unavailable.'); + app.unmount(); + }); + + it.each(['t', ':location off\r', '+'])('reports a failed explicit setting through %j without claiming success', async input => { + const {store, service} = fixture(); + simulatedPlayer(); + const app = render(); + await settle(); + const settings = store.snapshot().settings; + vi.spyOn(store, 'updateSettings').mockImplementation(failedSave); + app.stdin.write(input); + await settle(); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(store.snapshot().settings).toEqual(settings); + app.stdin.write('9'); + await settle(); + expect(app.lastFrame()).toContain('Choose a category'); + app.unmount(); + }); + + it('reports a failed favorite write and preserves the favorite', async () => { + const {store, service} = fixture(); + const app = render(); + await settle(); + app.stdin.write('2'); + await settle(); + vi.spyOn(store, 'toggleFavorite').mockImplementation(failedSave); + app.stdin.write('f'); + await settle(); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(app.lastFrame()).not.toContain('Removed from favorites'); + expect(store.isFavorite(station)).toBe(true); + app.unmount(); + }); + + it('reports a failed media-key save from raw input', async () => { + const {store, service} = fixture(); + const app = render(); + await settle(); + app.stdin.write(':learn next\r'); + await settle(); + vi.spyOn(store, 'updateSettings').mockImplementation(failedSave); + app.stdin.write('x'); + await settle(); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(store.snapshot().settings.mediaKeys.next).toEqual([]); + app.unmount(); + }); + + it.each([ + {key: 'O', field: 'homepage' as const, value: 'javascript:unsafe-homepage'}, + {key: 'y', field: 'streamUrl' as const, value: 'javascript:unsafe-stream'} + ])('does not echo or dispatch an invalid $field when a desktop helper is unavailable', async ({key, field, value}) => { + const {store, service} = fixture(); + store.toggleFavorite(station); + store.toggleFavorite({...station, [field]: value}); + const open = vi.spyOn(systemActions, 'openExternal').mockResolvedValue(false); + const copy = vi.spyOn(systemActions, 'copyToClipboard').mockResolvedValue(false); + const app = render(); + await settle(); + app.stdin.write('2'); + await settle(); + app.stdin.write(key); + await settle(); + expect(open).not.toHaveBeenCalled(); + expect(copy).not.toHaveBeenCalled(); + expect(app.lastFrame()).not.toContain(value); + expect(app.lastFrame()).toMatch(/valid|safe/i); + app.unmount(); + }); +}); + describe('Settings TUI integration', () => { it('checks once at launch and shows the available version beside the installed version', async () => { vi.stubEnv('CI', 'false'); @@ -47,7 +369,7 @@ describe('Settings TUI integration', () => { const {store, service} = fixture(); const updateChecker = vi.fn(async () => ({ checkedAt: '2026-09-07T12:00:00.000Z', - currentVersion: '0.2.3', + currentVersion: '0.2.4', latestVersion: '0.3.0', updateAvailable: true })); @@ -56,7 +378,7 @@ describe('Settings TUI integration', () => { await settle(); expect(updateChecker).toHaveBeenCalledOnce(); - expect(app.lastFrame()).toContain('v0.3.0 available v0.2.3'); + expect(app.lastFrame()).toContain('v0.3.0 available v0.2.4'); app.unmount(); }); @@ -223,7 +545,19 @@ describe('alarm TUI integration', () => { }); it('does not let background active polling move a non-alarm screen cursor', async () => { - const {store,service}=fixture();const app=render();await settle();app.stdin.write('9');await settle();await moveDown(app,4);const selectedLine=app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '));expect(selectedLine).toBeTruthy();vi.useFakeTimers();try{await vi.advanceTimersByTimeAsync(1_600);expect(app.lastFrame()?.split('\n').find(line=>line.trimStart().startsWith('> '))).toBe(selectedLine);}finally{app.unmount();vi.useRealTimers();} + const {store, service, calls} = fixture(); + const app = render(); + await settle(); + app.stdin.write('9'); + await settle(); + await moveDown(app, 4); + const selectedLine = app.lastFrame()?.split('\n').find(line => line.trimStart().startsWith('> ')); + expect(selectedLine).toBeTruthy(); + expect(calls.activeAlarms).toHaveBeenCalledOnce(); + await settle(1_600); + expect(calls.activeAlarms).toHaveBeenCalledTimes(2); + expect(app.lastFrame()?.split('\n').find(line => line.trimStart().startsWith('> '))).toBe(selectedLine); + app.unmount(); }); it('toggles, confirms deletion with a second x, and cleans the native job through the injected service', async () => { @@ -242,6 +576,81 @@ describe('alarm TUI integration', () => { app.stdin.write('r'); await settle(); expect(calls.syncAll.mock.calls.at(-1)?.[0]).toEqual([expect.objectContaining({id: alarm.id, enabled: false})]); app.unmount(); }); + it('does not touch native jobs when deletion cannot first save a disabled alarm', async () => { + const {store, service, calls} = fixture(); + const alarm = addAlarm(store); + const player = simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + const original = readFileSync(store.filePath, 'utf8'); + const disable = vi.spyOn(store, 'toggleAlarm').mockImplementation(() => { throw new Error('Library is read-only'); }); + calls.remove.mockRejectedValue(new Error('Native cleanup denied')); + + app.stdin.write('b'); await settle(); + app.stdin.write('8'); await settle(); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + + expect(calls.remove).not.toHaveBeenCalled(); + expect(store.getAlarm(alarm.id)?.enabled).toBe(true); + expect(readFileSync(store.filePath, 'utf8')).toBe(original); + expect(player.state().state).toBe('playing'); + expect(app.lastFrame()).toContain('Library is read-only'); + expect(app.lastFrame()).not.toContain('kept disabled'); + + // A failed attempt releases the row and permits an explicit retry. + disable.mockRestore(); calls.remove.mockResolvedValue(undefined); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + expect(store.getAlarm(alarm.id)).toBeUndefined(); + expect(calls.remove).toHaveBeenCalledOnce(); + expect(player.state().state).toBe('playing'); + app.unmount(); + }); + + it('survives native cleanup failure after storage becomes unavailable without a recovery write', async () => { + const {store, service, calls} = fixture(); + const alarm = addAlarm(store); + const player = simulatedPlayer(); + const app = render(); + await settle(); await tuneLibrary(app); + const disable = vi.spyOn(store, 'toggleAlarm'); + calls.remove.mockImplementationOnce(async () => { + disable.mockImplementation(() => { throw new Error('Storage disappeared during cleanup'); }); + throw new Error('Native cleanup denied'); + }); + app.stdin.write('b'); await settle(); + app.stdin.write('8'); await settle(); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + + expect(disable).toHaveBeenCalledOnce(); + expect(store.getAlarm(alarm.id)?.enabled).toBe(false); + expect(new JsonLibraryStore(store.filePath).getAlarm(alarm.id)?.enabled).toBe(false); + expect(app.lastFrame()).toContain('Native cleanup denied'); + expect(app.lastFrame()).toContain('kept disabled'); + expect(player.state().state).toBe('playing'); + app.stdin.write('b'); await settle(); + expect(app.lastFrame()).toContain('Overview'); + app.unmount(); + }); + + it('retains a disabled definition and accurate diagnostics when its final deletion write fails', async () => { + const {store, service, calls} = fixture(); + const alarm = addAlarm(store); + const app = render(); + await settle(); + const disable = vi.spyOn(store, 'toggleAlarm'); + vi.spyOn(store, 'removeAlarm').mockImplementation(() => { throw new Error('Disk full'); }); + app.stdin.write('8'); await settle(); + app.stdin.write('x'); app.stdin.write('x'); await settle(); + + expect(calls.remove).toHaveBeenCalledWith(expect.objectContaining({id: alarm.id, enabled: false})); + expect(disable).toHaveBeenCalledOnce(); + expect(new JsonLibraryStore(store.filePath).getAlarm(alarm.id)?.enabled).toBe(false); + expect(app.lastFrame()).toContain('Native cleanup completed'); + expect(app.lastFrame()).toContain('Disk full'); + app.unmount(); + }); + it('preserves rapid disable-enable order through a deferred native sync', async () => { const {store, service, calls} = fixture(); addAlarm(store); let finish!: (value: Date) => void; calls.sync.mockImplementationOnce(async () => new Promise(resolve => { finish = resolve; })); @@ -283,7 +692,8 @@ describe('alarm TUI integration', () => { expect(app.lastFrame()).toContain('Primary station'); app.stdin.write('\r'); await settle(); expect(app.lastFrame()).toContain('Station: KEXP'); app.stdin.write('\u0013'); await settle(); expect(store.listAlarms()).toHaveLength(1); const original = store.listAlarms()[0]!; app.stdin.write('\r'); await settle(); expect(app.lastFrame()).toContain('Edit alarm'); - app.stdin.write('\u001B'); await settle(); expect(store.getAlarm(original.id)?.label).toBe(original.label); + // Ink waits 20ms to distinguish a lone Escape from a split escape sequence. + app.stdin.write('\u001B'); await settle(20); expect(store.getAlarm(original.id)?.label).toBe(original.label); app.stdin.write('r'); await settle(); expect(calls.syncAll.mock.calls.length).toBeGreaterThanOrEqual(2); expect(app.lastFrame()).toContain('synchronized'); app.unmount(); }); @@ -299,7 +709,7 @@ describe('alarm TUI integration', () => { await moveDown(app, 3); app.stdin.write('\r'); await settle(); await moveDown(app, 1); app.stdin.write('\r'); await settle(); app.stdin.write('\u0013'); await settle(); const updated = store.getAlarm(alarm.id)!; expect(updated.label).toContain('edited'); expect(updated.enabled).toBe(false); expect(updated.schedule).toMatchObject({weekdays: [1,3,5]}); expect(updated.playback.volume).toBe(85); expect(updated.playback.fallbackStation).toBeUndefined(); expect(updated.reliability).toMatchObject({wakeIfSupported: false, keepAwakeUntilAlarm: true}); app.unmount(); - }); + }, 10_000); it('edits hours and minutes as separate arrow-controlled segments', async () => { const {store, service} = fixture(); addAlarm(store); const app = render(); await settle(); app.stdin.write('8'); await settle(); app.stdin.write('\r'); await settle(); diff --git a/src/ui/app-state.test.ts b/src/ui/app-state.test.ts index a5b750e..da9dc59 100644 --- a/src/ui/app-state.test.ts +++ b/src/ui/app-state.test.ts @@ -9,6 +9,7 @@ import { defaultExploreCursor, favoriteTarget, formatExploreCursor, + isSearchFavoriteShortcut, mediaTransportActionForInput, moveExploreCursor, nextPlaybackBackend, @@ -20,6 +21,7 @@ import { shouldAnimateReceiver, shouldResetReceiverPulse, shouldSkipAfterTuneError, + shouldTuneSearchResult, shouldToggleNearbyLocationShortcut, stationContextKeyForScreen, topTabs @@ -82,8 +84,9 @@ describe('app state helpers', () => { it('targets favorites based on the active screen', () => { const playing = {...station, id: 'playing'}; - expect(favoriteTarget('library', station, playing)?.id).toBe('station-1'); - expect(favoriteTarget('search', station, playing)?.id).toBe('station-1'); + for (const screen of ['explore', 'stations', 'search', 'nearby', 'library'] as const) { + expect(favoriteTarget(screen, station, playing)?.id).toBe('station-1'); + } expect(favoriteTarget('now-playing', station, playing)?.id).toBe('playing'); expect(favoriteTarget('settings', null, playing)?.id).toBe('playing'); }); @@ -118,6 +121,22 @@ describe('app state helpers', () => { expect(searchEditingArrowAction({}, true)).toBeNull(); }); + it('tunes retained search results after the query is cleared without tuning stale results for a changed query', () => { + expect(shouldTuneSearchResult('', 'jazz')).toBe(true); + expect(shouldTuneSearchResult(' ', 'jazz')).toBe(true); + expect(shouldTuneSearchResult(' jazz ', 'jazz')).toBe(true); + expect(shouldTuneSearchResult('news', 'jazz')).toBe(false); + }); + + it('recognizes cross-platform modified-F favorite shortcuts without consuming plain search text', () => { + expect(isSearchFavoriteShortcut('f', {ctrl: true})).toBe(true); + expect(isSearchFavoriteShortcut('f', {meta: true})).toBe(true); + expect(isSearchFavoriteShortcut('f', {super: true})).toBe(true); + expect(isSearchFavoriteShortcut('F', {ctrl: true})).toBe(true); + expect(isSearchFavoriteShortcut('f', {})).toBe(false); + expect(isSearchFavoriteShortcut('a', {ctrl: true})).toBe(false); + }); + it('maps terminal function-key transport sequences', () => { expect(mediaTransportActionForInput('\u001B[18~')).toBe('previous'); expect(mediaTransportActionForInput('\u001B[19~')).toBe('playPause'); diff --git a/src/ui/app-state.ts b/src/ui/app-state.ts index 67a72a3..9eb6b56 100644 --- a/src/ui/app-state.ts +++ b/src/ui/app-state.ts @@ -125,6 +125,18 @@ export function searchEditingArrowAction( return null; } +export function shouldTuneSearchResult(query: string, lastSubmittedQuery: string): boolean { + const trimmedQuery = query.trim(); + return !trimmedQuery || trimmedQuery === lastSubmittedQuery.trim(); +} + +export function isSearchFavoriteShortcut( + input: string, + key: {ctrl?: boolean; meta?: boolean; super?: boolean} +): boolean { + return input.toLowerCase() === 'f' && Boolean(key.ctrl || key.meta || key.super); +} + export function clampVolume(value: number): number { return Math.min(100, Math.max(0, Math.round(value))); } diff --git a/src/ui/ascii.test.ts b/src/ui/ascii.test.ts index 49b49bc..80d6842 100644 --- a/src/ui/ascii.test.ts +++ b/src/ui/ascii.test.ts @@ -29,6 +29,7 @@ describe('toAsciiSafe', () => { expect(toAsciiSafe('·•●')).toBe('.o@'); expect(toAsciiSafe('a · b')).toBe('a . b'); expect(toAsciiSafe('Loading…')).toBe('Loading.'); + expect(toAsciiSafe('∘ ◆ “radio” ‘track’')).toBe('o * "radio" \'track\''); }); it('preserves direction in visualizer geometry', () => { @@ -37,5 +38,6 @@ describe('toAsciiSafe', () => { it('preserves international station names', () => { expect(toAsciiSafe('Rádio 東京')).toBe('Rádio 東京'); + expect(toAsciiSafe('Cafe\u0301 / Český / हिन्दी / العربية')).toBe('Cafe\u0301 / Český / हिन्दी / العربية'); }); }); diff --git a/src/ui/ascii.ts b/src/ui/ascii.ts index d5ea19f..eed9cd0 100644 --- a/src/ui/ascii.ts +++ b/src/ui/ascii.ts @@ -82,6 +82,11 @@ function asciiGlyph(code: number): string { return 'o'; // ◉ ◒ } + if (code === 0x2218) return 'o'; // ring operator used by receiver patterns + if (code === 0x25c6 || code === 0x25c7) return '*'; // decorative diamonds + if (code === 0x201c || code === 0x201d) return '"'; + if (code === 0x2018 || code === 0x2019) return "'"; + if (code === 0x25b2) { return '^'; // ▲ } @@ -131,8 +136,9 @@ function asciiGlyph(code: number): string { if (code === 0x2191) return '^'; if (code === 0x2193) return 'v'; - // Drop zero-width and combining marks entirely so they do not skew widths. - if (code === 0x200b || code === 0xfeff || (code >= 0x0300 && code <= 0x036f)) { + // Drop invisible separators, but retain combining accents in station data. + // Width measurement already accounts for combining marks as zero columns. + if (code === 0x200b || code === 0xfeff) { return ''; } diff --git a/src/ui/components/AdaptiveMarquee.tsx b/src/ui/components/AdaptiveMarquee.tsx index 369bb0e..02c1af0 100644 --- a/src/ui/components/AdaptiveMarquee.tsx +++ b/src/ui/components/AdaptiveMarquee.tsx @@ -1,6 +1,8 @@ import React, {useEffect, useState} from 'react'; import {Text} from 'ink'; import {displayWidth, sliceDisplay, truncate} from '../format.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type AdaptiveMarqueeProps = { text: string; @@ -13,14 +15,15 @@ const edgePauseMs = 1000; const stepMs = 180; export function AdaptiveMarquee({text, width, active, reduceMotion}: AdaptiveMarqueeProps): React.ReactElement { + const {ascii, reduceMotion: terminalReduceMotion} = useDisplay(); const safeWidth = Math.max(0, width); const maxOffset = Math.max(0, displayWidth(text) - safeWidth); - const animated = active && !reduceMotion && maxOffset > 0; + const animated = active && !reduceMotion && !terminalReduceMotion && maxOffset > 0; const [offset, setOffset] = useState(0); useEffect(() => { setOffset(0); - }, [active, reduceMotion, safeWidth, text]); + }, [active, reduceMotion, terminalReduceMotion, safeWidth, text]); useEffect(() => { if (!animated) return; @@ -34,5 +37,5 @@ export function AdaptiveMarquee({text, width, active, reduceMotion}: AdaptiveMar const visible = animated ? sliceDisplay(text, Math.min(offset, maxOffset), safeWidth) : truncate(text, safeWidth); - return {visible}; + return {ascii ? toAsciiSafe(visible) : visible}; } diff --git a/src/ui/components/Logo.tsx b/src/ui/components/Logo.tsx index 9269693..9e68a69 100644 --- a/src/ui/components/Logo.tsx +++ b/src/ui/components/Logo.tsx @@ -1,15 +1,17 @@ import React from 'react'; import {Box, Text} from 'ink'; import {textMuted} from '../theme.js'; +import {useDisplay} from '../display-context.js'; const logoSpectrumColors =['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc']; function LogoSpectrum(): React.ReactElement { + const {ascii} = useDisplay(); return ( <> {logoSpectrumColors.map(color => ( - - ██ + + {ascii ? '##' : '██'} ))} @@ -17,6 +19,7 @@ function LogoSpectrum(): React.ReactElement { } export function Logo({compact = false, width = 80}: {compact?: boolean; width?: number} = {}): React.ReactElement { + const {ascii} = useDisplay(); if (compact) { const spectrumColumns = Math.max(1, width - 10); const colors = logoSpectrumColors.slice(0, Math.min(logoSpectrumColors.length, spectrumColumns)); @@ -24,7 +27,7 @@ export function Logo({compact = false, width = 80}: {compact?: boolean; width?: RADIOCLI - {colors.map(color => )} + {colors.map(color => {ascii ? '#' : '█'})} ); } diff --git a/src/ui/components/Menu.tsx b/src/ui/components/Menu.tsx index 4467b2f..57f075c 100644 --- a/src/ui/components/Menu.tsx +++ b/src/ui/components/Menu.tsx @@ -12,9 +12,9 @@ export function Menu({items, selected, keyFor, render}: MenuProps): React. const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1; return ( - + {items.map((item, position) => ( - + {render(item, position, position === activeIndex)} ))} @@ -23,7 +23,7 @@ export function Menu({items, selected, keyFor, render}: MenuProps): React. } export function Pointer({active}: {active: boolean}): React.ReactElement { - return {active ? '> ' : ' '}; + return {active ? '> ' : ' '}; } function defaultMenuKey(item: unknown): React.Key { diff --git a/src/ui/components/ScreenHeader.tsx b/src/ui/components/ScreenHeader.tsx index d28ebc2..057ff13 100644 --- a/src/ui/components/ScreenHeader.tsx +++ b/src/ui/components/ScreenHeader.tsx @@ -32,7 +32,7 @@ export function ScreenHeader({title, width, theme, subtitle, right}: ScreenHeade {a(safeTitle)} - {rule} + {rule} {rightText ? {a(rightText)} : null} {subtitle ? {a(truncate(subtitle, width))} : null} diff --git a/src/ui/components/StationList.tsx b/src/ui/components/StationList.tsx index 2985fd4..1f88681 100644 --- a/src/ui/components/StationList.tsx +++ b/src/ui/components/StationList.tsx @@ -7,6 +7,7 @@ import {Menu, Pointer} from './Menu.js'; import {visibleWindow} from '../list-window.js'; import {useDisplay} from '../display-context.js'; import {AdaptiveMarquee} from './AdaptiveMarquee.js'; +import {toAsciiSafe} from '../ascii.js'; type StationListProps = { stations: Station[]; @@ -31,12 +32,13 @@ export function StationList({ emptyHint = 'Try another view or clear active filters.', showCount = true }: StationListProps): React.ReactElement { - const {reduceMotion} = useDisplay(); + const {reduceMotion, ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; if (stations.length === 0) { return ( - {emptyTitle} - {emptyHint} + {a(emptyTitle)} + {a(emptyHint)} ); } @@ -72,7 +74,7 @@ export function StationList({ reduceMotion={reduceMotion} /> - {favorite ? : null} + {favorite ? {a(' ★')} : null} ); } @@ -90,10 +92,10 @@ export function StationList({ - {favorite ? : null} + {favorite ? {a(' ★')} : null} - {truncate(active ? selectedMetadata : standardMetadata, metaWidth)} + {a(truncate(active ? selectedMetadata : standardMetadata, metaWidth))} ); diff --git a/src/ui/components/TopTabs.tsx b/src/ui/components/TopTabs.tsx index 4420e41..1934db3 100644 --- a/src/ui/components/TopTabs.tsx +++ b/src/ui/components/TopTabs.tsx @@ -38,7 +38,7 @@ export function TopTabs({tabs, active, theme, width, backendLabel}: TopTabsProps const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightWidth : 0)); return ( - + `${tab.label}${tab.screen === active ? ' (selected)' : ''}`).join(', ')}. Audio output: ${backendLabel}.`}> {box.tl} @@ -51,7 +51,7 @@ export function TopTabs({tabs, active, theme, width, backendLabel}: TopTabsProps {visibleTabs.map((item, index) => ( {item.type === 'overflow' ? ( - + {ascii ? '.' : '…'} ) : ( {item.tab.label} diff --git a/src/ui/display-context.test.ts b/src/ui/display-context.test.ts index 2ff920d..652d7e6 100644 --- a/src/ui/display-context.test.ts +++ b/src/ui/display-context.test.ts @@ -1,12 +1,12 @@ import {describe, expect, it} from 'vitest'; -import {noColorRequested, panelBorderStyle, resolveDisplayMode} from './display-context.js'; +import {panelBorderStyle, resolveDisplayMode} from './display-context.js'; import {appBackground, panelBackground} from './theme.js'; describe('display mode', () => { it('honors the NO_COLOR convention only for non-empty values', () => { - expect(noColorRequested({NO_COLOR: '1'})).toBe(true); - expect(noColorRequested({NO_COLOR: ''})).toBe(false); - expect(noColorRequested({})).toBe(false); + expect(resolveDisplayMode({}, {NO_COLOR: '1'})).toMatchObject({colorLevel: 0, app: undefined, panel: undefined}); + expect(resolveDisplayMode({}, {NO_COLOR: ''})).toMatchObject({colorLevel: 3, app: appBackground, panel: panelBackground}); + expect(resolveDisplayMode({}, {})).toMatchObject({colorLevel: 3, app: appBackground, panel: panelBackground}); }); it('keeps opaque panel fills by default', () => { @@ -14,7 +14,9 @@ describe('display mode', () => { app: appBackground, panel: panelBackground, ascii: false, - reduceMotion: false + reduceMotion: false, + screenReader: false, + colorLevel: 3 }); }); @@ -27,6 +29,22 @@ describe('display mode', () => { expect(resolveDisplayMode({asciiMode: true, reduceMotion: true}, {})).toMatchObject({ascii: true, reduceMotion: true}); }); + it('applies terminal overrides without changing persisted preferences', () => { + const settings = Object.freeze({asciiMode: false, reduceMotion: false, transparentBackground: false}); + expect(resolveDisplayMode(settings, {TERM: 'dumb'})).toMatchObject({ascii: true, reduceMotion: true, colorLevel: 0, app: undefined, panel: undefined}); + expect(settings).toEqual({asciiMode: false, reduceMotion: false, transparentBackground: false}); + expect(resolveDisplayMode({asciiMode: true}, {RADIOCLI_UNICODE: '1'}).ascii).toBe(false); + }); + + it('drops forced dark backgrounds on sixteen-color terminals', () => { + expect(resolveDisplayMode({}, {TERM: 'xterm'})).toMatchObject({colorLevel: 1, app: undefined, panel: undefined}); + expect(resolveDisplayMode({}, {TERM: 'xterm-256color'})).toMatchObject({colorLevel: 2, app: appBackground, panel: panelBackground}); + }); + + it.each([{INK_SCREEN_READER: 'true'}, {RADIOCLI_SCREEN_READER: '1'}])('marks %j as static screen-reader output', env => { + expect(resolveDisplayMode({}, env)).toMatchObject({screenReader: true, reduceMotion: true, app: undefined, panel: undefined}); + }); + it('switches panel borders to ASCII classic in ascii mode', () => { expect(panelBorderStyle(false)).toBe('round'); expect(panelBorderStyle(false, 'single')).toBe('single'); diff --git a/src/ui/display-context.ts b/src/ui/display-context.ts index a05a430..5a4adcb 100644 --- a/src/ui/display-context.ts +++ b/src/ui/display-context.ts @@ -1,4 +1,5 @@ import {createContext, useContext} from 'react'; +import {resolveTerminalCapabilities, type TerminalColorLevel, type TerminalEvidence} from '../platform/terminal.js'; import {appBackground, panelBackground} from './theme.js'; type DisplayBackgrounds = { @@ -9,6 +10,8 @@ type DisplayBackgrounds = { export type DisplayMode = DisplayBackgrounds & { ascii: boolean; reduceMotion: boolean; + screenReader: boolean; + colorLevel: TerminalColorLevel; }; type DisplaySettings = { @@ -20,20 +23,15 @@ type DisplaySettings = { const opaqueBackgrounds: DisplayBackgrounds = {app: appBackground, panel: panelBackground}; const transparentBackgrounds: DisplayBackgrounds = {app: undefined, panel: undefined}; -// Respect the NO_COLOR convention (https://no-color.org): any non-empty value -// opts the user out of our forced colors, including the dark panel fills. -export function noColorRequested(env: NodeJS.ProcessEnv = process.env): boolean { - return typeof env.NO_COLOR === 'string' && env.NO_COLOR.length > 0; +// Sixteen-color palettes cannot safely reproduce our dark fills. Let those +// terminals keep their own background while Ink quantizes the foregrounds. +export function resolveDisplayMode(settings: DisplaySettings, env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): DisplayMode { + const terminal = resolveTerminalCapabilities(env, {...settings, ...evidence}); + const backgrounds = settings.transparentBackground || terminal.colorLevel < 2 ? transparentBackgrounds : opaqueBackgrounds; + return {...backgrounds, ascii: !terminal.unicode, reduceMotion: terminal.reduceMotion, screenReader: terminal.screenReader, colorLevel: terminal.colorLevel}; } -// Resolve the effective display mode from the user's settings and the NO_COLOR -// environment so light terminals keep their own background. -export function resolveDisplayMode(settings: DisplaySettings, env: NodeJS.ProcessEnv = process.env): DisplayMode { - const backgrounds = settings.transparentBackground || noColorRequested(env) ? transparentBackgrounds : opaqueBackgrounds; - return {...backgrounds, ascii: Boolean(settings.asciiMode), reduceMotion: Boolean(settings.reduceMotion)}; -} - -const defaultMode: DisplayMode = {...opaqueBackgrounds, ascii: false, reduceMotion: false}; +const defaultMode: DisplayMode = resolveDisplayMode({}, {}); export const DisplayContext = createContext(defaultMode); diff --git a/src/ui/help-content.ts b/src/ui/help-content.ts index 06b07f4..8ded474 100644 --- a/src/ui/help-content.ts +++ b/src/ui/help-content.ts @@ -30,7 +30,7 @@ export const keyHelpSections: HelpSection[] = [ {keys: ', / . or F7/F9', description: 'Previous / next station'}, {keys: '+ / -', description: 'Volume up / down'}, {keys: 'm', description: 'Mute / unmute'}, - {keys: 'f', description: 'Favorite the station'}, + {keys: 'f / Ctrl+F', description: 'Favorite the station (Ctrl+F while typing in Search)'}, {keys: 'O', description: 'Open station homepage'}, {keys: 'y', description: 'Copy stream URL'}, {keys: 's', description: 'Sleep timer (Now Playing)'}, @@ -87,7 +87,7 @@ export const commandHelp: CommandHelp[] = [ {name: 'ascii', args: '', description: 'ASCII-safe display'}, {name: 'motion', args: '', description: 'Reduce motion'}, {name: 'background', args: '', description: 'Transparent background'}, - {name: 'airplay', description: 'Open AirPlay settings'}, + {name: 'airplay', description: 'Open AirPlay settings (macOS only)'}, {name: 'learn', args: '', description: 'Learn a media key'}, {name: 'keys', args: '[reset]', description: 'Show or reset learned media keys'}, {name: 'favorite', description: 'Favorite the current station (alias :fav)'}, diff --git a/src/ui/page-footer.test.ts b/src/ui/page-footer.test.ts index c49b9e4..061ca4b 100644 --- a/src/ui/page-footer.test.ts +++ b/src/ui/page-footer.test.ts @@ -143,7 +143,7 @@ describe('page footer shortcuts', () => { playbackBackend: 'mpv', screen: 'search' }) - ).toBe('Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish'); + ).toBe('Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Ctrl+F favorite · Esc finish'); }); it('keeps AirPlay code entry hidden until AirPlay streaming is ready', () => { @@ -160,6 +160,20 @@ describe('page footer shortcuts', () => { ).toBe('↑/↓ choose · Enter select receiver · r refresh · b settings'); }); + it('does not advertise the AirPlay shortcut on unsupported operating systems', () => { + expect(pageFooterText({ + capturingTransportAction: null, + commandMode: false, + commandText: '', + editingCountryFilter: false, + editingSearch: false, + screen: 'settings', + airPlaySupported: false + })).toBe( + '↑/↓ choose · Enter open · o output · b Overview' + ); + }); + it('advertises the dedicated AirPlay receiver picker controls', () => { expect( pageFooterText({ diff --git a/src/ui/page-footer.ts b/src/ui/page-footer.ts index d41437c..6382fb4 100644 --- a/src/ui/page-footer.ts +++ b/src/ui/page-footer.ts @@ -11,6 +11,7 @@ type PageFooterInput = { editingCountryFilter: boolean; editingSearch: boolean; canEnterAirPlayCode?: boolean; + airPlaySupported?: boolean; playbackBackend?: string; screen: Screen; settingsPage?: SettingsPage; @@ -43,6 +44,7 @@ export function pageFooterText({ editingCountryFilter, editingSearch, canEnterAirPlayCode, + airPlaySupported = true, playbackBackend, screen, settingsPage = 'root' @@ -60,7 +62,7 @@ export function pageFooterText({ } if (screen === 'search' && editingSearch) { - return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish'; + return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Ctrl+F favorite · Esc finish'; } if (screen === 'search') { @@ -106,7 +108,7 @@ export function pageFooterText({ if (screen === 'settings') { return settingsPage === 'root' - ? '↑/↓ choose · Enter open · o output · a AirPlay · b Overview' + ? `↑/↓ choose · Enter open · o output${airPlaySupported ? ' · a AirPlay' : ''} · b Overview` : '↑/↓ choose · Enter change · b Settings · shortcuts still work'; } diff --git a/src/ui/portable-app.test.tsx b/src/ui/portable-app.test.tsx new file mode 100644 index 0000000..c1f1732 --- /dev/null +++ b/src/ui/portable-app.test.tsx @@ -0,0 +1,296 @@ +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {PassThrough, Writable} from 'node:stream'; +import {stripVTControlCharacters} from 'node:util'; +import {act} from 'react'; +import {render} from 'ink'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {PlayerController} from '../player/player-controller.js'; +import * as backendInstall from '../player/backend-install.js'; +import * as airplayDiscovery from '../player/airplay-discovery.js'; +import * as session from '../agent/session.js'; +import * as presence from '../alarms/tui-presence.js'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import type {PlaybackState, Station} from '../types.js'; +import {App} from './App.js'; +import type {AlarmTuiService} from './alarm-tui-service.js'; +import * as visualizers from './visualizers/receiver-visualizers.js'; + +const directories: string[] = []; +const instances: ReturnType[] = []; +const runtime = {capabilities: {supported: true, exactWake: false, catchUpAfterWake: true, message: 'ready'}, degradedAlarmIds: new Set(), message: 'Native scheduler ready.'}; +const service: AlarmTuiService = { + sync: async () => null, syncAll: async () => [], remove: async () => undefined, + runtimeStatus: async () => runtime, activeAlarms: async () => [], prepareTerminalAccess: async () => undefined, + verifySetup: async () => ({state: 'passed', startedAt: '2026-09-07T12:00:00.000Z', steps: []}) +}; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + for (const name of ['TERM', 'NO_COLOR', 'RADIOCLI_ASCII', 'RADIOCLI_UNICODE', 'RADIOCLI_SCREEN_READER', 'RADIOCLI_DISABLE_ANIMATION', 'RADIO_ATLAS_DISABLE_ANIMATION', 'RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH']) vi.stubEnv(name, undefined); + vi.stubEnv('INK_SCREEN_READER', 'false'); + vi.stubEnv('LC_ALL', 'en_US.UTF-8'); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '1'); + vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv']); + vi.spyOn(airplayDiscovery, 'discoverAirPlayDevices').mockResolvedValue([]); + vi.spyOn(ProviderManager.prototype, 'health').mockResolvedValue({}); + vi.spyOn(session, 'startRadioSession').mockResolvedValue({close: async () => undefined}); + vi.spyOn(presence, 'registerTuiPresence').mockReturnValue(() => undefined); + vi.spyOn(PlayerController.prototype, 'stop').mockResolvedValue(undefined); +}); +afterEach(() => { + act(() => { for (const instance of instances.splice(0)) { instance.unmount(); instance.cleanup(); } }); + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + for (const directory of directories.splice(0)) rmSync(directory, {recursive: true, force: true}); +}); + +describe('App terminal integration', () => { + it('tunes the selected retained result after its search query is cleared', async () => { + const result: Station = {id: 'search-result', provider: 'radio-browser', name: 'Search Result FM', tags: []}; + const search = vi.spyOn(ProviderManager.prototype, 'search').mockResolvedValue([result]); + vi.spyOn(ProviderManager.prototype, 'resolve').mockResolvedValue({url: 'https://example.test/search-result'}); + const play = vi.spyOn(PlayerController.prototype, 'play').mockResolvedValue(undefined); + const {input, lastFrame} = await openApp(false); + + await input('4'); + await input('jazz'); + await input('\r'); + expect(lastFrame()).toContain('Search Result FM'); + + for (let index = 0; index < 4; index += 1) await input('\u007f'); + expect(lastFrame()).toContain('Search Result FM'); + await input('\r'); + + expect(search).toHaveBeenCalledTimes(1); + expect(play).toHaveBeenCalledWith(result, 'https://example.test/search-result'); + }); + + it.each([ + ['Ctrl+F', '\u0006'], + ['legacy Meta+F', '\u001bf'], + ['Kitty Super+F', '\u001b[102;9u'] + ])('favorites a selected Search result with %s while the query remains editable', async (_label, shortcut) => { + const result: Station = {id: 'favorite-result', provider: 'radio-browser', name: 'Favorite Result FM', tags: []}; + vi.spyOn(ProviderManager.prototype, 'search').mockResolvedValue([result]); + vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(false); + const {store, input, lastFrame} = await openApp(false); + + await input('4'); + await input('jazz'); + await input('\r'); + await input('f'); + expect(store.isFavorite(result)).toBe(false); + expect(lastFrame()).toContain('jazzf'); + await input(shortcut); + + expect(store.isFavorite(result)).toBe(true); + expect(lastFrame()).toContain('Added to favorites: Favorite Result FM'); + expect(lastFrame()).toContain('jazzf'); + + await act(async () => { await vi.advanceTimersByTimeAsync(4499); }); + expect(lastFrame()).toContain('Added to favorites: Favorite Result FM'); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + expect(lastFrame()).not.toContain('Added to favorites: Favorite Result FM'); + + await input(shortcut); + expect(store.isFavorite(result)).toBe(false); + expect(lastFrame()).toContain('Removed from favorites: Favorite Result FM'); + expect(lastFrame()).toContain('jazzf'); + await act(async () => { await vi.advanceTimersByTimeAsync(4500); }); + expect(lastFrame()).not.toContain('Removed from favorites: Favorite Result FM'); + }); + + it('keeps plain f favoriting available in station lists and Now Playing', async () => { + vi.spyOn(ProviderManager.prototype, 'resolve').mockResolvedValue({url: 'https://example.test/portable'}); + vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(false); + vi.spyOn(PlayerController.prototype, 'play').mockResolvedValue(undefined); + const {store, input} = await openApp(false); + const portable = store.snapshot().favorites.find(item => item.id === 'portable')!; + + await input('2'); + await input('\r'); + await input('f'); + expect(store.isFavorite(portable)).toBe(false); + + await input('b'); + await input('1'); + await input('f'); + expect(store.isFavorite(portable)).toBe(true); + }); + + it('dismisses favorite confirmations after 4.5 seconds on every station view', async () => { + const explore: Station = {id: 'explore', provider: 'radio-browser', name: 'Explore FM', tags: []}; + const country: Station = {id: 'country', provider: 'radio-browser', name: 'Country FM', tags: []}; + vi.spyOn(ProviderManager.prototype, 'nearby').mockResolvedValue([explore]); + vi.spyOn(ProviderManager.prototype, 'countries').mockResolvedValue([{name: 'Canada', code: 'CA', stationCount: 1}]); + vi.spyOn(ProviderManager.prototype, 'byCountry').mockResolvedValue([country]); + vi.spyOn(ProviderManager.prototype, 'resolve').mockResolvedValue({url: 'https://example.test/portable'}); + vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(false); + vi.spyOn(PlayerController.prototype, 'play').mockResolvedValue(undefined); + const {input, lastFrame} = await openApp(false); + + await input('2'); + await input('\r'); + await input('f'); + await expectNoticeToExpire(lastFrame, 'Removed from favorites: Portable Radio'); + + await input('b'); + await input('f'); + await expectNoticeToExpire(lastFrame, 'Added to favorites: Portable Radio'); + + await input('2'); + await input('f'); + await expectNoticeToExpire(lastFrame, 'Removed from favorites: Portable Radio'); + + await input('b'); + await input('3'); + await input('f'); + await expectNoticeToExpire(lastFrame, 'Added to favorites: Explore FM'); + + await input('b'); + await input('5'); + await input('\r'); + await input('f'); + await expectNoticeToExpire(lastFrame, 'Added to favorites: Country FM'); + }); + + it('blocks AirPlay navigation with a clear macOS-only notice on unsupported systems', async () => { + const {input, lastFrame} = await openApp(false, false, 'linux'); + await input('9'); + expect(lastFrame()).not.toContain('a AirPlay'); + + await input('a'); + expect(lastFrame()).toContain('AirPlay output is available only on macOS'); + expect(lastFrame()).toContain('Settings'); + expect(lastFrame()).not.toContain('Choose where AirPlay playback should go'); + + await act(async () => { await vi.advanceTimersByTimeAsync(4500); }); + expect(lastFrame()).not.toContain('AirPlay output is available only on macOS'); + + await input('\r'); + await input('\u001B[B'); + expect(lastFrame()).toContain('AirPlay receiver (macOS only)'); + await input('\r'); + expect(lastFrame()).toContain('AirPlay output is available only on macOS'); + expect(lastFrame()).not.toContain('Choose where AirPlay playback should go'); + }); + + it('keeps AirPlay navigation available on macOS', async () => { + const {input, lastFrame} = await openApp(false, false, 'darwin'); + await input('9'); + expect(lastFrame()).toContain('a AirPlay'); + await input('a'); + expect(lastFrame()).toContain('Choose where AirPlay playback should go'); + }); + + it('keeps the TUI usable when its optional alarm presence directory is read-only', async () => { + vi.mocked(presence.registerTuiPresence).mockImplementation(() => {throw new Error('EACCES: private runtime directory');}); + const {input, lastFrame} = await openApp(false); + expect(lastFrame()).toContain('Alarm controls cannot register'); + await input('\u001B[B'); + await input('\r'); + expect(lastFrame()).toContain('Portable Radio'); + }); + + it('finishes unmount when optional alarm presence cleanup fails', async () => { + const warning = vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.mocked(presence.registerTuiPresence).mockReturnValue(() => {throw new Error('EACCES: private runtime directory');}); + await openApp(false); + expect(() => act(() => {const instance = instances.pop()!;instance.unmount();instance.cleanup();})).not.toThrow(); + expect(warning).toHaveBeenCalledWith(expect.stringContaining('could not remove its alarm-control presence marker')); + }); + + it.each(['RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH'])('skips automatic receiver discovery with %s while keeping library navigation', async variable => { + vi.stubEnv(variable, '1'); + const {input, lastFrame} = await openApp(false); + expect(airplayDiscovery.discoverAirPlayDevices).not.toHaveBeenCalled(); + await input('\u001B[B'); + await input('\r'); + expect(lastFrame()).toContain('Portable Radio'); + }); + + it('uses Ink accessibility state, keeps keyboard navigation, and leaves saved preferences intact', async () => { + setPlayback('playing'); + const visualizer = vi.spyOn(visualizers, 'buildVisualizer'); + const {store, input, frames, lastFrame} = await openApp(true); + const saved = store.snapshot().settings; + await input('\r'); + expect(lastFrame()).toContain('Now playing'); + expect(lastFrame()).toContain('Station: No station tuned'); + expect(visualizer).not.toHaveBeenCalled(); + const frameCount = frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + expect(frames).toHaveLength(frameCount); + await input('b'); + expect(lastFrame()).toContain('Live public radio from around the world'); + expect(lastFrame()).toContain('(selected)'); + expect(store.snapshot().settings).toEqual(saved); + expect(saved).toMatchObject({asciiMode: false, reduceMotion: false, transparentBackground: false}); + }); + + it.each([ + {env: {TERM: 'dumb'}, animated: false, redirected: false}, + {env: {RADIOCLI_SCREEN_READER: '1'}, animated: false, redirected: false}, + {env: {TERM: 'xterm-256color'}, animated: false, redirected: true}, + {env: {}, animated: true, redirected: false} + ])('keeps the loading spinner animated=$animated for $env with redirected=$redirected without changing settings', async ({env, animated, redirected}) => { + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value); + setPlayback('playing'); + vi.spyOn(ProviderManager.prototype, 'resolve').mockResolvedValue({url: 'https://example.test/portable'}); + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(() => new Promise(() => undefined)); + const {store, input, frames} = await openApp(false, redirected); + await input('\u001B[B'); + await input('\r'); + await input('\r'); + expect(play).toHaveBeenCalled(); + const frameCount = frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + if (animated) expect(frames.length).toBeGreaterThan(frameCount); + else expect(frames).toHaveLength(frameCount); + expect(store.snapshot().settings).toMatchObject({asciiMode: false, reduceMotion: false, transparentBackground: false}); + }); +}); + +function setPlayback(state: 'playing' | 'loading'): void { + const playback: PlaybackState = {backend: 'mpv', state, volume: 70, muted: false, ready: state === 'playing'}; + vi.spyOn(PlayerController.prototype, 'getState').mockReturnValue(playback); + vi.spyOn(PlayerController.prototype, 'onChange').mockImplementation(listener => { listener(playback); return () => undefined; }); +} + +async function openApp(screenReader: boolean, redirected = false, platform: NodeJS.Platform = process.platform) { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-portable-app-')); + directories.push(directory); + vi.stubEnv('RADIOCLI_HOME', directory); + const store = new JsonLibraryStore(join(directory, 'library.json')); + store.updateSettings({resumeOnLaunch: false, automaticUpdateChecks: false, asciiMode: false, reduceMotion: false, transparentBackground: false}); + store.toggleFavorite({id: 'portable', provider: 'radio-browser', name: 'Portable Radio', tags: [], streamUrl: 'https://example.test/portable'}); + const frames: string[] = []; + const stdout = Object.assign(new Writable({write(chunk, _encoding, done) { frames.push(String(chunk)); done(); }}), {columns: 100, rows: 30, ...(!redirected ? {isTTY: true, getColorDepth: () => 24} : {})}) as NodeJS.WriteStream; + const errors: string[] = []; + const stderr = new Writable({write(chunk, _encoding, done) { errors.push(String(chunk)); done(); }}) as NodeJS.WriteStream; + const stdin = Object.assign(new PassThrough(), {isTTY: true, setRawMode: () => undefined, ref: () => undefined, unref: () => undefined}) as unknown as NodeJS.ReadStream; + await act(async () => { + instances.push(render(, {stdout, stderr, stdin, debug: true, patchConsole: false, exitOnCtrlC: false, isScreenReaderEnabled: screenReader, kittyKeyboard: {mode: 'disabled'}})); + }); + expect(errors.join('')).toBe(''); + return { + store, frames, + lastFrame: () => stripVTControlCharacters(frames.slice().reverse().find(frame => frame.toLowerCase().includes('radiocli')) ?? ''), + input: async (data: string) => { await act(async () => { (stdin as unknown as PassThrough).write(data); }); } + }; +} + +async function expectNoticeToExpire(lastFrame: () => string, notice: string): Promise { + expect(lastFrame()).toContain(notice); + await act(async () => { await vi.advanceTimersByTimeAsync(4499); }); + expect(lastFrame()).toContain(notice); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + expect(lastFrame()).not.toContain(notice); +} diff --git a/src/ui/portable-render.test.tsx b/src/ui/portable-render.test.tsx new file mode 100644 index 0000000..46f9aa1 --- /dev/null +++ b/src/ui/portable-render.test.tsx @@ -0,0 +1,170 @@ +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {stripVTControlCharacters} from 'node:util'; +import {act, type ComponentProps} from 'react'; +import {Box, renderToString} from 'ink'; +import {cleanup, render} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {LibraryState, Screen, Station} from '../types.js'; +import {AppContent} from './AppContent.js'; +import {defaultExploreCursor} from './app-state.js'; +import {defaultAlarmDraft} from './alarm-editor.js'; +import {DisplayContext, resolveDisplayMode} from './display-context.js'; +import {computeTerminalLayout} from './layout.js'; +import * as visualizers from './visualizers/receiver-visualizers.js'; + +type ContentProps = ComponentProps; +const station: Station = {id: 'world', provider: 'radio-browser', name: '東京 FM / Café', country: 'Japan', countryCode: 'JP', city: '東京', tags: ['jazz'], codec: 'MP3', bitrate: 128}; +const library: LibraryState = { + favorites: [station], recent: [], imported: [], activity: {sessions: []}, trackHistory: [], searchHistory: [], alarms: [], + settings: {theme: 'green', receiverStyle: 'pulse-grid', volume: 70, enableRadioGarden: false, enableNearbyLocation: false, shareDirectoryVotes: true, preferredBackend: 'auto', tuneTimeoutSeconds: 12, skipBrokenStreams: true, mediaKeys: {previous: [], playPause: [], next: []}, resumeOnLaunch: false, asciiMode: false, reduceMotion: false, transparentBackground: false} +}; +const inkRequire = createRequire(import.meta.resolve('ink')); +const inkChalk = (await import(pathToFileURL(inkRequire.resolve('chalk')).href)).default as {level: number}; +const originalColorLevel = inkChalk.level; +const sizes = [{name: 'full', columns: 100, rows: 30}, {name: 'compact', columns: 50, rows: 16}, {name: 'micro', columns: 24, rows: 8}]; +const routes: Screen[] = ['home', 'now-playing', 'library', 'search', 'countries', 'explore', 'map', 'nearby', 'stations', 'stats', 'settings', 'help', 'airplay-code', 'airplay-settings', 'alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing']; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z')); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubEnv('INK_SCREEN_READER', 'false'); +}); +afterEach(() => { + act(() => cleanup()); + inkChalk.level = originalColorLevel; + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('portable terminal rendering', () => { + it.each(sizes)('preserves multilingual station and track data with ASCII decoration in $name', size => { + const props = contentProps('now-playing', size); + const display = resolveDisplayMode(library.settings, {LC_ALL: 'C', NO_COLOR: '1'}); + inkChalk.level = display.colorLevel; + const frame = staticFrame(, size.columns); + expect(frame).toContain('東京 FM / Café'); + if (size.name !== 'micro') expect(frame).toContain('音楽 / Café'); + expect(frame).not.toMatch(/[\u2500-\u259f\u2800-\u28ff]/u); + expect(frame).not.toContain('\u001B['); + }); + + it.each(sizes)('uses ASCII decoration across every route in $name', size => { + const display = resolveDisplayMode(library.settings, {TERM: 'dumb'}); + inkChalk.level = display.colorLevel; + for (const screen of routes) { + const frame = staticFrame(, size.columns); + expect(frame, screen).not.toMatch(/[\u2190-\u2193\u2500-\u259f\u2800-\u28ff·…›★☆∘◆“”]/u); + expect(frame, screen).not.toContain('\u001B['); + } + }); + + it.each(sizes)('hides receiver generation and renders readable screen-reader content in $name', async size => { + const visualizer = vi.spyOn(visualizers, 'buildVisualizer'); + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode(library.settings, {INK_SCREEN_READER: 'true'}); + const props = contentProps('now-playing', size); + let view!: ReturnType; + act(() => { view = render(); }); + const frame = stripVTControlCharacters(view.lastFrame() ?? ''); + expect(visualizer).not.toHaveBeenCalled(); + expect(frame).toContain('東京 FM / Café'); + expect(frame).toContain('音楽 / Café'); + expect(frame.toLowerCase()).toContain('playing'); + expect(frame).not.toMatch(/[\u2500-\u259f\u2800-\u28ff]/u); + const frames = view.frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(2400); }); + expect(view.frames).toHaveLength(frames); + }); + + it.each(['home', 'library', 'search', 'countries', 'explore', 'map', 'stats', 'settings', 'help', 'alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing'] as const)('retains the %s route in accessible output', screen => { + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode(library.settings, {INK_SCREEN_READER: 'true'}); + let view!: ReturnType; + act(() => { view = render(); }); + const frame = stripVTControlCharacters(view.lastFrame() ?? ''); + expect(frame.trim()).not.toBe(''); + expect(frame).not.toContain('Unknown screen.'); + if (screen === 'home') { + expect(frame).toContain('Playing'); + expect(frame).toContain('Settings'); + expect(frame).not.toContain('█'); + } + }); + + it.each([{name: 'Unicode', ascii: false}, {name: 'ASCII', ascii: true}])('summarizes the Stats heatmap without decorative rows in $name mode', ({ascii}) => { + vi.stubEnv('TZ', 'UTC'); + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '30'); + const props = contentProps('stats', sizes[0]!); + props.library = {...library, activity: {sessions: [ + {id: 'yesterday', station, startedAt: '2026-09-06T12:00:00.000Z', endedAt: '2026-09-06T13:00:00.000Z', listenedSeconds: 3600}, + {id: 'today', station, startedAt: '2026-09-07T09:00:00.000Z', endedAt: '2026-09-07T09:30:00.000Z', listenedSeconds: 1800} + ]}}; + const settings = {...library.settings, asciiMode: ascii}; + const visualDisplay = resolveDisplayMode(settings, {}); + const visual = staticFrame(, 100); + expect(stripVTControlCharacters(visual)).toMatch(/\bMon\b/u); + + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode(settings, {INK_SCREEN_READER: 'true'}); + let view!: ReturnType; + act(() => { view = render(); }); + const frame = stripVTControlCharacters(view.lastFrame() ?? ''); + expect(frame).toContain('Activity graph: 2 active days in 2026'); + expect(frame).toMatch(/Total hours listened:\s*1\.5h/u); + expect(frame).toMatch(/Sessions:\s*2/u); + expect(frame).toMatch(/Active days:\s*2\/371/u); + expect(frame).not.toMatch(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec|Mon|Wed|Fri|Less|More)\b/u); + }); + + it.each([{TERM: 'dumb'}, {RADIOCLI_SCREEN_READER: '1'}])('stops compact station marquees with %j', async env => { + const props = contentProps('library', sizes[2]!); + props.displayStations = [{...station, name: `${station.name} — A long international station name that would scroll`}]; + const display = resolveDisplayMode(library.settings, env); + let view!: ReturnType; + act(() => { view = render(); }); + const frames = view.frames.length; + await act(async () => { await vi.advanceTimersByTimeAsync(2400); }); + expect(view.frames).toHaveLength(frames); + }); + + it.each([{name: '16-color', env: {TERM: 'xterm'}, maximumLevel: 1}, {name: '256-color', env: {TERM: 'xterm-256color'}, maximumLevel: 2}])('keeps actual Ink colors within the $name terminal palette', ({env, maximumLevel}) => { + const display = resolveDisplayMode(library.settings, env); + inkChalk.level = display.colorLevel; + const frame = staticFrame(, 100); + expect(frame).toContain('\u001B['); + expect(frame).not.toMatch(/\u001B\[(?:38|48);2;/u); + if (maximumLevel === 1) expect(frame).not.toMatch(/\u001B\[(?:38|48);5;/u); + expect(stripVTControlCharacters(frame)).toContain('東京 FM / Café'); + }); +}); + +function staticFrame(tree: Parameters[0], columns: number): string { + let frame = ''; + act(() => { frame = renderToString(tree, {columns}); }); + return frame; +} + +function contentProps(screen: Screen, size: {columns: number; rows: number}): ContentProps { + const layout = computeTerminalLayout(size.columns, size.rows); + return { + airPlayDevices: [], airPlayCode: '', appVersion: '0.2.3', backends: ['mpv'], countryFilter: '', + diagnostics: {backend: 'mpv', availableBackends: ['mpv'], preferredBackend: 'auto', active: true, volume: 70, muted: false, ready: true}, + displayStations: [station], editingCountryFilter: false, editingSearch: true, + exploreCursor: defaultExploreCursor, favoriteKeys: new Set(['radio-browser:world']), filterLabel: 'none', + filteredCountries: [{name: 'Japan', code: 'JP', stationCount: 456}], frameWidth: layout.frameWidth, layout, library, + loadingCountries: false, loadingStations: false, nowPlaying: {title: '音楽 / Café', updatedAt: '2026-09-07T12:00:00.000Z'}, + playback: {backend: 'mpv', state: 'playing', volume: 70, muted: false, ready: true}, playingStation: station, providerHealth: {}, + searchQuery: '東京', screen, settingsPage: 'root', selected: 0, showDiagnostics: false, sleepLabel: 'Sleep off', + stationContext: {title: 'Library', subtitle: 'Saved stations', stations: [station]}, stationFavorite: true, stationTime: '12:00', storePath: '/fixture/radiocli.json', theme: 'green', + alarmTui: { + draft: defaultAlarmDraft(station), editorField: 'label', editingField: false, editorControl: null, timeSegment: 'hour', weekdayIndex: 0, + validationError: null, saving: false, pickerChoices: [], pickerFallback: false, runtime: null, verification: null, activeAlarms: [], activeSelected: 0, + snoozeMinutes: 10, busyAlarmIds: new Set(), itemCount: () => undefined, handleInput: () => false, openForStation: () => undefined, openActive: () => undefined + } + }; +} diff --git a/src/ui/screens/AirPlayCodeScreen.tsx b/src/ui/screens/AirPlayCodeScreen.tsx index dca3b5c..09af02c 100644 --- a/src/ui/screens/AirPlayCodeScreen.tsx +++ b/src/ui/screens/AirPlayCodeScreen.tsx @@ -3,6 +3,8 @@ import {Box, Text} from 'ink'; import type {AirPlayDevice, PlaybackState, ThemeName} from '../../types.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; import {textDim, textMuted, themeAccent} from '../theme.js'; type AirPlayCodeScreenProps = { @@ -20,6 +22,8 @@ export function AirPlayCodeScreen({ theme, width }: AirPlayCodeScreenProps): React.ReactElement { + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const lineWidth = Math.max(24, width - 4); const promptActive = isAirPlayCodePromptActive(playback); @@ -38,7 +42,7 @@ export function AirPlayCodeScreen({ - Receiver: {truncate(receiverName, Math.max(8, lineWidth - 10))} + Receiver: {a(truncate(receiverName, Math.max(8, lineWidth - 10)))} Playback: {playback.backend} / {playback.state} @@ -55,7 +59,7 @@ export function AirPlayCodeScreen({ Code {code ? masked : 'type the code from the receiver'} - Enter submits · Backspace edits · Esc returns to AirPlay + {a('Enter submits · Backspace edits · Esc returns to AirPlay')} ); diff --git a/src/ui/screens/AirPlaySettingsScreen.tsx b/src/ui/screens/AirPlaySettingsScreen.tsx index fb242d7..13bacf3 100644 --- a/src/ui/screens/AirPlaySettingsScreen.tsx +++ b/src/ui/screens/AirPlaySettingsScreen.tsx @@ -12,6 +12,8 @@ import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; import {textDim, textMuted, themeAccent} from '../theme.js'; import {visibleWindow} from '../list-window.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type AirPlaySettingsScreenProps = { selected: number; @@ -32,6 +34,8 @@ export function AirPlaySettingsScreen({ width, height }: AirPlaySettingsScreenProps): React.ReactElement { + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const availability = airPlayAvailability(backends, devices); const selectedDevice = selectedAirPlayDevice(settings, devices); @@ -53,13 +57,13 @@ export function AirPlaySettingsScreen({ - Audio output: {preferredOutput} · Streaming:{' '} - {availability.ready ? 'ready' : 'unavailable'} · Receivers:{' '} + Audio output: {preferredOutput}{a(' · Streaming: ')} + {availability.ready ? 'ready' : 'unavailable'}{a(' · Receivers: ')} {devices.length} - {truncate(availability.detail, lineWidth)} + {a(truncate(availability.detail, lineWidth))} - Selected: {truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10))} + Selected: {a(truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)))} {availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? ( @@ -68,7 +72,7 @@ export function AirPlaySettingsScreen({ ) : null} {selectedMissing ? ( - Saved receiver is not visible: {truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))} + Saved receiver is not visible: {a(truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31)))} ) : null} @@ -91,10 +95,10 @@ export function AirPlaySettingsScreen({ - {name} + {a(name)} {isSelected ? {selectedLabel} : null} - · {detail} + {a(` · ${detail}`)} ); }} @@ -103,7 +107,7 @@ export function AirPlaySettingsScreen({ No receivers discovered. )} - {canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers'} + {a(canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers')} diff --git a/src/ui/screens/ExploreScreen.tsx b/src/ui/screens/ExploreScreen.tsx index af83256..1401ccd 100644 --- a/src/ui/screens/ExploreScreen.tsx +++ b/src/ui/screens/ExploreScreen.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {Box, Text} from 'ink'; import type {Station, ThemeName} from '../../types.js'; import type {ExploreCursor} from '../app-state.js'; +import {formatExploreCursor} from '../app-state.js'; import {StationList} from '../components/StationList.js'; import {buildCosmoWorldMap, type CosmoMapCellKind, type CosmoMapRow} from '../cosmo-world-map.js'; import {computeExploreMapLayout} from '../explore-map-layout.js'; @@ -71,6 +72,7 @@ export function ExploreScreen({ width={mapPanelWidth} height={split ? bodyRows : mapRows + 2} flexDirection="column" + aria-label={`World map cursor: ${formatExploreCursor(cursor)}`} > {map.map((row, index) => ( diff --git a/src/ui/screens/HelpScreen.tsx b/src/ui/screens/HelpScreen.tsx index b09650b..7a8c679 100644 --- a/src/ui/screens/HelpScreen.tsx +++ b/src/ui/screens/HelpScreen.tsx @@ -6,6 +6,8 @@ import {ScreenHeader} from '../components/ScreenHeader.js'; import {commandHelp, keyHelpSections} from '../help-content.js'; import {padDisplayEnd, truncate} from '../format.js'; import {visibleWindow} from '../list-window.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type HelpScreenProps = { theme: ThemeName; @@ -17,6 +19,8 @@ type HelpScreenProps = { type HelpRow = {key: string; heading?: string; keys?: string; description?: string}; export function HelpScreen({theme, width, height, selected}: HelpScreenProps): React.ReactElement { + const {ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const keyColumnWidth = 16; const lineWidth = Math.max(28, width - 2); @@ -47,12 +51,12 @@ export function HelpScreen({theme, width, height, selected}: HelpScreenProps): R {row.heading} ) : ( - {padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth)} - {truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth))} + {a(padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth))} + {a(truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth)))} ))} - Showing {window.start + 1}-{window.end} of {rows.length} · ↑/↓ scroll + {a(`Showing ${window.start + 1}-${window.end} of ${rows.length} · ↑/↓ scroll`)} ); } diff --git a/src/ui/screens/HomeScreen.tsx b/src/ui/screens/HomeScreen.tsx index a8d52df..ea2313b 100644 --- a/src/ui/screens/HomeScreen.tsx +++ b/src/ui/screens/HomeScreen.tsx @@ -6,6 +6,8 @@ import {textMuted, themeAccent} from '../theme.js'; import {homeItems} from '../screen-items.js'; import type {LibraryState, ThemeName} from '../../types.js'; import {displayWidth} from '../format.js'; +import {useDisplay} from '../display-context.js'; +import {toAsciiSafe} from '../ascii.js'; type HomeProps = { selected: number; @@ -14,6 +16,8 @@ type HomeProps = { }; export function HomeScreen({selected, theme, library}: HomeProps): React.ReactElement { + const {ascii} = useDisplay(); + const summary = `${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`; const titleWidth = Math.max(...homeItems.map(item => displayWidth(item.label))) + 2; return ( @@ -39,7 +43,7 @@ export function HomeScreen({selected, theme, library}: HomeProps): React.ReactEl - {library.recent.length} recent · {library.favorites.length} favorites · {library.imported.length} imported + {ascii ? toAsciiSafe(summary) : summary} diff --git a/src/ui/screens/MapScreen.tsx b/src/ui/screens/MapScreen.tsx index 694e9ed..bd1b964 100644 --- a/src/ui/screens/MapScreen.tsx +++ b/src/ui/screens/MapScreen.tsx @@ -53,7 +53,7 @@ export function MapScreen({ right={filter ? `filter: ${filter}` : undefined} /> {loading ? {asciify('Loading country density…')} : null} - + {graph.rows.map((row, index) => ( ))} diff --git a/src/ui/screens/NowPlayingScreen.tsx b/src/ui/screens/NowPlayingScreen.tsx index 9b4610a..822aed0 100644 --- a/src/ui/screens/NowPlayingScreen.tsx +++ b/src/ui/screens/NowPlayingScreen.tsx @@ -53,7 +53,7 @@ export function NowPlayingScreen({ height }: NowPlayingProps): React.ReactElement { const pulse = useReceiverPulse(); - const {panel: panelBackground, ascii} = useDisplay(); + const {panel: panelBackground, ascii, screenReader} = useDisplay(); // In ASCII mode route every rendered string through the glyph mapper so no // braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal. const a = (value: string): string => (ascii ? toAsciiSafe(value) : value); @@ -71,7 +71,7 @@ export function NowPlayingScreen({ panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth ); - const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme); + const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme); const receiverState = playback.state.toUpperCase(); const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2); const stationIdentity = receiverStationIdentity(station, identityWidth); @@ -105,13 +105,13 @@ export function NowPlayingScreen({ height={panelHeight} > - + {a(stationIdentity.name)} {stationIdentity.location ? {a(` · ${stationIdentity.location}`)} : null} - {receiverState} + {receiverState} - + {!screenReader ? {renderRows.map((row, index) => ( {row.segments @@ -119,10 +119,10 @@ export function NowPlayingScreen({ : row.text} ))} - + : null} - {a(metadataLine)} - {a(favoriteText)} + {a(metadataLine)} + {a(favoriteText)} {showDiagnostics ? ( diff --git a/src/ui/screens/SearchScreen.tsx b/src/ui/screens/SearchScreen.tsx index df0e391..0529314 100644 --- a/src/ui/screens/SearchScreen.tsx +++ b/src/ui/screens/SearchScreen.tsx @@ -6,6 +6,7 @@ import {ScreenHeader} from '../components/ScreenHeader.js'; import {panelBorder, textMuted, themeAccent} from '../theme.js'; import {panelBorderStyle, useDisplay} from '../display-context.js'; import {truncate} from '../format.js'; +import {toAsciiSafe} from '../ascii.js'; type SearchScreenProps = { query: string; @@ -35,6 +36,7 @@ export function SearchScreen({ width }: SearchScreenProps): React.ReactElement { const {panel: panelBackground, ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const contentWidth = Math.max(40, width); const inputWidth = Math.max(34, Math.min(contentWidth, 96)); const inputTextWidth = Math.max(8, inputWidth - 8); @@ -59,10 +61,10 @@ export function SearchScreen({ marginBottom={1} flexShrink={0} > - {editing ? '› ' : ' '} - {truncate(query || 'Search stations, genres, languages, places…', inputTextWidth)} + {a(editing ? '› ' : ' ')} + {a(truncate(query || 'Search stations, genres, languages, places…', inputTextWidth))} - {loading ? Searching station directories… : null} + {loading ? {a('Searching station directories…')} : null} {!loading || stations.length > 0 ? ( ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const lineWidth = Math.max(32, width - 4); const group = settingsGroup(page); @@ -80,7 +86,7 @@ export function SettingsScreen({ selected={menuWindow.selectedOffset} keyFor={item => item} render={(item, _index, active) => { - const label = settingLabel(item, updateCheck, appVersion); + const label = settingLabel(item, updateCheck, appVersion, airPlaySupported); const value = page === 'root' ? settingsRootValue(item) : settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion); @@ -89,12 +95,12 @@ export function SettingsScreen({ - {truncate(label, labelWidth)} + {a(truncate(label, labelWidth))} - {truncate(value ?? '', valueWidth)} + {a(truncate(value ?? '', valueWidth))} ); @@ -103,7 +109,7 @@ export function SettingsScreen({ Selected - {truncate(detail, lineWidth)} + {a(truncate(detail, lineWidth))} ); @@ -199,7 +205,10 @@ export function settingValue( } } -export function settingLabel(item: string, updateCheck: UpdateCheckState | undefined, currentVersion?: string): string { +export function settingLabel(item: string, updateCheck: UpdateCheckState | undefined, currentVersion?: string, airPlaySupported = true): string { + if (item === 'AirPlay receiver' && !airPlaySupported) { + return 'AirPlay receiver (macOS only)'; + } if (item === 'Check for updates') { return updateStatusText(updateCheck, currentVersion).endsWith('available') ? 'Install update' : 'Check now'; } diff --git a/src/ui/screens/StatsScreen.tsx b/src/ui/screens/StatsScreen.tsx index f3413d9..0d29de5 100644 --- a/src/ui/screens/StatsScreen.tsx +++ b/src/ui/screens/StatsScreen.tsx @@ -6,6 +6,7 @@ import {panelBorder, textMuted, themeAccent, themeContributionColors} from '../t import {panelBorderStyle, useDisplay} from '../display-context.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; +import {toAsciiSafe} from '../ascii.js'; type StatsScreenProps = { library: LibraryState; @@ -40,6 +41,7 @@ type ContributionGraph = { export function StatsScreen({library, theme, width, height}: StatsScreenProps): React.ReactElement { const {panel: panelBackground, ascii} = useDisplay(); + const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const stats = computeListeningStats(library.activity.sessions); const contentWidth = Math.max(20, width - 4); const graph = buildContributionGraph(stats.days, contentWidth, height); @@ -70,10 +72,10 @@ export function StatsScreen({library, theme, width, height}: StatsScreenProps): > - Activity — {graph.year} + {a(`Activity — ${graph.year}`)} - + {' '.repeat(dayLabelWidth)}{graph.months} {graph.rows.map(row => ( @@ -101,13 +103,13 @@ export function StatsScreen({library, theme, width, height}: StatsScreenProps): Summary - {metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme)} + {metricPair('Favorite station', a(truncate(favorite, favoriteWidth)), 'Total hours listened', formatHours(totalHours), metricWidth, theme)} {metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme)} {metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme)} {metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Stations counted after', '2 min', metricWidth, theme)} - - Less · + + {a('Less · ')} {graphColors.map(color => ( {renderLegendCell(color, graph.cellText, graph.cellGap)} diff --git a/src/ui/screens/screen-render.test.tsx b/src/ui/screens/screen-render.test.tsx index 150b774..3432f49 100644 --- a/src/ui/screens/screen-render.test.tsx +++ b/src/ui/screens/screen-render.test.tsx @@ -1,6 +1,6 @@ import {act} from 'react'; -import {render} from 'ink-testing-library'; -import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render as inkRender} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import type {AppSettings, LibraryState, PlaybackDiagnostics, PlaybackState, Station, TrackPlay} from '../../types.js'; import {DisplayContext, resolveDisplayMode} from '../display-context.js'; import {HelpScreen} from './HelpScreen.js'; @@ -16,6 +16,26 @@ import {defaultExploreCursor} from '../app-state.js'; import {StationList} from '../components/StationList.js'; import {displayWidth} from '../format.js'; +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + // Ink's fixture supplies 100 columns but no rows. Keep its 24-row fallback + // deterministic without asking the host terminal through commands like tput. + vi.stubEnv('COLUMNS', '100'); + vi.stubEnv('LINES', '24'); +}); +afterEach(() => { + act(() => cleanup()); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function render(tree: Parameters[0]): ReturnType { + let view!: ReturnType; + act(() => { view = inkRender(tree); }); + return view; +} + const station: Station = { id: 'station-1', provider: 'radio-browser', @@ -100,8 +120,6 @@ const library: LibraryState = { }; describe('HomeScreen rendering', () => { - afterEach(() => vi.useRealTimers()); - it('does not repeat playback status already shown in the app header', () => { const frame = render().lastFrame() ?? ''; @@ -120,8 +138,6 @@ describe('HomeScreen rendering', () => { }); describe('StationList rendering', () => { - afterEach(() => vi.useRealTimers()); - it('replaces standard metadata with selected-station tags on the same row', () => { const frame = render( { expect(frame).not.toContain('ASCII-safe display'); }); + it('labels AirPlay as macOS-only on unsupported operating systems', () => { + const {lastFrame} = render( + + ); + + expect(lastFrame()).toContain('AirPlay receiver (macOS only)'); + }); + it('changes the update settings row when an update is available', () => { const settingsIndex = Math.max(0, settingsGroups.find(group => group.id === 'updates')?.items.indexOf('Check for updates') ?? 0); const {lastFrame} = render( @@ -411,8 +449,6 @@ describe('Explore world map rendering', () => { }); describe('CountriesScreen rendering', () => { - afterEach(() => vi.useRealTimers()); - it('marquees a focused long name while keeping the row to one terminal line', async () => { vi.useFakeTimers(); const view = render( diff --git a/src/ui/system-actions.test.ts b/src/ui/system-actions.test.ts index 66a013f..fcba2de 100644 --- a/src/ui/system-actions.test.ts +++ b/src/ui/system-actions.test.ts @@ -1,16 +1,116 @@ -import {describe, expect, it} from 'vitest'; -import {clipboardCommands, openExternalCommand} from './system-actions.js'; +import {EventEmitter} from 'node:events'; +import {PassThrough} from 'node:stream'; +import {once} from 'node:events'; +import type {ChildProcess} from 'node:child_process'; +import {describe, expect, it, vi} from 'vitest'; +import {copyToClipboard, openExternal} from './system-actions.js'; + +vi.mock('node:child_process', () => ({spawn: vi.fn(() => { throw new Error('Desktop tests must inject their launcher.'); })})); describe('system actions', () => { - it('opens URLs with the platform default handler', () => { - expect(openExternalCommand('darwin')).toEqual({command: 'open', args: []}); - expect(openExternalCommand('win32')).toEqual({command: 'explorer', args: []}); - expect(openExternalCommand('linux')).toEqual({command: 'xdg-open', args: []}); + it('does not report an asynchronous browser launch failure as success', async () => { + const launch = fakeLauncher('error'); + await expect(openExternal('https://example.test/?x=1&y=2', 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); + }); + + it('passes a validated URL as one literal argument and waits for completion', async () => { + const launch = fakeLauncher(0); + const url = 'https://example.test/?x=1&y=%22'; + await expect(openExternal(url, 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(true); + expect(launch.mock.calls[0]?.slice(0, 2)).toEqual(['explorer', [url]]); + await expect(openExternal('file:///etc/passwd', 'win32', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); + expect(launch).toHaveBeenCalledOnce(); + }); + + it('falls through to another clipboard tool after a nonzero exit', async () => { + const launch = fakeLauncher(1, 0); + const text = 'Zoë 日本 & $(literal)'; + await expect(copyToClipboard(text, 'linux', {spawn: launch, env: {DISPLAY: ':0'}, resolve: name => name})).resolves.toBe(true); + expect(launch.mock.calls.map(call => call[0])).toEqual(['wl-copy', 'xclip']); + expect(launch.inputs).toEqual([text, text]); + }); + + it('reports missing tools and headless sessions without launching', async () => { + const launch = fakeLauncher(0); + await expect(copyToClipboard('text', 'linux', {spawn: launch, env: {}, resolve: name => name})).resolves.toBe(false); + await expect(openExternal('https://example.test', 'darwin', {spawn: launch, env: {SSH_CONNECTION: 'test'}, resolve: name => name})).resolves.toBe(false); + await expect(copyToClipboard('text', 'darwin', {spawn: launch, env: {}, resolve: () => null})).resolves.toBe(false); + expect(launch).not.toHaveBeenCalled(); }); - it('lists platform clipboard tools in priority order', () => { - expect(clipboardCommands('darwin').map(entry => entry.command)).toEqual(['pbcopy']); - expect(clipboardCommands('win32').map(entry => entry.command)).toEqual(['clip']); - expect(clipboardCommands('linux').map(entry => entry.command)).toEqual(['wl-copy', 'xclip', 'xsel']); + it('handles clipboard stdin failure and bounds a hung helper', async () => { + const broken = fakeLauncher('pipe-error'); + await expect(copyToClipboard('text', 'darwin', {spawn: broken, env: {}, resolve: name => name})).resolves.toBe(false); + const hung = fakeLauncher('hang'); + await expect(copyToClipboard('text', 'darwin', {spawn: hung, env: {}, resolve: name => name, timeoutMs: 10})).resolves.toBe(false); + expect(hung.kills.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]); + }); + + it('terminates an actual helper that ignores graceful shutdown before reporting timeout', async () => { + const {spawn} = await vi.importActual('node:child_process'); + let child: ChildProcess | undefined; + let closed = false; + try { + child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});process.stdin.resume();setInterval(()=>{},1000);process.send('ready');"], {stdio: ['pipe', 'ignore', 'ignore', 'ipc']}); + await once(child, 'message'); + child.once('close', () => { closed = true; }); + const result = await copyToClipboard('literal text', 'darwin', { + env: {}, resolve: () => process.execPath, timeoutMs: 50, + spawn: () => child! + }); + expect(result).toBe(false); + expect(closed).toBe(true); + expect(child!.exitCode !== null || child!.signalCode !== null).toBe(true); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close'); + child.kill('SIGKILL'); + await closed; + } + } + }); + + it('accepts a long-lived browser opener without terminating its application', async () => { + const {spawn} = await vi.importActual('node:child_process'); + let child: ChildProcess | undefined; + try { + const result = await openExternal('https://example.test/?x=1&y=%22', 'linux', { + env: {DISPLAY: ':0'}, resolve: () => process.execPath, timeoutMs: 500, + spawn: (_command, _args, options) => { + child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});setInterval(()=>{},1000);"], options); + return child; + } + }); + expect(result).toBe(true); + expect(child!.exitCode).toBeNull(); + expect(child!.signalCode).toBeNull(); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close'); + child.kill('SIGKILL'); + await closed; + } + } }); }); + +function fakeLauncher(...results: Array) { + const inputs: string[] = []; + const kills = vi.fn(); + const launch = vi.fn((_command: string, _args: readonly string[], _options?: unknown) => { + const child = new EventEmitter() as ChildProcess; + child.stdin = new PassThrough(); + child.kill = kills; + child.unref = vi.fn(); + const index = inputs.push('') - 1; + child.stdin.on('data', chunk => { inputs[index] += String(chunk); }); + const result = results.shift() ?? 1; + queueMicrotask(() => { + if (result === 'error') child.emit('error', new Error('Command unavailable')); + else if (result === 'pipe-error') child.stdin?.emit('error', new Error('EPIPE')); + else if (result !== 'hang') child.emit('close', result); + }); + return child; + }); + return Object.assign(launch, {inputs, kills}); +} diff --git a/src/ui/system-actions.ts b/src/ui/system-actions.ts index a0fcc09..5f8339c 100644 --- a/src/ui/system-actions.ts +++ b/src/ui/system-actions.ts @@ -1,79 +1,86 @@ -import {spawn} from 'node:child_process'; -import {resolveCommand} from '../player/command.js'; +import {spawn, type ChildProcess, type SpawnOptions} from 'node:child_process'; +import {resolveCommand} from '../platform/executables.js'; import {safeExternalHttpUrl} from '../safety.js'; +import {browserCommands, clipboardCandidates, hasGraphicalSession} from '../platform/desktop.js'; +import {identifyPlatform} from '../platform/runtime.js'; +import {waitForLaunch} from '../platform/launch-command.js'; -type SystemCommand = { - command: string; - args: string[]; +type ActionDeps = { + env?: NodeJS.ProcessEnv; + resolve?: (command: string) => string | null; + spawn?: (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; + timeoutMs?: number; }; -// The command used to open a URL in the platform's default handler. -export function openExternalCommand(platform: NodeJS.Platform = process.platform): SystemCommand { - if (platform === 'darwin') { - return {command: 'open', args: []}; - } - - if (platform === 'win32') { - // Direct invocation avoids cmd.exe interpreting URL query characters. - return {command: 'explorer', args: []}; - } - - return {command: 'xdg-open', args: []}; -} - -// Candidate clipboard tools per platform, in priority order. pbcopy/clip ship -// with macOS/Windows; Linux/BSD rely on whichever of these is installed. -export function clipboardCommands(platform: NodeJS.Platform = process.platform): SystemCommand[] { - if (platform === 'darwin') { - return [{command: 'pbcopy', args: []}]; - } - - if (platform === 'win32') { - return [{command: 'clip', args: []}]; - } - - return [ - {command: 'wl-copy', args: []}, - {command: 'xclip', args: ['-selection', 'clipboard']}, - {command: 'xsel', args: ['--clipboard', '--input']} - ]; -} - -export function openExternal(url: string, platform: NodeJS.Platform = process.platform): boolean { +export async function openExternal(url: string, platform: NodeJS.Platform = process.platform, deps: ActionDeps = {}): Promise { const safeUrl = safeExternalHttpUrl(url); - if (!safeUrl) { - return false; + const env = deps.env ?? process.env; + const host = identifyPlatform({platform, env}); + if (!safeUrl || !hasGraphicalSession(host, env)) return false; + for (const {command, args} of browserCommands(host)) { + const executable = (deps.resolve ?? resolveCommand)(command); + if (!executable) continue; + try { + // xdg-open may run for the browser's entire lifetime. Observe startup + // failure, then let the desktop application own its detached process. + const child = (deps.spawn ?? spawn)(executable, [...args, safeUrl], {stdio: 'ignore', env, detached: true, windowsHide: true}); + await waitForLaunch(child); + return true; + } catch { /* Try the next available opener after a launch failure. */ } } + return false; +} - const {command, args} = openExternalCommand(platform); - try { - const child = spawn(resolveCommand(command) ?? command, [...args, safeUrl], {stdio: 'ignore', detached: true}); - child.on('error', () => undefined); - child.unref(); - return true; - } catch { - // Opening a browser is best-effort; never crash the TUI over it. - return false; +export async function copyToClipboard(text: string, platform: NodeJS.Platform = process.platform, deps: ActionDeps = {}): Promise { + const env = deps.env ?? process.env; + const host = identifyPlatform({platform, env}); + if (!hasGraphicalSession(host, env)) return false; + for (const {command, args} of clipboardCandidates(host)) { + const executable = (deps.resolve ?? resolveCommand)(command); + if (executable && await runHelper(executable, args, text, deps)) return true; } + return false; } -export function copyToClipboard(text: string, platform: NodeJS.Platform = process.platform): boolean { - for (const {command, args} of clipboardCommands(platform)) { - const resolved = resolveCommand(command); - if (!resolved) { - continue; - } - +function runHelper(command: string, args: string[], input: string, deps: ActionDeps): Promise { + return new Promise(resolve => { + let child: ChildProcess | undefined; + let settled = false; + let stopping = false; + let forceTimer: NodeJS.Timeout | undefined; + let cleanupTimer: NodeJS.Timeout | undefined; + const signal = (value: NodeJS.Signals) => { + try { child?.kill(value); } catch { /* The failed action remains false; cleanup is still bounded. */ } + }; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(forceTimer); + clearTimeout(cleanupTimer); + resolve(ok); + }; + const stop = () => { + if (settled || stopping) return; + stopping = true; + forceTimer = setTimeout(() => signal('SIGKILL'), 250); + cleanupTimer = setTimeout(() => { + // Even an uninterruptible OS process must not keep RadioCLI alive. + child?.unref(); + finish(false); + }, 1_000); + child?.stdin?.destroy(); + signal('SIGTERM'); + }; + const timer = setTimeout(stop, deps.timeoutMs ?? 5000); try { - const child = spawn(resolved, args, {stdio: ['pipe', 'ignore', 'ignore']}); - child.on('error', () => undefined); - child.stdin.write(text); - child.stdin.end(); - return true; + child = (deps.spawn ?? spawn)(command, args, {stdio: ['pipe', 'ignore', 'ignore'], env: deps.env ?? process.env, windowsHide: true}); + child.on('error', () => { if (child?.pid) stop(); else finish(false); }); + child.once('close', code => finish(!stopping && code === 0)); + child.stdin?.on('error', stop); + child.stdin?.end(input); } catch { - continue; + if (child?.pid) stop(); else finish(false); } - } - - return false; + }); } diff --git a/src/ui/terminal-renderer.ts b/src/ui/terminal-renderer.ts new file mode 100644 index 0000000..3a5ac7c --- /dev/null +++ b/src/ui/terminal-renderer.ts @@ -0,0 +1,17 @@ +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {resolveTerminalCapabilities, type TerminalCapabilities, type TerminalEvidence} from '../platform/terminal.js'; + +/** + * Ink uses Chalk's shared instance for both text and panel fills. FORCE_COLOR + * is a minimum in Chalk, so setting it cannot cap a Windows/CI/truecolor host + * at 16 or 256 colors. Set the instance's level before the first Ink render. + * Resolve from Ink itself so nested dependency installations work as well. + */ +export async function configureTerminalRenderer(env: NodeJS.ProcessEnv = process.env, evidence: TerminalEvidence = {}): Promise { + const terminal = resolveTerminalCapabilities(env, evidence); + const inkRequire = createRequire(import.meta.resolve('ink')); + const {default: chalk} = await import(pathToFileURL(inkRequire.resolve('chalk')).href) as {default: {level: number}}; + chalk.level = terminal.colorLevel; + return terminal; +} diff --git a/src/ui/use-alarm-tui.ts b/src/ui/use-alarm-tui.ts index 27beb48..155b080 100644 --- a/src/ui/use-alarm-tui.ts +++ b/src/ui/use-alarm-tui.ts @@ -175,6 +175,39 @@ export function useAlarmTui(params: Params): AlarmTuiController { await refreshRuntime(); }, [refreshRuntime, service, setMessage]); + const deleteAlarm = useCallback(async (alarm: Alarm) => { + let phase: 'disable' | 'native' | 'definition' = 'disable'; + let message: string; + try { + // Persist the safe state before touching native jobs. A failed cleanup + // can then be retried without another write in the error handler. + const disabled = store.toggleAlarm(alarm.id, false); + const disabledSnapshot = store.snapshot(); + alarmStateRef.current = disabledSnapshot.alarms; + setLibrary(disabledSnapshot); + phase = 'native'; + await service.remove(disabled); + phase = 'definition'; + store.removeAlarm(alarm.id); + const snapshot = store.snapshot(); + alarmStateRef.current = snapshot.alarms; + setLibrary(snapshot); + setSelected(value => clamp(value, snapshot.alarms.length + 1)); + message = `Deleted ${alarm.label}.`; + } catch (error) { + message = phase === 'disable' + ? `Delete canceled: ${messageOf(error)}. Alarm and native jobs unchanged.` + : phase === 'native' + ? `Delete cleanup failed: ${messageOf(error)}. ${alarm.label} was kept disabled; press r to repair.` + : `Native cleanup completed. Save failed: ${messageOf(error)}. ${alarm.label} was kept disabled; retry deletion.`; + } finally { + busyAlarmIdsRef.current.delete(alarm.id); + setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); + } + await refreshRuntime(); + setMessage(message); + }, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]); + const saveDraft = useCallback(() => { if (!draft || savingRef.current) return; const error = validateAlarmDraft(draft); @@ -248,13 +281,8 @@ export function useAlarmTui(params: Params): AlarmTuiController { if(alarmIsActive){setMessage(`Dismiss or snooze ${alarm.label} before deleting it; current playback was left untouched.`);return true;} if (deletingIdRef.current !== alarm.id) { deletingIdRef.current = alarm.id; setDeletingId(alarm.id); setMessage(`Press x again to delete “${alarm.label}”.`); return true; } deletingIdRef.current = undefined; setDeletingId(undefined); busyAlarmIdsRef.current.add(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); - void service.remove(alarm).then(() => { - if (store.getAlarm(alarm.id)) store.removeAlarm(alarm.id); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); setSelected(value => clamp(value, snapshot.alarms.length + 1)); setMessage(`Deleted ${alarm.label}.`); - }).catch(error => { - const existing = store.getAlarm(alarm.id); - if (existing) { const disabled = store.toggleAlarm(alarm.id, false); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); setMessage(`Delete cleanup failed; ${disabled.label} was kept disabled so Repair can retry: ${messageOf(error)}`); } - else setMessage(`Delete cleanup failed after the local alarm disappeared: ${messageOf(error)}`); - }).finally(() => { busyAlarmIdsRef.current.delete(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); void refreshRuntime(); }); return true; + void deleteAlarm(alarm).catch(error => setMessage(`Unexpected alarm deletion failure: ${messageOf(error)}`)); + return true; } if (input !== 'x') { deletingIdRef.current = undefined; setDeletingId(undefined); } if (input === 't' && alarm) { void previewStation(alarm.station).then(() => setMessage(`Test-tuning ${alarm.station.name}; no alarm state changed.`)).catch(error => setMessage(`Test tune failed: ${messageOf(error)}`)); return true; } @@ -361,7 +389,7 @@ export function useAlarmTui(params: Params): AlarmTuiController { return true; } return false; - }, [activeAlarms, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]); + }, [activeAlarms, deleteAlarm, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]); const openActive = useCallback(() => { if (!activeAlarms.length) { setMessage('No alarm is currently playing.'); return; } diff --git a/src/ui/use-app-input.ts b/src/ui/use-app-input.ts index 77b05b5..114e5d6 100644 --- a/src/ui/use-app-input.ts +++ b/src/ui/use-app-input.ts @@ -1,5 +1,5 @@ -import {useEffect} from 'react'; -import {useInput} from 'ink'; +import {useCallback, useEffect} from 'react'; +import {useInput, type Key} from 'ink'; import type {Dispatch, SetStateAction} from 'react'; import type {Country, Screen, Station, AppSettings} from '../types.js'; import type {PlayerController} from '../player/player-controller.js'; @@ -9,9 +9,11 @@ import { favoriteTarget, isEditableInput, isPlainPrintableInput, + isSearchFavoriteShortcut, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, + shouldTuneSearchResult, shouldToggleNearbyLocationShortcut, type ExploreMoveDirection, type MediaTransportAction @@ -19,6 +21,7 @@ import { import {parseTerminalMouseEvents, primaryMousePress, wheelScrollDelta} from './terminal-mouse.js'; import {completeCommand} from './help-content.js'; import {commitImmediateSelection} from './selection-state.js'; +import {sanitizeTerminalText} from '../safety.js'; type CurrentRef = { current: T; @@ -40,7 +43,7 @@ type AppInputOptions = { commandText: string; confirmCtrlCExit: () => void; copyStationUrl: (station: Station | null) => Promise; - openStationHomepage: (station: Station | null) => void; + openStationHomepage: (station: Station | null) => Promise; currentItemCount: (screen: Screen) => number; cycleDisplayColor: () => void; cycleAudioOutput: () => void; @@ -190,6 +193,9 @@ export function useAppInput({ toggleSkipBrokenStreams, updateFromSettings }: AppInputOptions): void { + const reportInputError = useCallback((error: unknown) => { + setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.'); + }, [setMessage]); const commitSelection = (next: number): void => { commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screen)); }; @@ -199,7 +205,7 @@ export function useAppInput({ }; useEffect(() => { - const onData = (data: Buffer | string) => { + const handleData = (data: Buffer | string) => { const rawInput = String(data); if (capturingTransportAction) { if (rawInput === '\u001B') { @@ -253,6 +259,10 @@ export function useAppInput({ } }; + const onData = (data: Buffer | string) => { + try { handleData(data); } catch (error) { reportInputError(error); } + }; + stdin.on('data', onData); return () => { stdin.off('data', onData); @@ -268,6 +278,7 @@ export function useAppInput({ lastRawTransportAtRef, playAdjacent, player, + reportInputError, moveExploreCursorToCell, saveLearnedTransportKey, screen, @@ -280,7 +291,7 @@ export function useAppInput({ togglePause ]); - useInput((input, key) => { + const handleInput = async (input: string, key: Key): Promise => { if (key.ctrl && input === 'c') { confirmCtrlCExit(); return; @@ -300,9 +311,9 @@ export function useAppInput({ if (commandMode) { if (key.return) { - void executeCommand(commandText); setCommandText(''); setCommandMode(false); + await executeCommand(commandText); return; } @@ -329,16 +340,21 @@ export function useAppInput({ return; } - // An active text field owns its keystrokes before app-wide shortcuts. In - // particular, a normal "a" in a station query must not invoke the global - // schedule-this-station shortcut and leave Search for the alarm editor. + // An active text field owns ordinary keystrokes before app-wide shortcuts. + // Dedicated modified shortcuts are handled here so plain query characters + // never trigger actions or leave Search. if (screen === 'search' && editingSearch) { + if (isSearchFavoriteShortcut(input, key)) { + toggleFavorite(favoriteTarget(screen, selectedStationForInput(), playingStation)); + return; + } + if (key.return) { const inputStation = selectedStationForInput(); - if (searchQuery.trim() && searchQuery.trim() === lastSubmittedSearchRef.current && inputStation) { - void playStation(inputStation); + if (inputStation && shouldTuneSearchResult(searchQuery, lastSubmittedSearchRef.current)) { + await playStation(inputStation); } else { - void runSearch(); + await runSearch(); } return; } @@ -458,7 +474,7 @@ export function useAppInput({ if (input.startsWith(':')) { const seed = input.slice(1).replace(/[\r\n]+$/g, ''); if (/[\r\n]/.test(input)) { - void executeCommand(seed); + await executeCommand(seed); } else { setCommandMode(true); setCommandText(seed); @@ -618,12 +634,12 @@ export function useAppInput({ } if (input === 'O') { - openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation)); + await openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation)); return; } if (input === 'y') { - void copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation)); + await copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation)); return; } @@ -674,7 +690,7 @@ export function useAppInput({ if (screen === 'countries') { const country = filteredCountries[selectedRef.current]; if (country) { - void loadCountry(country); + await loadCountry(country); } return; } @@ -723,9 +739,9 @@ export function useAppInput({ } else if (item === 'Automatically check for updates') { toggleSetting('automaticUpdateChecks'); } else if (item === 'Allow local agent control') { - void setAgentIntegrationEnabled(); + await setAgentIntegrationEnabled(); } else if (item === 'Install or repair MCP integrations') { - void repairMcpIntegrations(); + await repairMcpIntegrations(); } else if (item === 'Open TUI for agent playback') { toggleAgentSetting('openUiOnPlay'); } else if (item === 'Show Now Playing for agent playback') { @@ -737,7 +753,7 @@ export function useAppInput({ setCommandText('import '); setCommandMode(true); } else if (item === 'Check for updates') { - void updateFromSettings(); + await updateFromSettings(); } else if (item === 'Refresh provider health') { refreshProviderHealth(); setMessage('Provider health refreshed.'); @@ -761,16 +777,20 @@ export function useAppInput({ if (screen === 'map') { const country = filteredCountries[selectedRef.current]; if (country) { - void loadCountry(country); + await loadCountry(country); } return; } const inputStation = selectedStationForInput(); if (inputStation) { - void playStation(inputStation); + await playStation(inputStation); } } + }; + + useInput((input, key) => { + void handleInput(input, key).catch(reportInputError); }); } diff --git a/src/ui/use-command-executor.ts b/src/ui/use-command-executor.ts index f526c86..b801376 100644 --- a/src/ui/use-command-executor.ts +++ b/src/ui/use-command-executor.ts @@ -17,6 +17,7 @@ type UseCommandExecutorOptions = { loadCountry: (country: Country) => Promise; openAirPlaySettings: () => void; openLibrary: () => void; + persistLibrary: (write: () => LibraryState) => LibraryState | undefined; player: PlayerController; playingStation: Station | null; providers: ProviderManager; @@ -46,6 +47,7 @@ export function useCommandExecutor({ loadCountry, openAirPlaySettings, openLibrary, + persistLibrary, player, playingStation, providers, @@ -271,7 +273,7 @@ export function useCommandExecutor({ } if (name === 'stop') { - setLibrary(store.finishActiveListeningSession()); + persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); return; } @@ -311,6 +313,7 @@ export function useCommandExecutor({ loadCountry, openAirPlaySettings, openLibrary, + persistLibrary, player, playingStation, providers, diff --git a/src/ui/visual-baseline.test.tsx b/src/ui/visual-baseline.test.tsx new file mode 100644 index 0000000..1df32fe --- /dev/null +++ b/src/ui/visual-baseline.test.tsx @@ -0,0 +1,228 @@ +import {createRequire} from 'node:module'; +import {pathToFileURL} from 'node:url'; +import {stripVTControlCharacters} from 'node:util'; +import type {ComponentProps} from 'react'; +import {Box, renderToString} from 'ink'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {receiverStyleNames, themeNames, type Alarm, type LibraryState, type Screen, type Station} from '../types.js'; +import {AppContent} from './AppContent.js'; +import {defaultExploreCursor} from './app-state.js'; +import {draftFromAlarm} from './alarm-editor.js'; +import {DisplayContext, resolveDisplayMode} from './display-context.js'; +import {computeTerminalLayout} from './layout.js'; +import {settingsGroups} from './screen-items.js'; +import {alarmPickerChoices} from './screens/AlarmsScreen.js'; + +type ContentProps = ComponentProps; +type BaselineCase = { + name: string; + screen: Screen; + props?: Partial; + alarm?: Partial; + receiverStyle?: LibraryState['settings']['receiverStyle']; +}; + +// Resolve Ink's own Chalk instance, including nested dependency installations. +// Its explicit color level makes these captures independent of the host TTY, +// CI, and FORCE_COLOR while retaining the actual renderer's style sequences. +const inkRequire = createRequire(import.meta.resolve('ink')); +const inkChalk = (await import(pathToFileURL(inkRequire.resolve('chalk')).href)).default as {level: number}; +const originalColorLevel = inkChalk.level; +const now = '2026-09-07T12:00:00.000Z'; +const station: Station = { + id: 'kexp', provider: 'radio-browser', name: 'KEXP 90.3 FM', + country: 'United States', countryCode: 'US', city: 'Seattle', + tags: ['indie'], codec: 'MP3', bitrate: 128, + latitude: 47.6062, longitude: -122.3321, + streamUrl: 'https://example.test/kexp' +}; +const otherStation: Station = { + ...station, id: 'kcrw', name: 'KCRW 89.9 FM — A deliberately long station name', + city: 'Santa Monica', tags: ['eclectic', 'public radio'], + latitude: 34.0195, longitude: -118.4912 +}; +const alarm: Alarm = { + id: 'morning', label: 'Morning radio', enabled: true, station, + schedule: {type: 'recurring', time: '06:30', weekdays: [1, 2, 3, 4, 5], timezone: 'America/Los_Angeles'}, + playback: {volume: 70, fadeSeconds: 30, stopAfterMinutes: 60, fallbackStation: otherStation}, + reliability: {missedRunGraceMinutes: 15, wakeIfSupported: true, keepAwakeUntilAlarm: true}, + createdAt: '2026-08-22T00:00:00.000Z', updatedAt: '2026-08-22T00:00:00.000Z' +}; +const library: LibraryState = { + favorites: [station], + recent: [{station: otherStation, playedAt: '2026-09-06T12:00:00.000Z'}], + imported: [otherStation], + activity: {sessions: [ + {id: 'listen-1', station, startedAt: '2026-09-06T12:00:00.000Z', endedAt: '2026-09-06T13:00:00.000Z', listenedSeconds: 3600}, + {id: 'listen-2', station: otherStation, startedAt: '2026-09-07T09:00:00.000Z', endedAt: '2026-09-07T09:30:00.000Z', listenedSeconds: 1800} + ]}, + trackHistory: [ + {title: 'Bjork - Joga', stationKey: 'radio-browser:kexp', stationName: station.name, at: '2026-09-07T11:58:00.000Z'}, + {title: 'Aphex Twin - Avril 14th', stationKey: 'radio-browser:kexp', stationName: station.name, at: '2026-09-07T11:54:00.000Z'} + ], + searchHistory: ['tokyo jazz'], + alarms: [alarm], + settings: { + theme: 'green', receiverStyle: 'pulse-grid', volume: 70, + enableRadioGarden: false, enableNearbyLocation: false, shareDirectoryVotes: true, + preferredBackend: 'auto', preferredAirPlayDevice: 'living-room', tuneTimeoutSeconds: 12, + skipBrokenStreams: true, mediaKeys: {previous: [], playPause: [], next: []}, + resumeOnLaunch: true, asciiMode: false, reduceMotion: false, + transparentBackground: false, automaticUpdateChecks: true + } +}; +const playback: ContentProps['playback'] = {backend: 'mpv', state: 'playing', volume: 70, muted: false, ready: true}; +const runtime: ContentProps['alarmTui']['runtime'] = { + capabilities: {supported: true, exactWake: false, catchUpAfterWake: true, message: 'ready'}, + degradedAlarmIds: new Set(), message: 'Native scheduler ready.' +}; +const verification: NonNullable = { + state: 'warning', alarmLabel: alarm.label, startedAt: now, finishedAt: now, + steps: [ + {id: 'scheduler', label: 'Native scheduler', state: 'passed', detail: 'Disposable job registered and removed.', critical: true}, + {id: 'power', label: 'Sleep protection', state: 'warning', detail: 'Closed-lid policy cannot be overridden.', critical: false} + ] +}; +const activeAlarms: ContentProps['alarmTui']['activeAlarms'] = [{ + key: 'morning-session', + status: {alarmId: alarm.id, scheduledAt: '2026-09-07T11:59:00.000Z', stationName: station.name, station, startedAt: '2026-09-07T11:59:00.000Z', state: 'playing'}, + dismiss: async () => undefined, snooze: async () => undefined, keepPlaying: async () => undefined +}]; + +// Keep this exhaustive: adding a route must also make its baseline intentional. +const screens: Record> = { + home: {}, 'now-playing': {}, library: {}, explore: {}, search: {}, countries: {}, + nearby: {}, map: {}, stations: {}, stats: {}, settings: {}, + 'airplay-settings': {}, 'airplay-code': {playback: {...playback, backend: 'airplay', state: 'loading', ready: false, message: 'AirPlay code required.'}}, + help: {}, alarms: {selected: 1}, 'alarm-editor': {}, 'alarm-picker': {}, 'alarm-ringing': {} +}; +const screenCases: BaselineCase[] = [ + ...Object.entries(screens).map(([screen, props]) => ({name: screen, screen: screen as Screen, props})), + ...settingsGroups.map(group => ({name: `settings/${group.id}`, screen: 'settings' as const, props: {settingsPage: group.id}})), + {name: 'now-playing/diagnostics', screen: 'now-playing', props: {showDiagnostics: true}}, + {name: 'now-playing/idle', screen: 'now-playing', props: {playingStation: null, nowPlaying: null, playback: {...playback, state: 'idle', ready: false}}}, + {name: 'search/loading', screen: 'search', props: {loadingStations: true, displayStations: []}}, + {name: 'library/empty', screen: 'library', props: {displayStations: [], library: {...library, favorites: [], imported: [], recent: []}}}, + {name: 'help/commands', screen: 'help', props: {selected: 40}}, + {name: 'alarms/empty', screen: 'alarms', props: {library: {...library, alarms: []}}}, + {name: 'alarms/degraded', screen: 'alarms', props: {selected: 1}, alarm: {runtime: {...runtime, degradedAlarmIds: new Set([alarm.id])}, deletingId: alarm.id}}, + {name: 'alarms/verification', screen: 'alarms', alarm: {verification}}, + {name: 'alarm-editor/time', screen: 'alarm-editor', alarm: {editorField: 'time', editorControl: 'time', timeSegment: 'minute'}}, + {name: 'alarm-editor/weekdays', screen: 'alarm-editor', alarm: {editorField: 'weekdays', editorControl: 'weekdays', weekdayIndex: 2}}, + {name: 'alarm-editor/volume', screen: 'alarm-editor', alarm: {editorField: 'volume', editorControl: 'number'}}, + {name: 'alarm-editor/save', screen: 'alarm-editor', alarm: {editorField: 'save'}}, + {name: 'alarm-picker/fallback', screen: 'alarm-picker', alarm: {pickerFallback: true, pickerChoices: alarmPickerChoices([alarm], [station], [otherStation], library.recent, station, true)}} +]; +const sizes = [ + {name: 'full', columns: 100, rows: 30}, + {name: 'compact', columns: 50, rows: 16}, + {name: 'micro', columns: 24, rows: 8} +] as const; +const profiles = [ + {name: 'unicode', ascii: false, noColor: false}, + {name: 'ascii', ascii: true, noColor: false}, + {name: 'unicode-no-color', ascii: false, noColor: true}, + {name: 'ascii-no-color', ascii: true, noColor: true} +] as const; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(now)); + vi.stubEnv('TZ', 'UTC'); +}); +afterEach(() => { + inkChalk.level = originalColorLevel; + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +for (const size of sizes) { + for (const profile of profiles) { + describe(`screen baseline / ${size.name} / ${profile.name}`, () => { + it.each(screenCases)('$name', baseline => capture(baseline, size, profile)); + }); + } +} + +describe('receiver baseline / full / unicode', () => { + it.each(receiverStyleNames)('%s', receiverStyle => capture({name: receiverStyle, screen: 'now-playing', receiverStyle}, sizes[0], profiles[0])); +}); + +const representativeReceivers = ['gallop-fine', 'pulse-grid', 'ultracode', 'braille-wave', 'manhattan'] as const; +for (const size of sizes) { + for (const profile of profiles.filter(profile => !profile.noColor && (size.name !== 'full' || profile.ascii))) { + describe(`receiver baseline / ${size.name} / ${profile.name}`, () => { + it.each(representativeReceivers)('%s', receiverStyle => capture({name: receiverStyle, screen: 'now-playing', receiverStyle}, size, profile)); + }); + } +} + +// Most baselines stay readable as terminal text. These focused ANSI captures +// retain the exact accents, heatmap levels, selection fills, and scene colors. +describe('palette baseline', () => { + const paletteCases = themeNames.flatMap(theme => ['home', 'stats'].map(screen => ({ + name: `${theme}/${screen}`, screen: screen as Screen, props: {theme} + }))); + it.each(paletteCases)('$name', baseline => capture(baseline, sizes[0], profiles[0], true)); + it.each([ + {name: 'receiver accent', screen: 'now-playing'}, + {name: 'alarm selection', screen: 'alarms', props: {selected: 1}}, + {name: 'settings selection', screen: 'settings', props: {settingsPage: 'appearance'}}, + {name: 'default receiver', screen: 'now-playing', receiverStyle: 'gallop-fine'}, + {name: 'scene', screen: 'now-playing', receiverStyle: 'manhattan'}, + {name: 'mono scene', screen: 'now-playing', receiverStyle: 'manhattan', props: {theme: 'mono'}} + ])('$name', baseline => capture(baseline, sizes[0], profiles[0], true)); +}); + +function capture(baseline: BaselineCase, size: (typeof sizes)[number], profile: (typeof profiles)[number], includeColor = false): void { + inkChalk.level = profile.noColor ? 0 : 3; + const layout = computeTerminalLayout(size.columns, size.rows); + const baselineLibrary = baseline.props?.library ?? library; + const renderLibrary: LibraryState = { + ...baselineLibrary, + settings: {...baselineLibrary.settings, theme: baseline.props?.theme ?? baselineLibrary.settings.theme, asciiMode: profile.ascii, receiverStyle: baseline.receiverStyle ?? baselineLibrary.settings.receiverStyle} + }; + const display = resolveDisplayMode(renderLibrary.settings, profile.noColor ? {NO_COLOR: '1'} : {}); + const props: ContentProps = { + airPlayDevices: [{id: 'living-room', name: 'Living Room', host: 'speaker.local', port: 7000, txt: [], requiresPassword: true, airplay2: true}], + airPlayCode: '1234', appVersion: '0.2.3', backends: ['mpv', 'airplay'], + countryFilter: '', diagnostics: {backend: 'mpv', availableBackends: ['mpv', 'airplay'], preferredBackend: 'auto', active: true, volume: 70, muted: false, ready: true, startedAt: '2026-09-07T11:30:00.000Z'}, + displayStations: [station, otherStation], editingCountryFilter: false, editingSearch: true, + exploreCursor: defaultExploreCursor, favoriteKeys: new Set(['radio-browser:kexp']), filterLabel: 'none', + filteredCountries: [{name: 'United States', code: 'US', stationCount: 12_345}, {name: 'Japan', code: 'JP', stationCount: 456}], + frameWidth: layout.frameWidth, layout, library: renderLibrary, loadingCountries: false, loadingStations: false, + nowPlaying: {title: 'Bjork - Joga', updatedAt: now}, playback, playingStation: station, + providerHealth: {'radio-browser': 'online', 'radio-garden': 'disabled'}, searchQuery: 'tokyo jazz', + screen: baseline.screen, settingsPage: 'root', selected: 0, showDiagnostics: false, + sleepLabel: 'Sleep off', stationContext: {title: baseline.screen === 'library' ? 'Library' : baseline.screen === 'nearby' ? 'Nearby' : baseline.screen === 'stations' ? 'United States' : 'Explore', subtitle: 'Saved and discovered stations', stations: [station, otherStation]}, + stationFavorite: true, stationTime: '12:00', storePath: '/fixture/radiocli.json', theme: 'green', + updateCheck: {checkedAt: now, currentVersion: '0.2.3', latestVersion: '0.3.0', updateAvailable: true}, + alarmTui: { + draft: draftFromAlarm(alarm), editorField: 'label', editingField: false, editorControl: null, + timeSegment: 'hour', weekdayIndex: 0, validationError: null, saving: false, + pickerChoices: alarmPickerChoices([alarm], [station], [otherStation], library.recent, station, false), + pickerFallback: false, runtime, verification: null, activeAlarms, activeSelected: 0, + snoozeMinutes: 10, busyAlarmIds: new Set(), itemCount: () => undefined, + handleInput: () => false, openForStation: () => undefined, openActive: () => undefined, + ...baseline.alarm + }, + ...baseline.props + }; + props.library = renderLibrary; + // Match App's content viewport. renderToString runs the real Ink renderer and + // tears down its Yoga tree and effects after capturing the initial frame. + const frame = renderToString( + + + + + , + {columns: layout.frameWidth} + ); + const lines = stripVTControlCharacters(frame).split('\n'); + expect(lines.length).toBeLessThanOrEqual(layout.contentRows); + expect(frame).not.toContain('Unknown screen.'); + if (profile.noColor) expect(frame).not.toContain('\u001B['); + const snapshot = includeColor ? frame.replaceAll('\u001B', '') : stripVTControlCharacters(frame); + expect(snapshot.split('\n').map(line => line.trimEnd()).join('\n')).toMatchSnapshot(); +} diff --git a/src/update-check.test.ts b/src/update-check.test.ts index ae66a5e..bee193d 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -13,6 +13,7 @@ import { describe('update checks', () => { afterEach(() => { vi.unstubAllEnvs(); + vi.useRealTimers(); }); it('compares semantic versions', () => { @@ -53,6 +54,33 @@ describe('update checks', () => { expect(updateCheck.error).toContain('503'); }); + it('does not make automatic or explicit registry requests while offline', async () => { + vi.stubEnv('CI', 'false'); + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const fetchImpl = vi.fn(async () => new Response('{"version":"99.0.0"}')); + expect(shouldCheckForUpdate(undefined)).toBe(false); + expect(automaticUpdateChecksAllowed(true)).toBe(false); + const result = await checkForUpdate({fetchImpl}); + expect(result.updateAvailable).toBe(false); + expect(result.error).toMatch(/offline/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('bounds the registry response body after headers arrive', async () => { + vi.useFakeTimers(); + let body!: ReadableStreamDefaultController; + const fetchImpl = async () => new Response(new ReadableStream({start(controller) {body = controller;}})); + let result: Awaited> | undefined; + const checking = checkForUpdate({fetchImpl, timeoutMs: 100}).then(value => {result = value;}); + try { + await vi.advanceTimersByTimeAsync(101); + expect(result?.error).toMatch(/timed out/i); + } finally { + body.error(new Error('test cleanup')); + await checking; + } + }); + it('uses a 24 hour cache and honors disable flags', () => { vi.stubEnv('CI', 'false'); diff --git a/src/update-check.ts b/src/update-check.ts index a141b64..1bb9a24 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -2,6 +2,7 @@ import {realpathSync} from 'node:fs'; import {spawn} from 'node:child_process'; import {appVersion} from './version.js'; import type {UpdateCheckState} from './types.js'; +import {networkPolicy, withExternalResponse} from './platform/network.js'; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const UPDATE_PACKAGE_NAME = '@ciphore/radiocli'; @@ -42,12 +43,10 @@ export async function checkForUpdate({ timeoutMs = 3000 }: CheckForUpdateOptions = {}): Promise { try { - const response = await fetchWithTimeout(registryLatestUrl(packageName), fetchImpl, timeoutMs); - if (!response.ok) { - throw new Error(`npm registry returned ${response.status}`); - } - - const parsed = await response.json() as {version?: unknown}; + const parsed = await withExternalResponse(registryLatestUrl(packageName), {fetchImpl, timeoutMs}, async response => { + if (!response.ok) throw new Error(`npm registry returned ${response.status}`); + return await response.json() as {version?: unknown}; + }); const latestVersion = typeof parsed.version === 'string' ? parsed.version : undefined; if (!latestVersion) { throw new Error('npm registry response did not include a version'); @@ -70,7 +69,7 @@ export async function checkForUpdate({ } export function shouldCheckForUpdate(updateCheck: UpdateCheckState | undefined, now = Date.now()): boolean { - if (process.env.RADIOCLI_DISABLE_UPDATE_CHECK === '1' || process.env.CI === 'true') { + if (!automaticUpdateChecksAllowed()) { return false; } @@ -83,7 +82,7 @@ export function shouldCheckForUpdate(updateCheck: UpdateCheckState | undefined, } export function automaticUpdateChecksAllowed(enabled = true): boolean { - return enabled && process.env.RADIOCLI_DISABLE_UPDATE_CHECK !== '1' && process.env.CI !== 'true'; + return enabled && !networkPolicy().offline && process.env.RADIOCLI_DISABLE_UPDATE_CHECK !== '1' && process.env.CI !== 'true'; } export function updateAvailableForVersion( @@ -179,16 +178,6 @@ function registryLatestUrl(packageName: string): string { return `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`; } -async function fetchWithTimeout(url: string, fetchImpl: FetchLike, timeoutMs: number): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetchImpl(url, {signal: controller.signal}); - } finally { - clearTimeout(timer); - } -} - function semverParts(version: string): [number, number, number] { const normalized = version.trim().replace(/^v/i, '').split(/[+-]/)[0] ?? ''; const [major = '0', minor = '0', patch = '0'] = normalized.split('.');