diff --git a/.gitattributes b/.gitattributes index e4ec365..2eb55ba 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,6 @@ *.jpeg binary *.gif binary *.cast text eol=lf +# Preserve verified upstream formatting; UPSTREAM.json guards these files. +vendor/node-airtunes2/src/** -whitespace +vendor/node-airtunes2/README.md -whitespace diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a027c34..4b98b49 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,13 +4,14 @@ updates: directory: / schedule: interval: weekly - open-pull-requests-limit: 5 + open-pull-requests-limit: 0 - package-ecosystem: npm directory: /apps/docs schedule: interval: weekly - open-pull-requests-limit: 5 + open-pull-requests-limit: 0 - package-ecosystem: github-actions directory: / schedule: interval: weekly + open-pull-requests-limit: 0 diff --git a/.github/social-preview.png b/.github/social-preview.png index 7faf384..ae66ed3 100644 Binary files a/.github/social-preview.png and b/.github/social-preview.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8be5c1f..eaa84f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,30 +80,44 @@ jobs: run: npm ci - name: Audit required runtime dependencies + id: audit + env: + RADIOCLI_AUDIT_EVIDENCE_PATH: output/release-evidence/audit-runtime.json run: npm run audit:runtime - name: Typecheck + id: typecheck run: npm run check - name: Lint + id: lint run: npm run lint - name: Test + id: tests + # Rendering and storage fixtures can saturate small hosted runners. + # Bound concurrency without changing assertions or test deadlines. + env: + VITEST_MAX_WORKERS: '1' 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 + id: harness + run: node --test scripts/packed-smoke.test-node.mjs scripts/install-smoke-mpv.test-node.mjs scripts/release-evidence.test-node.mjs scripts/audit-ci.test-node.mjs scripts/build-airplay-native.test-node.mjs - name: Build + id: build run: npm run build - name: MCP stdio smoke + id: source-mcp run: npm run smoke:mcp - name: Package dry run run: npm run pack:check - name: Package lint + id: package run: npm run check:package - name: Data smoke @@ -117,22 +131,46 @@ jobs: - name: Install pinned native macOS or Windows playback fixture if: runner.os != 'Linux' env: - RADIOCLI_SMOKE_EVIDENCE_DIR: packed-evidence + RADIOCLI_SMOKE_EVIDENCE_DIR: output/release-evidence run: node scripts/install-smoke-mpv.mjs + - name: Background CLI playback smoke + id: background-cli + run: npm run smoke:cli + + - name: Native TUI crash recovery + id: native-tui + env: + RADIOCLI_REQUIRE_TUI_RECOVERY: '1' + RADIOCLI_TUI_RECOVERY_BUILT: '1' + run: npx vitest run src/agent/tui-recovery.native.test.ts + + - name: Native network security and recovery + id: native-network + env: + RADIOCLI_SMOKE_EVIDENCE_DIR: output/release-evidence + run: npm run smoke:network + - name: Fresh install smoke + id: packed 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 + RADIOCLI_SMOKE_EVIDENCE_DIR: output/release-evidence + run: npm run fresh:check -- --require-mpv --require-clean + + - name: Bind release evidence to this revision and artifact + if: always() + env: + RADIOCLI_CHECK_OUTCOMES: ${{ toJSON(steps) }} + run: node scripts/release-evidence.mjs output/release-evidence - name: Preserve packed runtime evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: packed-${{ matrix.os }}-${{ matrix.node-version }} - path: packed-evidence/*.json + path: output/release-evidence/ if-no-files-found: warn playback-smoke: @@ -162,7 +200,7 @@ jobs: cache: npm - name: Install system playback backend - run: sudo apt-get update && sudo apt-get install -y mpv ffmpeg + run: sudo apt-get update && sudo apt-get install -y mpv mpv-mpris ffmpeg vlc dbus-daemon dbus-bin pipewire pipewire-pulse wireplumber pulseaudio-utils avahi-daemon avahi-utils shairport-sync - name: Install run: npm ci @@ -171,6 +209,32 @@ jobs: # Packed normal/omit-optional installs for this OS/CPU/Node matrix run in verify. run: npm run smoke:playback + - name: Native network and fallback playback + env: + RADIOCLI_REQUIRE_FALLBACKS: '1' + run: npm run smoke:network + + - name: Native desktop media protocol + # Real session-bus transport on both CPUs and Node lines. This does not + # establish Hyprland/Quickshell, hardware keys, or audible output. + run: | + test -f /usr/lib/mpv-mpris/mpris.so + dbus-run-session -- npx vitest run src/platform/mpris.native.test.ts src/player/mpv-desktop-media.native.test.ts src/platform/diagnostics.native.test.ts + + - name: Native alarm output restoration + # Synthetic sinks on an isolated server verify device ownership, not speakers. + env: + RADIOCLI_NATIVE_PIPEWIRE_TEST: '1' + run: dbus-run-session -- npx vitest run src/alarms/system-volume.native.test.ts + + - name: Native AirPlay software receiver + # Decoded PCM, authentication, controls and cleanup; no physical receiver claim. + env: + RADIOCLI_NATIVE_AIRPLAY_TEST: '1' + run: | + sudo systemctl start avahi-daemon + npx vitest run src/player/airplay.native.test.ts + docs: name: Docs if: github.event_name != 'schedule' @@ -206,6 +270,19 @@ jobs: working-directory: apps/docs run: npm run build + required-release-checks: + name: Required Release Checks + needs: [verify, playback-smoke, docs] + if: always() && github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - name: Require all maintained-platform jobs + env: + VERIFY: ${{ needs.verify.result }} + PLAYBACK: ${{ needs.playback-smoke.result }} + DOCS: ${{ needs.docs.result }} + run: test "$VERIFY" = success && test "$PLAYBACK" = success && test "$DOCS" = success + # 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 @@ -236,7 +313,7 @@ jobs: sha256sum portability-artifact/*.tgz - name: Preserve the built package and standalone harness - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: portability-package path: portability-artifact/ @@ -323,10 +400,11 @@ jobs: 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. + # The matching 10.1 set moved to the official release archive. + # Digest: NetBSD-10.1/amd64/binary/sets/SHA512 on archive.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 + ftp -q 60 -o "$radiocli_xbase" https://archive.netbsd.org/pub/NetBSD-archive/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 diff --git a/.gitignore b/.gitignore index 2b2049a..fb42bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ coverage/ *.log *.tsbuildinfo .radiocli-test/ +.tmp/ +README.proposed.md +README.preview.html +README*.preview.jpg +vendor/node-airtunes2/build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b2326e4..e3a0723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,76 @@ All notable changes to RadioCLI are documented in this file. +## [0.2.5] - 2026-10-18 + +### Added + +- Added direct shell playback, volume controls, station-name lookup, filtered + search, favorites, recents, named presets, and listening stats. +- Added a compact now-playing view with live track metadata and `--watch`, + readable terminal tables, JSON/plain output, and shell completion for Bash, + Zsh, Fish, and PowerShell. +- Added Linux desktop media controls for RadioCLI's station queue, metadata, + playback, and volume, with `radiocli media-controls auto|on|off` overrides. +- Added experimental Omarchy Quattro integration with preferred-terminal + launching and desktop/session diagnostics. +- Added experimental, opt-in Linux AirPlay with Avahi discovery, receiver + selection, passcode handling, and guided dependency setup. + +### Changed + +- Simplified shell help around eight everyday commands, with `help --all` for + the full reference, `play` to resume, and favorites, recent, and popular queues. +- Expanded Doctor with separate terminal, desktop media, audio, Omarchy, and + AirPlay readiness checks. +- Included station presets in library backup, restore, and concurrent saves. +- Reduced Gallop's drawing scale by 10% while preserving its proportions, + centering, and animation timing. +- Removed the twelve preview receiver scenes, retaining the original 63 styles + and Ultracode default. Retired selections migrate without resetting saved data. + +### Fixed + +- Kept TUI playback running after a terminal crash, recovering the active + station, queue, controls, and sleep timer when the terminal reopens. +- Recovered interrupted or stalled live mpv streams without restarting paused + sessions, finite recordings, or playback canceled by Stop. +- Prevented duplicate mpv desktop media players and kept desktop controls in + sync with the shared player, including live-edge resume and alarm transitions. +- Restored alarm volume to the original Linux output after default-device + changes and overlapping alarms, and kept imported alarm IDs distinct across + case-insensitive and Unicode-normalizing filesystems. +- Preserved AirPlay mute, volume, and station metadata during connection setup, + added legacy RAOP password authentication, and improved connection errors. +- Protected concurrent library saves and preserved the latest data in the safety + backup before a restore. Retried temporary Windows file-replacement conflicts. +- Preserved migrated custom-station IDs, corrected XSPF CDATA and Unicode URL + imports, and restored Windows drive-path playlist imports. +- Corrected mixed Arabic/Latin terminal layout and emoji clipping, padding, and + scrolling without changing plain output or screen-reader behavior. +- Expired future-dated provider cache entries after clock corrections and + preserved valid data when migrating older caches. +- Preserved package-manager paths during Unix updates and allowed slower + PowerShell startup when opening native Windows terminals. +- Fixed a documentation typecheck race and added a single required CI gate for + platform, playback, and documentation checks. Release checks now include + packed upgrades from 0.2.3 and 0.2.4 and native TUI crash recovery. + +### Security + +- Removed the optional AirPlay sender's vulnerable elliptic dependency, using + Node's native Ed25519 while preserving pairing formats and optional installs. +- Enabled native stream certificate verification and restricted FFmpeg network + playlists that cannot safely carry TLS policy. Documented the remaining + native OpenSSL numeric-IP certificate identity limitation. +- Kept Doctor's probes read-only, bounded local service checks, and excluded + receiver codes and stream credentials from AirPlay diagnostics. +- Updated documentation and development dependencies to patched releases. + +Quattro integration and Linux AirPlay remain experimental. Software playback +checks do not establish physical receiver, speaker, hardware-key, or suspend/wake +support; existing platform support tiers are unchanged. + ## [0.2.4] - 2026-09-17 ### Added @@ -475,3 +545,4 @@ Initial public release. [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 [0.1.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.1 +[0.2.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 809dc62..6a83bed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,16 @@ 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. +Also run `npm run smoke:network` for playback or transport changes. It uses +loopback HTTP/TLS fixtures with null/dummy audio to verify certificate rejection, +live EOF and stall recovery, finite-media EOF, Stop ownership, and installed +fallback readiness. Set `RADIOCLI_REQUIRE_FALLBACKS=1` when the test host should +have FFmpeg, ffplay and VLC; missing tools then fail instead of being skipped. + +Run `npm run build && npm run smoke:cli` when changing shell playback or session +control. It uses an isolated library and silent local WAV with mpv's null audio +output to check background startup, separate CLI controls, presets, and shutdown. + Live provider checks are useful before releases, but ordinary pull requests should stay deterministic. Run `npm run verify:release` for a full maintainer pre-release pass that includes live data, playback, packaging, and fresh-install diff --git a/README.md b/README.md index 794a95e..e4e6734 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,61 @@ # RadioCLI -Discover and stream stations from around the world without leaving your -terminal. No account. No cloud library. +RadioCLI is an open-source internet radio player with a full-screen terminal +interface and a scriptable CLI. Discover and stream stations from around the +world without leaving your terminal. No account. No cloud library. -[![CI](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml/badge.svg)](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml) +[![CI](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Node.js](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](package.json) -![RadioCLI Now Playing visualizer demo](apps/docs/public/demo/radiocli-now-playing.gif) +![RadioCLI playing SomaFM while cycling through terminal receiver styles](apps/docs/public/demo/radiocli-now-playing.gif) ## Features -RadioCLI pairs a keyboard-first interface with the open -[Radio Browser](https://api.radio-browser.info/) directory and dependable -playback through `mpv`. - -- **Discovery:** Global search and filters, country browsing, a station-density - map, an interactive world map, nearby stations, and an optional experimental - Radio Garden provider. -- **Playback:** Live metadata, track history, 60+ terminal visualizers, receiver - styles, color themes, sleep timers, diagnostics, learned media keys, and - previous/next queues based on the station list you opened. -- **AirPlay:** Select and switch to discovered receivers through the experimental - macOS output, including passcode entry when a receiver requires it. -- **Custom stations:** Add direct HTTP(S) streams with automatic ICY metadata, - import `.m3u`, `.pls`, and `.xspf` playlists, and export an `.m3u` library. -- **Local library:** Favorites, recents, imports, track history, settings, and - JSON backup and restore stay on your machine. -- **Listening stats:** Sessions, streaks, active days, total listening time, and - a 52-week activity graph. -- **Alarms:** One-time and recurring schedules with station fallback, independent - volume, fade-in, auto-stop, snooze, missed-run grace, native OS scheduling, - and optional Alarm Guard. -- **Agent control:** An optional local Model Context Protocol (MCP) server gives - supported coding tools typed access to discovery, playback, favorites, stats, - alarms, AirPlay, and appearance. `radiocli agent` exposes the same controls to - other local tools. -- **Terminal support:** Full, compact, and micro layouts; keyboard, mouse, and - trackpad input; Unicode and ASCII rendering; reduced motion; screen-reader, - limited-color, no-color, transparent-background, offline, and low-bandwidth - modes. - -## Install +- **Station discovery:** Search by name or genre, filter by country, language, + codec, and bitrate, or browse country lists. The interactive world map uses a + cached station atlas to find broadcasts around the map cursor and sort them by + distance. Cached directory results remain available during provider outages. +- **63 receiver styles:** Switch between braille animation, ASCII geometry, + waves, fire, cityscapes, and the Gallop display without interrupting + playback. Color themes are selected independently. Receivers animate during + active playback and return to a zero-signal display when audio is inactive. +- **Listening stats:** A 52-week activity graph, listening time, session counts, + active days, streaks, and your most-listened station. Stats are recorded locally + across TUI and CLI listening sessions. +- **Alarms (beta):** One-time and recurring schedules with timezones, independent + volume, fade-in, fallback stations, snooze, and auto-stop. Native OS scheduling + allows alarms to run after the terminal closes. See the + [alarm guide](apps/docs/content/docs/alarms.mdx) for setup and power requirements. +- **Agent control:** An optional Model Context Protocol (MCP) server exposes + typed tools for discovery, playback, favorites, stats, alarms, AirPlay, and + appearance. Coding assistants can operate RadioCLI directly; `radiocli agent` + provides the same controls for other local tools. +- **Terminal support:** Full, compact, and micro layouts adapt to window resizing. + Keyboard, mouse, and trackpad controls work alongside Unicode and ASCII + rendering, reduced motion, screen-reader output, transparent backgrounds, and + limited-color or `NO_COLOR` environments. Offline browsing and low-bandwidth + modes are also supported. +- **Playback:** Pause, volume, mute, and live track metadata through `mpv`, with + configurable media keys and a sleep timer. Previous and next follow the station + list you tuned from. Track history and stream diagnostics are available in + the TUI. +- **Nearby stations:** Discover stations around an approximate location and + browse them by distance. Location lookup runs when you open Nearby and can be + disabled in Settings. +- **Local library:** Save favorites, revisit recent stations, and manage imports + in one place. Settings, track history, and listening data stay on your machine, + with JSON backup and restore. No account is required. +- **AirPlay (experimental):** Discover network receivers and switch audio output + from the TUI, including passcode entry when required. Availability depends on + the platform and receiver; see [supported setups](apps/docs/content/docs/platforms.mdx). +- **Custom stations:** Add direct HTTP(S) stream URLs with automatic ICY metadata + lookup. Import M3U, PLS, or XSPF playlists and export an M3U library. +- **Shell commands:** Start background playback by station name, save named + presets, and watch the current track from your prompt. Shell completion and + JSON or plain output support interactive use and scripting. + +## Quick start **Homebrew** @@ -50,7 +64,7 @@ brew install ciphore/tap/radiocli radiocli ``` -**npm** +**npm · Node.js 22 or newer** ```bash npm install -g @ciphore/radiocli @@ -58,120 +72,84 @@ radiocli setup radiocli ``` -RadioCLI requires Node.js 22 or newer. `radiocli setup` can install and verify -the native playback tools available on macOS, Linux, and Windows. +Setup helps install the playback tools for your system. See +[installation](apps/docs/content/docs/getting-started/install.mdx) for other +routes and [platform support](apps/docs/content/docs/platforms.mdx) for +macOS, Linux, Windows, and BSD details. -Need a different package route, AirPlay setup, or a fallback player? See the -[installation guide](apps/docs/content/docs/getting-started/install.mdx) and -[platform matrix](apps/docs/content/docs/platforms.mdx). +Launch `radiocli`, open Search or Explore, select a station, and press `Enter` +to start playback. -## Screenshots +| Key | Action | +| --- | --- | +| `←` / `→` | Switch screens | +| `↑` / `↓`, then `Enter` | Select and tune a station | +| `space` | Pause or resume | +| `f` | Toggle a favorite | +| `v` / `t` | Change receiver style / color theme on Playing | +| `?` / `q` | Show shortcuts / quit | -**Explore** +The footer shows controls for the current screen. See the +[controls guide](apps/docs/content/docs/getting-started/tui-controls.mdx) for +filters, station skipping, media keys, and the command palette. -![World map discovery in RadioCLI Explore](apps/docs/public/demo/radiocli-explore-map.gif) +## Screenshots -**Search** +**Explore:** Move the map cursor with WASD or click a location to list stations +by distance. -![Station search results in RadioCLI](apps/docs/public/demo/radiocli-search.gif) +![Interactive world map and station discovery in RadioCLI](apps/docs/public/demo/radiocli-explore-map.gif) -**Library** +**Search:** Browse results with station names, countries, codecs, and bitrates. -![Favorites and recent stations in the RadioCLI Library](apps/docs/public/demo/radiocli-library.gif) +![Searching internet radio stations in the terminal](apps/docs/public/demo/radiocli-search.gif) -**Nearby** +**Library:** Switch between favorites, recent stations, and imported streams. -![Nearby stations in RadioCLI](apps/docs/public/demo/radiocli-nearby.gif) +![Favorites and recent stations in the RadioCLI library](apps/docs/public/demo/radiocli-library.gif) -**Stats** +**Nearby:** Browse stations around your approximate location. -![Local listening stats with selectable display colors](apps/docs/public/demo/radiocli-stats-colors.gif) +![Nearby radio stations in RadioCLI](apps/docs/public/demo/radiocli-nearby.gif) -## Controls +**Stats:** Review listening activity and totals, with selectable display colors. -| Key | Action | -| --- | --- | -| `←` / `→` or `Tab` / `Shift+Tab` | Switch screens | -| `↑` / `↓` or `n` / `p` | Move the selection | -| `Enter` | Open or tune the selection | -| `space` or `F8` | Pause or resume | -| `,` / `.` or `F7` / `F9` | Previous or next station | -| `+` / `-` | Change volume | -| `f` | Save or remove a favorite | -| `:` | Open the command palette | -| `?` | Show every shortcut | -| `q` | Quit | - -The footer always shows the controls that matter on the current screen. The -[controls guide](apps/docs/content/docs/getting-started/tui-controls.mdx) covers -playback, filters, media keys, AirPlay, and commands. +![Listening stats and the 52-week activity graph with selectable colors](apps/docs/public/demo/radiocli-stats-colors.gif) ## CLI and agent control -RadioCLI also works as a scriptable CLI, so the same library is available to -shell scripts and local development tools. - ```bash -radiocli search "japan hits" -radiocli countries -radiocli import https://example.com/live.mp3 -radiocli export favorites.m3u -radiocli alarm list -radiocli check -radiocli doctor --json +radiocli play jazz +radiocli now +radiocli favorite +radiocli stop ``` -Run `radiocli setup --mcp` or enable **Agent control & MCP** in Settings to expose -typed local tools for discovery, playback, favorites, stats, alarms, AirPlay, -and receiver customization. See the -[CLI guide](apps/docs/content/docs/getting-started/cli.mdx) for the complete -command and agent-control reference. - -## Alarms - -Set one-time or recurring alarms that reopen RadioCLI through launchd on macOS, -systemd on Linux, or Task Scheduler on Windows. Station fallback, snooze, -missed-run grace, and Alarm Guard help make scheduled playback more resilient. +`play` starts background playback and returns to your prompt. If the TUI is +already open, commands control its player instead. Both interfaces use the same +library, metadata, and listening history. Use `radiocli help` for common commands +or `radiocli help --all` for the full reference. -Alarms are experimental. A computer must be powered on with an active audio -session, and wake behavior depends on the operating system and hardware. Use a -separate primary alarm for anything safety-critical. Read the -[alarm guide](apps/docs/content/docs/alarms.mdx) before relying on one. +Run `radiocli setup --mcp` to configure the optional local MCP integration for +coding assistants. Ordinary CLI playback does not require MCP setup. The +[CLI and agent guide](apps/docs/content/docs/getting-started/cli.mdx) covers +commands, presets, shell completion, and integration details. -## Privacy - -RadioCLI does not require an account, proxy your audio, or upload your listening -history. Favorites, recents, imports, settings, alarms, track history, and stats -stay in a local JSON library. - -Nearby discovery requests an approximate location only when you open that -screen. Radio Browser favorite votes can be disabled in Settings. The full data -flow is documented in [Privacy and security](apps/docs/content/docs/privacy-security.mdx). - -## Development - -```bash -git clone https://github.com/Ciphore/RadioCLI.git -cd RadioCLI -npm ci -npm run verify -npm run dev -``` +## Documentation -`npm run verify` runs the type checks, lint, tests, production build, and live -provider validation. Contributions are welcome—start with -[CONTRIBUTING.md](CONTRIBUTING.md). +[Getting started](apps/docs/content/docs/index.mdx) · +[Controls](apps/docs/content/docs/getting-started/tui-controls.mdx) · +[Alarms](apps/docs/content/docs/alarms.mdx) · +[Troubleshooting](apps/docs/content/docs/troubleshooting.mdx) · +[Architecture](apps/docs/content/docs/architecture.mdx) -## Documentation +Your library and listening history stay on your machine. Read +[Privacy and security](apps/docs/content/docs/privacy-security.mdx) for the +network requests and data flow. -- [Getting started](apps/docs/content/docs/index.mdx) -- [Installation](apps/docs/content/docs/getting-started/install.mdx) -- [Controls](apps/docs/content/docs/getting-started/tui-controls.mdx) -- [CLI and agent control](apps/docs/content/docs/getting-started/cli.mdx) -- [Platforms](apps/docs/content/docs/platforms.mdx) -- [Troubleshooting](apps/docs/content/docs/troubleshooting.mdx) -- [Architecture](apps/docs/content/docs/architecture.mdx) +Want to contribute? [CONTRIBUTING.md](CONTRIBUTING.md) covers development and +checks. RadioCLI is [MIT licensed](LICENSE). -## License +--- -[MIT](LICENSE) +![Radio on internet, from Silicon Valley](apps/docs/public/demo/silicon-valley-radio-on-internet.gif) diff --git a/SECURITY.md b/SECURITY.md index b46bb9b..3f19893 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,10 +24,32 @@ information, or unexpected command execution. - The app stores recents, favorites, settings, imports, and provider cache data locally under the user data directory. - RadioCLI does not proxy audio. It resolves public stream URLs and hands - playback to `mpv` or `ffplay`. + playback to native players. - Favoriting a Radio Browser station sends a best-effort vote to that public directory by default; the Settings screen can disable vote sharing. +## Stream Certificate Verification + +RadioCLI enables mpv TLS verification, including redirected and nested streams, +and enables TLS verification for direct FFmpeg/ffplay network media. Native +trust stores and player certificate configuration apply. It never retries an +HTTPS input with certificate verification disabled. + +FFmpeg releases in circulation do not propagate TLS policy to every resource +opened by network playlist demuxers. Those inputs are restricted in FFmpeg +fallbacks: use mpv for HLS/DASH/IMF playback, or a direct audio stream for AirPlay. +Local FFmpeg inputs cannot open network protocols. The plain HTTP compatibility +retry explicitly blocks both HTTPS and TLS, including changed redirects. + +Known upstream limitation: tested FFmpeg/OpenSSL builds can accept a trusted +certificate with a mismatched **numeric IP address**, even with verification +enabled. This also affects mpv builds using that native TLS implementation. +DNS hostname mismatch fixtures are rejected. Prefer DNS station URLs. A +separate Node preflight would not secure the player's actual connection, so +RadioCLI does not present one as a fix. See the +[reliability reference](apps/docs/content/docs/reliability.mdx#playback-recovery-and-secure-streams) +and native smoke results for the precise guarantees and remaining limits. + ## Supported Versions Until a tagged release cadence is established, security fixes target the active diff --git a/apps/docs/content/docs/alarms.mdx b/apps/docs/content/docs/alarms.mdx index 569bc5b..38cbd4b 100644 --- a/apps/docs/content/docs/alarms.mdx +++ b/apps/docs/content/docs/alarms.mdx @@ -64,6 +64,13 @@ configured alarm volume. During an interactive handoff, that audible level is preserved for regular playback. Scheduled alarms use local speakers; unattended AirPlay is not supported. +On Linux, PipeWire/PulseAudio volume leases remember the output actually changed. +Changing the default sink cannot restore an earlier device's volume onto the new +one. Overlapping alarms share a lease for the same output, including when they +use different mixer tools. If an output disappears or cannot be identified +safely, RadioCLI retains the recovery record for repair instead of changing an +unrelated replacement device. + ## Background Delivery And Power All platforms require a powered-on machine, a logged-in interactive audio @@ -77,6 +84,13 @@ idle sleep until the next occurrence using `caffeinate`, `systemd-inhibit`, or Windows execution-state APIs. It consumes power and cannot override shutdown, hibernate, explicit sleep, a closed lid, logout, or enforced OS policy. +Packaged Omarchy Quattro systemd user jobs retain the saved RadioCLI data and +Node executable identity, while desktop/audio connection variables come from +the user manager at launch. Explicit terminal choices remain preserved; the +native Quattro default resolves the currently selected desktop terminal. +This avoids storing stale display and bus addresses in the job. It still +requires a usable logged-in user session and does not add exact-wake support. + See [Reliability](./reliability.mdx) for the platform matrix, permission flow, timezone and DST semantics, fallback behavior, volume integration, and known limitations. diff --git a/apps/docs/content/docs/architecture.mdx b/apps/docs/content/docs/architecture.mdx index ea669c7..1e34f6d 100644 --- a/apps/docs/content/docs/architecture.mdx +++ b/apps/docs/content/docs/architecture.mdx @@ -13,14 +13,17 @@ integrations for those components. src/cli.tsx -> src/ui/App.tsx -> provider manager - -> player controller - -> JSON library store + -> TuiSession -> shared playback owner + -> RadioEngine -> player controller + -> LibraryRepository -> transactional JSON store worker -> alarm TUI service -> Ink screens/components -> src/alarms/cli.ts -> native scheduler adapter -> alarm runner + local control session -> power inhibitor / Alarm Guard + -> headless playback owner + -> RadioEngine + LibraryRepository ``` The CLI has two modes: @@ -30,13 +33,38 @@ The CLI has two modes: `radiocli import https://example.com/live`, `radiocli import stations.m3u`, `radiocli alarm doctor` -Local agent control adds two adapters over one command model: `radiocli agent` -for shell-capable harnesses and a stdio MCP server for MCP-capable harnesses. +The listener CLI (`src/cli`) handles station-name resolution, inline selection, +terminal/JSON/plain formatting, shell completion, and named presets. Playback +commands use the shared radio service with local CLI access, which does not +enable agent integrations. The service reuses an owning TUI or starts one +background host; `now --watch` reads that owner's live metadata and cancels +pending status requests when the view exits. Presets participate in the library's +concurrent write merge and JSON backup/restore. + +Help, version, and basic shell completion load only the CLI argument and output +helpers. Providers, persistence, playback, React/Ink, Doctor, and MCP load when +their command needs them. Setup rehearses a local WAV through installed players +with null or dummy output and a bounded process deadline. Executable discovery, +successful decoding, and physical speaker or receiver output are separate facts; +`radiocli doctor --playback` repeats the decoder rehearsal. + +Opt-in local agent control adds two adapters over the same command model: +`radiocli agent` for shell-capable harnesses and a stdio MCP server for MCP-capable harnesses. Both route mutations to the currently owning TUI or headless playback process -through an authenticated loopback session. That owner serializes commands, so -concurrent completions have deterministic arrival-order behavior. Discovery +through an authenticated loopback session. The owner orders preference changes +and desktop transport intent. Status and Stop remain available during slow +station resolution; Stop and alarm takeover cancel active and queued transport +work, while a replacement tune invalidates the earlier tune's generation. A new +command waits for an accepted Stop to finish. Discovery queries and listening-stat reads do not start a player or open a terminal. +The loopback boundary validates command shapes, stations, settings, queue sizes, +and numeric limits before dispatch. Version 2 requests carry a UUID, and a +bounded five-minute outcome cache lets clients retry that UUID without replaying +a mutation. Reusing an ID with a different command fails. Legacy command +requests remain supported. Request and response bodies are bounded, and control +tokens always use direct loopback connections regardless of proxy settings. + Agent playback and scheduled alarms intentionally have separate discovery files, tokens, and owners. Explicit alarm MCP tools reuse the alarm scheduler and authenticated active-occurrence controls; ordinary agent stop/pause tools @@ -55,6 +83,7 @@ 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 | +| `omarchy.ts`, `desktop-media.ts`, `mpris.ts` | Identify packaged Quattro independently of the OS, select desktop-media policy, and publish the ordinary playback owner's state on a local Unix user bus | | `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 | @@ -98,7 +127,9 @@ 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 -first matching instant. One-time schedules are absolute ISO instants at minute +first matching instant. Bounded caches reuse civil-time resolution and the next +occurrence until the occurrence passes, the schedule changes, or the clock moves +backward; timezone formatters are also bounded. One-time schedules are absolute ISO instants at minute precision. The scheduler must be resynchronized after the host timezone changes because native registrations contain the already-resolved next instant. @@ -140,6 +171,23 @@ does not discard a successful directory response. Request deadlines remain active while response bodies are consumed, and unused bodies are canceled where possible. +Search and country browsing return explicit pages with a continuation cursor, +exhaustion flag, and per-source status. Each source advances by raw rows it +actually consumed; post-query filters do not pretend that rejected rows were +never fetched. Overflow candidates and seen identities stay in a bounded, +expiring discovery session. Radio Browser and Radio Garden have independent +progress, so mixed pages cannot skip one source's stations. Radio Garden's finite +snapshot is exhausted once consumed. A failed or canceled page does not commit +its progress, and replaying the last cursor returns that same page. The older +array APIs remain available for existing callers. + +Provider memory caches are bounded by entry count and estimated bytes. Durable +shards share a disk budget, preserve per-key write order, and keep reloadable +entries after memory eviction. Public JSON responses count bytes as they are +consumed, with the request deadline and cancellation retained through body +reading and parsing. Large JSON parsing uses a bounded worker queue instead of +blocking terminal input. + `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 @@ -158,17 +206,38 @@ so map exploration is not capped to globally popular stations. ## Playback Layer -`PlayerController` owns playback process lifecycle. +`RadioEngine` owns station tuning, cancellation, queue auto-skip, bounded +reconnection, metadata identity, and optional listening history for both TUI and +headless playback. Screens retain navigation and list selection. The engine +sets the candidate station before backend startup so early metadata is recorded +against the correct station. Backend exit, pause, and buffering close audible +listening intervals; an ended process cannot continue accumulating time. +Diagnosed transient transport failures can reconnect up to three times with +backoff. Normal EOF, unknown failures, authentication, and output initialization +errors remain visible instead of entering a retry loop. + +Production TUI playback attaches to the same detached owner used by shell +commands. `TuiSession`, `TuiRadioEngine`, and `TuiPlayer` adapt its authenticated +session to the existing screens. The owner retains the engine, stream control, +queue, listening checkpoints, desktop controls, and sleep timer when a terminal +dies. Reopening the TUI claims a new view only after the previous view's PID is +dead, then restores Now Playing without retuning. A normal quit explicitly +detaches, stops playback, and shuts down the owner. Alarms can preempt this owner +even while the TUI is absent. Inline playback remains available for restricted +runtime/loopback environments and injected test adapters. + +`PlayerController` owns playback process lifecycle within the playback owner. - Default local output: `mpv` - Playback-only fallbacks: `ffplay` and VLC -- Experimental macOS output: AirPlay/RAOP through Bonjour discovery, FFmpeg, - and RadioCLI's bundled sender bridge +- Experimental macOS/native Linux output: AirPlay/RAOP through Bonjour/Avahi, + FFmpeg, and RadioCLI's optional sender bridge - `mpv` is controlled over JSON IPC for pause, mute, volume, readiness checks, - and metadata polling. + property events, and fallback polling. The controller waits for backend readiness before reporting playback as active. -Tune attempts time out according to `settings.tuneTimeoutSeconds`. +Backend startup uses `settings.tuneTimeoutSeconds`; the engine also bounds the +whole resolution and auto-skip operation to 90 seconds. ### Audio Pipeline @@ -184,17 +253,39 @@ 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 +The optional AirPlay backend discovers RAOP receivers with Bonjour on macOS or +bounded Avahi queries on native Linux, and streams through a child worker that feeds decoded PCM from `ffmpeg` into a bundled sender bridge. The player requires an explicit receiver selection from Settings before it starts an AirPlay worker. AirPlay is a current-session output instead of an automatic startup default, so missing receivers do not trigger station-skip fallback. -With `mpv`, RadioCLI waits for the IPC socket to accept commands before marking -the stream as ready. It then polls playback state every 500ms and ICY metadata -every 2.5s. Metadata is cleaned before it is shown in Now Playing, the footer, -and the terminal/window media title. +Linux AirPlay also requires the per-library `linuxAirPlayEnabled` preference, +set explicitly through setup and disabled by default. macOS ignores this Linux +preference. The player, TUI, headless owner, and agent service enforce the same +policy. Platform eligibility, opt-in, and dependency/service readiness are +separate: an opted-in installation can still need setup, while Doctor can inspect +prerequisites without enabling playback or starting services. Hidden settings +and command suggestions use the same filtered items as keyboard navigation. + +The Linux desktop-media adapter shares the ordinary owner's command queue with +terminal and authenticated local controls. It resolves transport commands when +dequeued, publishes station and playback changes through MPRIS, and releases its +service during alarm takeover. Names are scoped to the RadioCLI data directory; +the bridge never steals another owner's name. It accepts only local Unix bus +addresses and backs off between reconnection attempts. Selective per-process mpv script +filtering avoids duplicate packaged MPRIS players while retaining user scripts. + +With `mpv`, RadioCLI waits for usable audio and IPC readiness before marking the +stream as ready. It subscribes to pause, metadata, and buffering properties; +observations are restored after an IPC reconnect. Elapsed-time and metadata +polling remain as fallbacks. Pause polling runs every 500ms while subscriptions +are unavailable and falls back to a five-second check after acceptance. Delayed +replies from an earlier player instance cannot overwrite the current state. +Metadata is cleaned before it is shown in Now Playing, the footer, and the +terminal/window media title. A ready AirPlay connection can retune in place; +the owner does not tear down the receiver before every station change. ### FFT And Signal Model @@ -233,6 +324,12 @@ Pure screen model helpers, tab definitions, filters, and text-editing helpers live in `src/ui/app-state.ts`. The Now Playing screen owns receiver layout while the receiver visualizer builders live under `src/ui/visualizers`. +Settings intents are ordered separately from rendering. Relative controls read +the latest committed settings when their turn begins, so rapid repeated keys +apply every change. Save confirmations follow durable acknowledgement. A new +station waits for earlier accepted settings before choosing its output; Stop +can cancel that wait and any pending retune. + Explore cursor movement is intentionally split between fine WASD nudges and Shift+WASD jumps. Fine movement keeps the map from leaping over local station clusters; jump movement preserves fast cross-continent travel. @@ -277,6 +374,23 @@ 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. +Long-lived TUI, headless, and MCP processes use `LibraryRepository`. It submits +ordered semantic operations to a worker that performs locking, validation, +merging, serialization, and replacement. A successful mutation acknowledgement +follows persistence. Filesystem events and a revision fallback refresh cached +snapshots after another process writes the library, including changes to agent +consent. Short-lived commands retain the synchronous transactional store. +Restricted hosts that cannot start a worker fall back before any operation is +sent; an ambiguous failure after startup is reported without replaying a toggle +that might already have committed. Shutdown drains accepted writes. + +Imported URL identities use SHA-256 rather than the legacy 32-bit hash. Known +legacy URL references migrate together across favorites, recents, presets, +alarms, and activity, retaining legacy IDs as aliases. Track keys migrate only +when their old identity maps unambiguously to one URL. Arbitrary imported IDs +retain their meaning; migration cannot recover an entry already overwritten by +a collision in an older version. + 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, @@ -284,8 +398,8 @@ 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 +The library and backup format remain JSON, with optional station aliases for +identity migration. 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, diff --git a/apps/docs/content/docs/changelog.mdx b/apps/docs/content/docs/changelog.mdx index ec6bb10..c0bd8e5 100644 --- a/apps/docs/content/docs/changelog.mdx +++ b/apps/docs/content/docs/changelog.mdx @@ -3,6 +3,76 @@ title: Changelog description: Release history for RadioCLI. --- +## 0.2.5 - 2026-10-18 + +### Added + +- Added direct shell playback, volume controls, station-name lookup, filtered + search, favorites, recents, named presets, and listening stats. +- Added a compact now-playing view with live track metadata and `--watch`, + readable terminal tables, JSON/plain output, and shell completion for Bash, + Zsh, Fish, and PowerShell. +- Added Linux desktop media controls for RadioCLI's station queue, metadata, + playback, and volume, with `radiocli media-controls auto|on|off` overrides. +- Added experimental Omarchy Quattro integration with preferred-terminal + launching and desktop/session diagnostics. +- Added experimental, opt-in Linux AirPlay with Avahi discovery, receiver + selection, passcode handling, and guided dependency setup. + +### Changed + +- Simplified shell help around eight everyday commands, with `help --all` for + the full reference, `play` to resume, and favorites, recent, and popular queues. +- Expanded Doctor with separate terminal, desktop media, audio, Omarchy, and + AirPlay readiness checks. +- Included station presets in library backup, restore, and concurrent saves. +- Reduced Gallop's drawing scale by 10% while preserving its proportions, + centering, and animation timing. +- Removed the twelve preview receiver scenes, retaining the original 63 styles + and Ultracode default. Retired selections migrate without resetting saved data. + +### Fixed + +- Kept TUI playback running after a terminal crash, recovering the active + station, queue, controls, and sleep timer when the terminal reopens. +- Recovered interrupted or stalled live mpv streams without restarting paused + sessions, finite recordings, or playback canceled by Stop. +- Prevented duplicate mpv desktop media players and kept desktop controls in + sync with the shared player, including live-edge resume and alarm transitions. +- Restored alarm volume to the original Linux output after default-device + changes and overlapping alarms, and kept imported alarm IDs distinct across + case-insensitive and Unicode-normalizing filesystems. +- Preserved AirPlay mute, volume, and station metadata during connection setup, + added legacy RAOP password authentication, and improved connection errors. +- Protected concurrent library saves and preserved the latest data in the safety + backup before a restore. Retried temporary Windows file-replacement conflicts. +- Preserved migrated custom-station IDs, corrected XSPF CDATA and Unicode URL + imports, and restored Windows drive-path playlist imports. +- Corrected mixed Arabic/Latin terminal layout and emoji clipping, padding, and + scrolling without changing plain output or screen-reader behavior. +- Expired future-dated provider cache entries after clock corrections and + preserved valid data when migrating older caches. +- Preserved package-manager paths during Unix updates and allowed slower + PowerShell startup when opening native Windows terminals. +- Fixed a documentation typecheck race and added a single required CI gate for + platform, playback, and documentation checks. Release checks now include + packed upgrades from 0.2.3 and 0.2.4 and native TUI crash recovery. + +### Security + +- Removed the optional AirPlay sender's vulnerable elliptic dependency, using + Node's native Ed25519 while preserving pairing formats and optional installs. +- Enabled native stream certificate verification and restricted FFmpeg network + playlists that cannot safely carry TLS policy. Documented the remaining + native OpenSSL numeric-IP certificate identity limitation. +- Kept Doctor's probes read-only, bounded local service checks, and excluded + receiver codes and stream credentials from AirPlay diagnostics. +- Updated documentation and development dependencies to patched releases. + +Quattro integration and Linux AirPlay remain experimental. Software playback +checks do not establish physical receiver, speaker, hardware-key, or suspend/wake +support; existing platform support tiers are unchanged. + ## 0.2.4 - 2026-09-17 ### Added diff --git a/apps/docs/content/docs/getting-started/cli.mdx b/apps/docs/content/docs/getting-started/cli.mdx index be2e6ff..edddf75 100644 --- a/apps/docs/content/docs/getting-started/cli.mdx +++ b/apps/docs/content/docs/getting-started/cli.mdx @@ -1,6 +1,6 @@ --- title: CLI Usage -description: Command-mode entry points for searching, checking, data transfer, and scheduled radio. +description: Play, discover, save, and control radio from your shell, with readable output and scriptable commands. --- RadioCLI opens the full TUI by default: @@ -9,11 +9,203 @@ RadioCLI opens the full TUI by default: radiocli ``` -It also supports command mode for scriptable tasks: +## Everyday Listening + +Start listening from your shell without opening a full-screen interface or +enabling MCP. The same `play` command starts and continues listening: + +```bash +radiocli play jazz +radiocli pause +radiocli play +radiocli stop +``` + +These eight commands cover everyday listening: + +| Command | What it does | +| --- | --- | +| `play [station]` | Find and play a station, or continue listening. | +| `pause` | Pause the current station. | +| `next` | Tune the next station in the current queue. | +| `now` | Show the station, track, and volume. | +| `volume 40` | Set volume from 0 to 100. | +| `favorite` | Save the current station. | +| `favorites` | List saved stations; add `--pick` to choose one. | +| `stop` | Stop listening and close the background player. | + +`radiocli help` shows this short guide. `radiocli help --all` groups the full +command list by purpose, and `radiocli help play` explains one command. +`previous`, `mute`, `unmute`, and `resume` remain available when you need them. + +`play` starts a background player and returns to your prompt. If the TUI is +already open, commands control that player instead. The CLI uses the same +library, playback backends, metadata, and listening history as the TUI. +`stop` closes background playback; use `play` to start again. Pause, volume, +and mute depend on backend capabilities; `mpv` provides full controls. + +Station selection accepts a saved preset, a station name, or an exact +`provider:id`. Saved presets and names are checked before the public directory. +A unique match plays immediately; multiple matches offer a numbered chooser in +an interactive terminal. In scripts, ambiguous names fail with matching IDs so +you can select the exact station. `play` with no argument resumes a paused +station, leaves playing audio uninterrupted, or restarts the selected station +after stopping. It preserves that station's queue. When no station is selected, +it starts your most recent station. + +```bash +radiocli play --favorites +radiocli play --favorites --random +radiocli play --recent --pick +radiocli play --popular +radiocli play --country JP +radiocli play "Groove Salad" --keep-playing +``` + +Choose one queue flag: `--favorites`, `--recent`, or `--popular`. The longer +`--source` form still accepts `recent`, `favorites` (also `favorite`), `popular`, +or `country`. Country queues require `--country` with a two-letter code. +Explicit queue or filter options select a station anew; they do not resume the +current selection. `--random` selects from the chosen +queue; `--keep-playing` preserves an already playing or paused station. Next +and previous follow the selected search or library queue, wrapping at its ends. +Ordinary playback commands do not dismiss or modify alarms, and `play` is +refused while an alarm occurrence is active. + +## Search And Choose + +```bash +radiocli search jazz --pick +radiocli search jazz --country US --language english --limit 10 +radiocli search ambient --codec MP3 --min-bitrate 128 +radiocli countries --limit 20 +``` + +Search shows a table with station name, country, audio quality, and a `*` for +favorites. `--pick` lets you enter a result number to start playback. Enter `q`, +press Enter on an empty selection, or press Ctrl+C to cancel. The chooser +preserves terminal scrollback and retries invalid numbers. + +Search and station-name playback accept `--country`, `--language`, `--codec`, +`--min-bitrate`, and `--limit`. Limits range from 1 through 100; search defaults +to 20. An empty result includes a suggestion to broaden the query. Station +names beginning with a dash can follow `--`, for example +`radiocli search -- "-station name"`. + +## Now Playing + +```bash +radiocli now +radiocli now --watch +radiocli status --json +``` + +The compact view includes station, current track when supplied by the stream, +playback state, audio output, volume, and mute status. `status` is an alias for +`now`. `--watch` refreshes one terminal line when the displayed information +changes. Ctrl+C exits the view while playback continues. Screen-reader mode +prints changed states on separate lines instead of rewriting a line. + +## Favorites, History, And Presets + +```bash +radiocli favorite # save the active station +radiocli favorite "Groove Salad" +radiocli favorites --pick +radiocli recent --pick +radiocli unfavorite "Groove Salad" +radiocli stats +``` + +Favorites and recents accept `--limit` and `--pick`. Stats shows listening time, +sessions, station count, streaks, and your most-listened station. + +Presets give stations short names that are easy to remember and complete: + +```bash +radiocli preset save work # use the active station +radiocli preset save evening "Groove Salad" +radiocli play work +radiocli presets # also: radiocli preset list +radiocli preset remove evening +``` + +Preset names are case-insensitive and contain 1–40 letters, numbers, hyphens, +or underscores, starting with a letter or number. Saving an existing name +replaces its station. Removing a preset removes only the shortcut. Up to 200 +presets are stored in the local library and included in JSON backup/restore. +M3U export continues to contain favorites and imported stations. + +## Output And Scripts + +Listener commands support consistent output options: + +| Option | Behavior | +| --- | --- | +| `--json` | One structured JSON result, with no terminal decoration. | +| `--plain` | Tab-separated output, even in an interactive terminal. | +| `--quiet`, `-q` | Suppress successful output; errors still fail. | +| `--no-input` | Never prompt; ambiguous matches require an exact ID. | +| `--no-color` | Disable terminal colors. `NO_COLOR` is also respected. | +| `--help`, `-h` | Command-specific usage and examples without playback or network access. | + +These options apply to playback, search, countries, library, preset, stats, +and now-playing commands. Existing setup, alarm, update, and agent commands +retain their own documented options. + +```bash +radiocli search jazz --json +radiocli favorites --plain +radiocli play radio-browser:STATION_ID --no-input --quiet +radiocli now --json +radiocli help play +radiocli help --all +``` + +Output automatically becomes plain when piped. Search, favorites, and recents +use five TSV columns: `provider:id`, name, country, codec, and bitrate. Country +rows retain code, station count, and name. Preset rows use name, station ID, +and station name. Now-playing and stats use key/value TSV rows. Control results +use `ok` or `error` followed by the message. Empty lists have no TSV rows. + +JSON search/library results are arrays with `id`, `name`, `country`, +`countryCode`, `codec`, `bitrate`, `language`, `tags`, and `favorite`. Now-playing +JSON is the session snapshot, or `null` when no player is active. Control +results include `ok`, `message`, and the session status when available. +`--pick` and `--watch` require an interactive terminal and cannot be combined +with JSON, plain, quiet, or no-input mode. Successful commands exit with 0; +errors exit with 1; cancelling a picker or watch with Ctrl+C exits with 130. + +## Shell Completion + +Load completion for your shell, then add the same line to its profile if you +want it available in new sessions: + +```bash +# Bash +source <(radiocli completion bash) + +# Zsh, after compinit +source <(radiocli completion zsh) + +# Fish +radiocli completion fish | source +``` + +```powershell +# PowerShell +radiocli completion powershell | Out-String | Invoke-Expression +``` + +Completion suggests commands, command-specific flags, common filter values, +and saved preset names. It does not query public station directories or change +your shell configuration. Misspelled commands suggest a likely correction +without executing it. + +## Other Commands ```bash radiocli doctor -radiocli doctor --json radiocli setup radiocli setup --dry-run radiocli setup --mcp @@ -22,8 +214,6 @@ radiocli mcp repair radiocli update --install radiocli agent status radiocli check -radiocli countries -radiocli search "japan hits" radiocli import https://example.com/live.mp3 radiocli import stations.m3u radiocli export favorites.m3u @@ -44,6 +234,24 @@ fallback playback with limited controls. Use `radiocli doctor --json` for a machine-readable support report; paths under your home directory are redacted to `~`. +Doctor also separates Omarchy release evidence, terminal prerequisites, desktop +media policy, user-bus and audio readiness, scheduling, and AirPlay prerequisites. +It does not browse receivers, open a terminal, or register an MPRIS player. +An available tool is not proof of audible output or a working receiver. + +Configure Linux desktop media controls with: + +```bash +radiocli media-controls # show saved and effective policy +radiocli media-controls auto # packaged Quattro local desktop +radiocli media-controls on # opt in on another Linux desktop +radiocli media-controls off +RADIOCLI_DESKTOP_MEDIA=off radiocli # override for this process +``` + +Restart active sessions after changing the saved setting. Other platforms retain +their existing media-key behavior; this setting selects the Linux MPRIS bridge. + Use `radiocli check` when debugging playback or provider issues. It reports local store paths, playback backends, and provider health. Include that output when filing playback issues. @@ -90,8 +298,8 @@ update through MCP without the user running or approving an update path. ## Local Agent And MCP Control The MCP server exposes typed tools for status, discovery, playback, favorites, -listening stats, appearance, alarms, macOS AirPlay, and configurable completion -actions. This includes creating, editing, enabling, disabling, listing, +listening stats, appearance, alarms, experimental macOS/Linux AirPlay, and +configurable completion actions. This includes creating, editing, enabling, disabling, listing, synchronizing, and explicitly confirmed alarm removal, plus dismiss, snooze, keep-playing, and interactive handoff for a ringing occurrence. It accepts only saved stations or opaque station IDs returned by RadioCLI discovery tools; @@ -119,9 +327,11 @@ radiocli agent done # runs the completion preset ``` Harnesses without MCP can use the existing complete `radiocli alarm` command -set for alarm creation and control. AirPlay commands are available only on -macOS, select receivers by opaque IDs returned from discovery, and never save a -receiver passcode. Scheduled alarms continue to use local speakers; selecting +set for alarm creation and control. AirPlay commands are available on macOS and +native Linux after `radiocli setup --airplay`, select receivers by opaque IDs +returned from discovery, and never save a receiver passcode. Agent and headless +controls respect the same Linux opt-in as the TUI; a saved receiver or installed +dependency does not enable it. Scheduled alarms continue to use local speakers; selecting AirPlay affects only interactive playback and cannot silently reroute an unattended alarm. diff --git a/apps/docs/content/docs/getting-started/install.mdx b/apps/docs/content/docs/getting-started/install.mdx index b24cda0..9848c9c 100644 --- a/apps/docs/content/docs/getting-started/install.mdx +++ b/apps/docs/content/docs/getting-started/install.mdx @@ -57,7 +57,7 @@ RadioCLI needs: - 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 -- FFmpeg on macOS for experimental AirPlay and optional `ffplay` fallback +- FFmpeg for experimental macOS/Linux AirPlay and optional `ffplay` fallback - `ffplay` from FFmpeg as an optional playback-only fallback - VLC (`cvlc`/`vlc`) as an additional playback-only fallback when `mpv` and `ffplay` are unavailable. RadioCLI also probes common install locations @@ -88,7 +88,9 @@ radiocli ``` Run `radiocli doctor` to check executable discovery and launch diagnostics. -Tuning a station verifies the actual audio path. +Add `--playback` to rehearse local WAV playback through each installed local +backend using null or dummy audio. Tuning a station and checking the intended +speakers, headphones, or receiver verifies the actual output path. ## npm Install @@ -104,8 +106,15 @@ 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 -by checking executable availability. `mpv` is selected by default everywhere. FFmpeg is +by checking executable availability and awaiting a local WAV decoder rehearsal. +Each backend reports `passed`, `failed`, or `unverified`; a successful null/dummy +rehearsal does not verify a physical audio device, an AirPlay receiver, or a live +network stream. Repeat this check with `radiocli doctor --playback --json`. +Dry runs do not launch playback. `mpv` is selected by default everywhere. FFmpeg is also selected by default on macOS because it enables experimental AirPlay. +Native Linux additionally offers an optional experimental AirPlay setup choice. +It is off by default; `--yes` and `--all` alone do not enable it or change an +existing AirPlay opt-in. macOS keeps its existing FFmpeg default. ```text RADIOCLI SETUP RECEIVER @@ -168,8 +177,8 @@ brew install mpv brew install ffmpeg ``` -RadioCLI discovers AirPlay/RAOP receivers with Bonjour and decodes streams with -FFmpeg. The sender is an optional package dependency and must install and load +On macOS, RadioCLI discovers AirPlay/RAOP receivers with Bonjour and decodes +streams with 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 @@ -208,6 +217,50 @@ radiocli setup radiocli ``` +On Omarchy Quattro, setup retains Arch's `pacman -S --needed` package route. +If repository metadata is stale, use Omarchy's supported update workflow before +retrying setup; RadioCLI does not run a partial system upgrade or bypass its +pacman guard. Its native terminal integration follows your selected desktop +terminal while preserving explicit terminal overrides. + +Experimental Linux AirPlay is disabled until you opt in for the current library. +It needs FFmpeg, `avahi-browse`, a running Avahi daemon, and an installed, +loadable optional sender: + +```bash +radiocli setup --airplay --dry-run # inspect packages and service actions +radiocli setup --airplay # review and accept experimental setup +radiocli doctor +radiocli setup --no-airplay # disable RadioCLI's Linux AirPlay opt-in +``` + +Restart any running RadioCLI session after changing this preference. + +Setup has Avahi package recipes for apt and pacman. Arch's +[`avahi` package](https://archlinux.org/packages/extra/x86_64/avahi/files/) +provides the browser and daemon; Debian/Ubuntu use `avahi-daemon` and +[`avahi-utils`](https://packages.debian.org/bookworm/avahi-utils). +On a systemd host, the reviewed plan can include enabling and starting +`avahi-daemon.service`, a system service for discovery on the local network. +Other package or service arrangements receive manual setup guidance. Startup +and Doctor never install packages or activate the service. Disabling RadioCLI's +opt-in leaves shared packages and system services installed and running. + +An absent optional sender requires repairing the RadioCLI installation with +optional dependencies included; installing FFmpeg alone does not install the +sender. A dry run does not change preferences, install packages, or start services. + +After opting in, open Settings → AirPlay or `:airplay` to discover receivers, +then select one explicitly each session. Missing dependencies show **Setup required**; +a temporary discovery or receiver failure stays visible instead of hiding the feature. +Unsupported platforms and Linux without the opt-in hide AirPlay controls and +suggestions. WSL and Termux remain unsupported even if their tools are installed. + +Linux startup never scans for receivers. Offline mode blocks discovery; +low-bandwidth mode permits an explicit refresh. `--omit=optional` retains local +playback but omits AirPlay. Software RAOP tests do not establish compatibility +with every physical or AirPlay 2 receiver. + 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 @@ -287,7 +340,7 @@ through the OS installation tools before installing mpv. See NetBSD's 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 +omitting the optional AirPlay sender. After providing the matching Node/npm/player prerequisites, a manual registry installation uses: ```bash diff --git a/apps/docs/content/docs/getting-started/tui-controls.mdx b/apps/docs/content/docs/getting-started/tui-controls.mdx index de2f84b..1700052 100644 --- a/apps/docs/content/docs/getting-started/tui-controls.mdx +++ b/apps/docs/content/docs/getting-started/tui-controls.mdx @@ -270,8 +270,9 @@ Learn keys from Settings or with `:learn previous`, `:learn play`, and Location behavior is covered in [Privacy and Security](../privacy-security.mdx). -On macOS, Settings opens a dedicated AirPlay receiver picker for Bonjour -receivers. Change Audio output to AirPlay with `o`, open AirPlay with `a` or +On macOS and opted-in native Linux, Settings opens an experimental AirPlay receiver +picker using Bonjour or Avahi respectively. Change Audio output to AirPlay +with `o`, open AirPlay with `a` or `:airplay`, and choose a visible receiver. If a station is already playing, RadioCLI moves it to the selected receiver immediately; switch Audio output back to `This device (mpv)` to leave AirPlay. RadioCLI does not auto-pick a receiver. @@ -279,3 +280,21 @@ AirPlay is a current-session output: restarting RadioCLI returns to automatic local playback while remembering the last receiver for the next manual switch. If the receiver asks for a code, RadioCLI opens the AirPlay Code screen; you can also press `c` from AirPlay settings or use `:airplay-code 1234`. + +On Linux, run `radiocli setup --airplay` first. AirPlay settings and suggestions +are hidden until you opt in, and remain hidden on unsupported platforms. +An opted-in installation with missing dependencies shows **Setup required**. +Temporary service or receiver failures stay visible with an explanatory message. +macOS keeps its existing picker and setup defaults. + +Linux discovery starts only when you open or refresh the picker. Offline mode +blocks it; low-bandwidth mode still permits an explicit refresh. Receiver +selection is required in each process, including when a previous choice was +saved. Pause remains a local mpv feature; AirPlay supports volume and mute. + +On eligible Omarchy Quattro desktops, the application-owned MPRIS bridge lets +hardware media keys and the optional shell media widget control the current +station queue. It also works when MCP is disabled. Use +`radiocli media-controls [auto|on|off]` and restart the active session to change +that policy, or set `RADIOCLI_DESKTOP_MEDIA=off` for an environment override. +Existing learned terminal media-key sequences remain available. diff --git a/apps/docs/content/docs/platforms.mdx b/apps/docs/content/docs/platforms.mdx index 0662f87..dc36004 100644 --- a/apps/docs/content/docs/platforms.mdx +++ b/apps/docs/content/docs/platforms.mdx @@ -68,6 +68,39 @@ 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. +## Omarchy Quattro + +RadioCLI keeps Omarchy under the Linux platform ID. Packaged stable 4.x releases +are distinguished from development checkouts, legacy Omarchy, unknown versions, +and ordinary Arch/Hyprland installations. The integration contract was inspected +against [Omarchy v4.0.4](https://github.com/omacom/omarchy/releases/tag/v4.0.4). +This is an experimental desktop integration: a fresh or upgraded Quattro desktop, +hardware media keys, physical audio outputs, suspend/wake, and physical AirPlay +receivers have not completed acceptance. Detection does not promote support tiers +or establish that every 4.x release has been exercised. + +In a detected local Quattro desktop session, the native terminal route uses +`omarchy-launch-tui`, UWSM, and `xdg-terminal-exec` with the RadioCLI app ID. +Explicit RadioCLI terminal choices and valid `TERMINAL`/`TERM_PROGRAM` selections +keep precedence. The existing direct terminal routes remain fallbacks. + +`radiocli media-controls auto` enables the application-owned MPRIS bridge only +for packaged Quattro, a local Unix session bus, and a compatible mpv configuration. +Use `on` to opt in on another Linux desktop, or `off` to disable it, then restart +active sessions. `RADIOCLI_DESKTOP_MEDIA=off` overrides the saved setting. +The bridge controls the real station queue independently of MCP, yields during +alarms, and publishes no stream URLs or fabricated duration/seek support. It +suppresses only the recognized packaged mpv MPRIS script in its own player +process; unrelated mpv configuration and scripts are retained. An ambiguous +configuration disables the bridge with a Doctor explanation. RadioCLI does not +change Hyprland bindings, themes, or the optional Quickshell media widget. + +Local Linux arm64 checks exercised real D-Bus transport, packaged mpv MPRIS +coexistence, PipeWire/PulseAudio null outputs, and a Shairport Sync software +receiver on Node 22 and 24. They verify protocols and decoded PCM, not speaker +output or a Quattro desktop. The new Linux CI checks cover those protocols; +the existing OS/CPU matrix and support distinctions remain in place. + ## Node.js Runtime Reference The version numbers here are upstream binary or build baselines. Node.js also @@ -201,7 +234,7 @@ Android permission and execution checks still apply. | --- | --- | --- | --- | --- | | 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 | +| Linux, including musl | Accessible systemd user manager and timer required | Working `wpctl`, `pactl`, or `amixer` | Live `systemd-inhibit` lease; no privileged `WakeSystem` request | Experimental, off by default; opt in with `radiocli setup --airplay`. FFmpeg, Avahi, usable optional sender, and explicit receiver selection required; each CPU/libc combination needs verification | | 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 | diff --git a/apps/docs/content/docs/privacy-security.mdx b/apps/docs/content/docs/privacy-security.mdx index 707892c..3d59b44 100644 --- a/apps/docs/content/docs/privacy-security.mdx +++ b/apps/docs/content/docs/privacy-security.mdx @@ -5,16 +5,26 @@ description: Local-first data behavior, approximate location lookup, and vulnera RadioCLI does not require secrets or accounts. -## Local Agent Control +## Local Playback And Agent Control -Agent control is opt-in. Its MCP transport is stdio, and active playback uses a +Agent control is opt-in, and its MCP transport is stdio. Ordinary CLI commands +work independently of that setting. An open TUI or background player uses a 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. +competing players. CLI commands use this same authenticated session to control +the existing player. They do not enable MCP or install agent integrations. +The endpoint is not exposed to the LAN or internet. + +The TUI attaches as a view of this playback owner. If its terminal crashes, the +owner keeps the audio and control channel alive; reopening `radiocli` reconnects +without creating a second player. Normal TUI quit stops the owner. Attachment +uses a process-liveness check and a per-view identifier to prevent a second live +terminal or a stale detach from displacing the current view. AirPlay codes stay +in the playback process's memory and are not added to the discovery file. + +CLI, 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 @@ -41,11 +51,22 @@ external terminal windows or app-control prompts. - Recents, favorites, settings, imports, listening activity, alarm definitions, and provider cache data are stored locally on your machine. - 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 + to `mpv`, `ffplay`, or the experimental macOS/native Linux AirPlay worker. +- AirPlay discovery uses Bonjour on macOS or Avahi on native Linux. Linux + requires an explicit per-library setup opt-in and opening or refreshing the + picker; startup never scans. The opt-in setup plan may install Avahi and + explicitly enable its system discovery service after the plan is accepted. + Turning off RadioCLI's opt-in does not disable a service used by other apps. + AirPlay streaming runs 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. +- Linux desktop media controls expose station/title and playback state to the + local user bus, with stream URLs and private paths removed. The bridge is + independent of agent/MCP consent and yields during alarms. Disable it with + `RADIOCLI_DESKTOP_MEDIA=off`. Doctor checks readiness without browsing receivers + or registering a media player. RadioCLI does not persist receiver codes or + forward them in worker diagnostics. - 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. Offline mode also prevents the vote diff --git a/apps/docs/content/docs/release-packaging.mdx b/apps/docs/content/docs/release-packaging.mdx index cb0c2d4..b587db2 100644 --- a/apps/docs/content/docs/release-packaging.mdx +++ b/apps/docs/content/docs/release-packaging.mdx @@ -29,10 +29,29 @@ npm run verify:release npm pack --dry-run ``` +`verify:release` requires a clean committed checkout. It records each completed +gate, requires native mpv in both packed installation modes, and saves the exact +tested tarball under `output/release-evidence/`. Its manifest links the source +revision and dependency lockfile digest to the tarball SHA-256, runtime, audit, +and test outcomes. Publish that preserved tarball after reviewing the release +evidence; repacking would create a candidate that needs its own verification. +Set `RADIOCLI_SMOKE_EVIDENCE_DIR` to choose another destination outside the +tracked source tree. Failed or interrupted checks cannot produce a complete +manifest. An unavailable npm audit now exits unsuccessfully and records +`unavailable`; it must be rerun successfully before release. + +The local release gate also requires built TUI crash recovery and installs, +audits, typechecks, and builds the documentation site. This catches documentation +security or build failures before they can fail the release's CI run. + +Publishing from a source directory runs the same `verify:release` gate through +`prepublishOnly`. Publishing a preserved tarball still requires reviewing its +recorded evidence; npm cannot rerun the source verification from that artifact. + Test the packed tarball locally: ```bash -VERSION=0.2.4 +VERSION=0.2.5 npm pack npm install -g "./ciphore-radiocli-$VERSION.tgz" radiocli --help @@ -44,7 +63,7 @@ Publish: ```bash npm login -npm publish --access public +npm publish ./output/release-evidence/ciphore-radiocli-0.2.5.tgz --access public ``` After publishing, `npm install -g @ciphore/radiocli` should install the @@ -76,6 +95,63 @@ 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`. +The evidence directory also retains the tested tarball and a `candidate.json` +source record. `--require-clean` rejects a dirty or unknown source state when +running the fresh-install harness directly. + +The same harness upgrades separate installations of published versions 0.2.3 +and 0.2.4 to the candidate tarball. It keeps each older storage implementation +running throughout the replacement, then checks all 63 supported receivers while +old and new processes read and write one library. Favorites, recent stations, +track/search history, alarms, and the previously selected receiver must survive. +Each `upgrade-.json` record is bound to the tested artifact, source, and +runtime; missing or failed upgrade evidence blocks a complete release manifest. + +The twelve removed preview scenes fall back to Ultracode when loading a library, +a preview receiver-preference file, or a JSON backup. Favorites, history, alarms, +and other settings remain intact. Removed scenes are absent from cycling and MCP +appearance choices. + +### Device Rehearsal + +The manifest covers one OS, CPU, and Node runtime. Review all required CI matrix +jobs for the same source revision and retain their artifacts before publication. +A local or CI null-output pass leaves physical speakers, headphones, receiver +connections, terminal applications, and suspend/wake delivery unverified. + +The required `smoke:network` check uses only loopback fixtures and null/dummy +audio. It exercises trusted and untrusted certificates, hostname mismatches, +redirects, live EOF, open-but-stalled streams, finite recordings, and Stop after +recovery. Installed fallbacks also exercise decoded readiness, ordinary local +and HTTP media, and blocked unsafe nested requests. The dedicated Linux jobs +require ffplay, FFmpeg and VLC rather than silently skipping missing fallbacks. +Numeric-IP certificate mismatch behavior is recorded as a known upstream +limitation when the native TLS backend accepts it. + +The pinned Windows player uses Schannel, which ignores a private CA file. +Windows still requires untrusted-certificate, redirect and nested-request +rejection, finite HTTP playback, recovery and Stop checks. Trusted private-CA +playback and trusted DNS/IP identity fixtures remain explicitly unverified +there; the harness never installs test roots into the user's trust store. +`RADIOCLI_SMOKE_PRIVATE_CA=1` opts into those additional fixtures on a Windows +test installation known to use a CA-file-capable backend. The per-check outcomes +are retained in `network-playback.json` and bound to the release revision/runtime. + +On each intended device and output route, retain the candidate SHA-256 and +`radiocli doctor --playback --json`, then record observed results for these paths: + +| Path | Evidence to record | +| --- | --- | +| Interactive TUI | Start a station, change stations, change volume, pause/resume where supported, then stop; verify audible output and keyboard response. | +| Background CLI and MCP | Play and inspect status, replace a pending tune, stop during startup, and confirm the native child exits. | +| Discovery and saved library | Apply minimum bitrate, load several search/country pages, save a favorite from another process, and confirm the open session refreshes. | +| AirPlay, where supported and enabled | Discover and authenticate the actual receiver, tune twice on the same receiver, change volume, return to local output, and verify cleanup. | +| Alarms and desktop integration, where supported | Exercise a short alarm, snooze/dismiss, restore the previous output, and test media keys and suspend/wake on the actual desktop session. | +| Fallback players | Select installed ffplay or VLC, confirm audible playback and stop, and retain the documented limited-control behavior. | + +Record `passed`, `failed`, or `unverified` with the OS/CPU, player version, output +device or receiver, and terminal application. A route that was not exercised +retains `unverified`; success on a different device does not fill that gap. ### Release Gates And Experimental Checks @@ -87,6 +163,13 @@ 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. +The stable **Required Release Checks** job aggregates the complete verification, +native playback and documentation matrices. Maintainers should select this +check in branch protection after the workflow has run. The repository files do +not activate remote protection settings. Evidence completeness also requires +the source MCP, background CLI and native network checks; a skipped or absent +check cannot produce a passing release manifest. + 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 @@ -117,7 +200,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.4 +VERSION=0.2.5 TARBALL="$(npm view @ciphore/radiocli@$VERSION dist.tarball)" curl -L "$TARBALL" -o "/tmp/radiocli-$VERSION.tgz" shasum -a 256 "/tmp/radiocli-$VERSION.tgz" @@ -180,9 +263,27 @@ 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. Its optional sender dependency must install -and load successfully, and Bonjour and FFmpeg must be usable. The Homebrew +AirPlay is experimental on macOS and native Linux. Its optional sender dependency +must install and load successfully, and Bonjour or Avahi plus FFmpeg must be +usable. Linux additionally requires the per-library opt-in from +`radiocli setup --airplay`; installed dependencies do not enable it. 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 +The fixed sender source ships with RadioCLI; its runtime dependencies remain +optional. The required runtime audit includes those optional dependencies and +fails on advisories at every severity. AirPlay should remain an explicit session output rather than the automatic startup fallback. + +Linux CI includes real D-Bus/mpv-MPRIS checks, isolated PipeWire/PulseAudio output +restoration, and Shairport Sync software receiver checks for discovery, decoded +PCM, password entry, volume/mute, retuning, metadata, and cleanup. These tests do +not establish a Quattro desktop, audible speakers, physical receiver compatibility, +or suspend/wake delivery. Fresh and upgraded Quattro desktop acceptance and +physical macOS/Linux receiver regression remain release gates for a broader +verified-support claim; keep the desktop integration experimental meanwhile. + +To roll back the desktop bridge, set `RADIOCLI_DESKTOP_MEDIA=off` and restart +RadioCLI. A 0.2.4 library rewrite can discard the additive `desktopMediaControls` +setting, causing it to default to `auto` after upgrading again; the environment +override survives that downgrade. Existing station/library data stays compatible. +After changing executable paths or restoring an older installation, run +`radiocli alarm sync` and verify alarm setup again. diff --git a/apps/docs/content/docs/reliability.mdx b/apps/docs/content/docs/reliability.mdx index 5405d61..8e2d892 100644 --- a/apps/docs/content/docs/reliability.mdx +++ b/apps/docs/content/docs/reliability.mdx @@ -28,9 +28,15 @@ requirement. - tune timeout - skip-to-next on failed tune when enabled - `mpv` IPC readiness check +- TUI reattachment to a surviving playback owner, retaining controls, queue, + listening history, and sleep timer without restarting the stream +- decoded-audio readiness for `ffplay`, and audio-output plus decoder-buffer + readiness for VLC; a surviving process or HTTP headers alone are insufficient +- bounded reconnection after live `mpv` EOF or missing audio-clock progress, + canceled by Stop or a replacement tune - explicit limited-control state when only `ffplay` fallback playback is available -- AirPlay sender high-risk dependency gate, bounded Bonjour discovery, bounded +- AirPlay sender high-risk dependency gate, bounded Bonjour/Avahi discovery, bounded worker messages, passcode validation, and worker shutdown on startup timeout - playback-gated receiver animation and zero-signal visualizer frames for inactive playback states @@ -100,10 +106,23 @@ 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 +A failed lock, write, permission change, file sync, or rename leaves the previous committed state intact. Cleanup errors cannot replace the original failure or make a completed replacement appear to have failed. +Each lock records a unique owner generation. A slow or suspended live writer +does not lose ownership because of elapsed time. Only a positively dead owner +or an explicitly released generation is reclaimed automatically. Unknown and +legacy empty locks remain busy: close all RadioCLI processes before removing +such a lock, and close older versions before upgrading. Keep the library on a +local filesystem; shared filesystems and different hosts do not share PID +ownership semantics. + +The replacement file is synced before rename. RadioCLI also attempts to sync +the parent directory after the commit; some operating systems and filesystems +do not support directory sync. Atomic replacement and best-effort directory +sync do not guarantee survival of every hardware or power failure. + 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 @@ -112,6 +131,40 @@ explicit saves and report failure without changing the saved state. Check the directory's permissions or choose a private writable location with `RADIOCLI_HOME`. +## Playback Recovery And Secure Streams + +Live directory stations reconnect after unexpected EOF or a stopped audio +clock. Imported HTTP inputs qualify when they are nonseekable and have no known +finite duration. Local files and known finite imported recordings stop at EOF. +The stall deadline is at least 15 seconds and never shorter than the tune +timeout. Paused playback does not trigger the watchdog. + +Recovery uses at most three attempts with delays of 1, 2, and 4 seconds. Stop, +station replacement, and alarm takeover cancel pending recovery. The retry +budget resets after 60 seconds of uninterrupted, ready, unbuffered playback; +brief reconnects cannot create an endless retry loop. Certificate, authentication, +decoder, and output-device failures are not treated as transient transport errors. + +RadioCLI passes `--tls-verify=yes` to mpv, including for redirected and nested +streams. Native certificate stores and supported player CA configuration still +apply. FFmpeg/ffplay receive `-tls_verify 1` for network inputs. Plain HTTP media +can need one compatibility restart because FFmpeg rejects an unused TLS option; +that restart blocks both HTTPS and TLS protocols, including changed redirects. + +Affected FFmpeg versions do not propagate certificate policy into HLS/DASH/IMF +child requests. FFmpeg fallbacks therefore accept a restricted set of direct +network media formats such as MP3, AAC, Ogg, FLAC and WAV. Use mpv for network +playlists; for AirPlay, choose a direct audio stream or return to **This device** +with mpv. Local ffplay inputs retain format support but cannot open network +resources. This is an intentional security restriction on previously accepted +inputs, not a claim of unchanged fallback format coverage. + +There is also a known native TLS limitation: tested FFmpeg/OpenSSL builds, +including mpv built against them, accept a trusted certificate with a mismatched +numeric IP address. DNS hostname mismatch checks pass our fixtures. Prefer DNS +station URLs; neither a separate preflight request nor these flags repair the +native IP identity check. The release smoke reports this outcome explicitly. + ## Scheduled Radio Delivery Saving an enabled alarm resolves its next occurrence and registers one native @@ -256,8 +309,10 @@ the launchd label alone does not change executable identity. - Some stations play silence while still looking technically connected. - Radio Garden can be blocked by edge protection. -- AirPlay support is experimental and macOS-only; it depends on the local - network, Bonjour discovery, and FFmpeg. Missing AirPlay receivers are output +- AirPlay support is experimental on macOS and explicitly opted-in native Linux; it depends on the + local network, Bonjour/Avahi discovery, FFmpeg, and a loadable optional sender. + Software RAOP checks do not establish every physical or AirPlay 2 receiver. + Missing AirPlay receivers are output failures, not broken station streams. - Station health is local and opportunistic; there is not yet a long-term health score per station. diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index 3f44cb0..b739866 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -18,6 +18,54 @@ scheduling and recorded delivery health. An available player does not establish desktop session, system mixer, or scheduler is usable. See [Platforms](/docs/platforms) for runtime requirements and verified coverage. +## Audio Continues After A TUI Crash + +Run `radiocli` again. The terminal reconnects to the existing playback owner and +opens Now Playing with its station, queue, metadata, volume, mute, pause state, +and sleep timer. Reconnecting does not restart the stream. You can also use +`radiocli now`, `radiocli pause`, `radiocli volume 40`, or `radiocli stop` while +the TUI is closed. Agent/MCP setup is not required. A normal TUI quit still +stops playback. + +Recovery uses the same private runtime directory and authenticated IPv4/IPv6 +loopback transport as the ordinary CLI. It adds no system service, native +dependency, or Unix-only process lookup. Existing backend limitations still +apply: ffplay/VLC do not gain mpv's interactive controls, and AirPlay remains +limited to its supported platforms. Live radio continues at its current +broadcast rather than replaying missed audio. + +The playback owner must still be running, and both launches must use the same +user and data/runtime environment, including `RADIOCLI_HOME` and +`XDG_RUNTIME_DIR` where set. Rebooting or killing the playback owner ends the +session. A second live TUI is refused so it cannot take control away from the +first terminal. + +After upgrading from a version without recovery, stop its existing player once +with `radiocli stop`, then reopen `radiocli` to start the updated playback owner. + +Settings updates keep the current station controllable during installation. +After a successful update, quit the TUI with `q` and reopen `radiocli` to start +the updated playback owner. Normal quit closes its player and private control +session on every supported OS; manually killing RadioCLI processes is not part +of the update flow. A failed update leaves the current session available. + +Restart or reconnect your MCP client after upgrading so it starts +`radiocli mcp serve` with the updated tool code. Reopening the TUI does not reload +an already running MCP server. Restarting MCP leaves ordinary playback and +ringing alarms running. TUI quit closes ordinary playback only; alarm runners, +their ringing controls, and saved native schedules remain separate. + +The twelve preview receiver scenes have been removed. Previously saved selections +fall back to Ultracode without resetting your library. The original 63 receivers +remain in their established order, with Gallop near the beginning and Crossfade +at the end. + +When runtime storage is unwritable or loopback is blocked, the TUI retains +in-process playback and reports that recovery is unavailable. Set +`RADIOCLI_HOME` to a private writable directory to fix runtime storage. Set +`RADIOCLI_TUI_PLAYBACK=inline` to explicitly use in-process playback when no +other playback owner is active; this mode cannot recover after a crash. + ## Playback Tool Installed But Not Found `radiocli doctor` prints the resolved `mpv` path, discovery method, version, and @@ -32,7 +80,7 @@ For a custom installation, set an absolute path to the native executable: | `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 | +| `RADIOCLI_FFMPEG_PATH` | FFmpeg, including the decoder used for experimental macOS/Linux 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 @@ -187,6 +235,22 @@ after correcting the path or permissions. 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. + Experimental AirPlay is hidden until you opt in with `radiocli setup --airplay`. + It separately needs `avahi-browse`, a running Avahi daemon, + FFmpeg, and the usable optional sender. An opted-in installation with missing + dependencies shows **Setup required**; repeat setup for installation guidance. + Doctor probes readiness without + searching the LAN; open the AirPlay picker to search explicitly. Selecting + local output remains available when a receiver or service fails. + +On Omarchy Quattro, use `radiocli doctor --json` to distinguish packaged-release +detection, terminal prerequisites, the user bus, audio service, scheduling, and +AirPlay readiness. Missing services do not disable unrelated local features. +If desktop controls are unavailable, check the reported media policy and mpv +configuration compatibility. `RADIOCLI_DESKTOP_MEDIA=off` disables the new +bridge for diagnosis; restart the active RadioCLI session after changing it. +Keep custom mpv scripts and desktop configuration intact. For stale Arch package +repositories, use Omarchy's supported update workflow before retrying setup. Use **Verify alarm setup** in the Alarms screen to check registration, terminal delivery, sleep protection, system volume, and bounded sample playback on the diff --git a/apps/docs/lib/seo.ts b/apps/docs/lib/seo.ts index abfae45..661c8fc 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.4', + softwareVersion: '0.2.5', license: licenseUrl, isAccessibleForFree: true, author: { diff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json index 70aa622..ce39e8a 100644 --- a/apps/docs/package-lock.json +++ b/apps/docs/package-lock.json @@ -1,19 +1,19 @@ { "name": "radiocli-docs", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "radiocli-docs", - "version": "0.2.4", + "version": "0.2.5", "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.5", + "next": "16.3.8", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -537,9 +537,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -555,13 +555,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -577,20 +577,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -600,9 +600,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -616,9 +616,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -632,9 +632,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -651,9 +651,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -670,9 +670,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -689,9 +689,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -708,9 +708,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -727,9 +727,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -746,9 +746,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -765,9 +765,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -784,9 +784,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -805,13 +805,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -830,13 +830,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -855,13 +855,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -880,13 +880,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -905,13 +905,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -930,13 +930,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -955,13 +955,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -980,13 +980,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1000,16 +1000,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1019,9 +1019,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1038,9 +1038,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1057,9 +1057,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "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==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1163,15 +1163,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", - "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "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==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "cpu": [ "arm64" ], @@ -1185,9 +1185,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "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==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "cpu": [ "x64" ], @@ -1201,9 +1201,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "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==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "cpu": [ "arm64" ], @@ -1220,9 +1220,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "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==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "cpu": [ "arm64" ], @@ -1239,9 +1239,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "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==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "cpu": [ "x64" ], @@ -1258,9 +1258,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "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==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "cpu": [ "x64" ], @@ -1277,9 +1277,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "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==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "cpu": [ "arm64" ], @@ -1293,9 +1293,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "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==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "cpu": [ "x64" ], @@ -5139,12 +5139,12 @@ } }, "node_modules/next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", - "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.5", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5158,14 +5158,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@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", + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { @@ -5660,9 +5660,9 @@ } }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -5677,31 +5677,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@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" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -5738,9 +5738,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/apps/docs/package.json b/apps/docs/package.json index 53ccb16..38ce738 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,6 +1,6 @@ { "name": "radiocli-docs", - "version": "0.2.4", + "version": "0.2.5", "private": true, "description": "Public website and documentation for RadioCLI.", "type": "module", @@ -8,7 +8,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "check": "fumadocs-mdx && next typegen && tsc --noEmit", + "check": "next typegen && fumadocs-mdx && tsc --noEmit", "postinstall": "fumadocs-mdx" }, "dependencies": { @@ -16,7 +16,7 @@ "fumadocs-mdx": "15.0.10", "fumadocs-ui": "16.9.3", "lucide-react": "^1.17.0", - "next": "16.3.5", + "next": "16.3.8", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/apps/docs/public/demo/radiocli-alarm-editor.gif b/apps/docs/public/demo/radiocli-alarm-editor.gif new file mode 100644 index 0000000..582a3fb Binary files /dev/null and b/apps/docs/public/demo/radiocli-alarm-editor.gif differ diff --git a/apps/docs/public/demo/radiocli-codex-birdsong.gif b/apps/docs/public/demo/radiocli-codex-birdsong.gif new file mode 100644 index 0000000..33074cd Binary files /dev/null and b/apps/docs/public/demo/radiocli-codex-birdsong.gif differ diff --git a/apps/docs/public/demo/radiocli-codex-birdsong.mp4 b/apps/docs/public/demo/radiocli-codex-birdsong.mp4 new file mode 100644 index 0000000..e441cd6 Binary files /dev/null and b/apps/docs/public/demo/radiocli-codex-birdsong.mp4 differ diff --git a/apps/docs/public/demo/radiocli-fullscreen.png b/apps/docs/public/demo/radiocli-fullscreen.png index ab7bfdb..c3c4707 100644 Binary files a/apps/docs/public/demo/radiocli-fullscreen.png and b/apps/docs/public/demo/radiocli-fullscreen.png differ diff --git a/apps/docs/public/demo/radiocli-now-playing.gif b/apps/docs/public/demo/radiocli-now-playing.gif index 2a9d6b3..ce8e129 100644 Binary files a/apps/docs/public/demo/radiocli-now-playing.gif and b/apps/docs/public/demo/radiocli-now-playing.gif differ diff --git a/apps/docs/public/demo/radiocli-now-playing.webm b/apps/docs/public/demo/radiocli-now-playing.webm index 7f1cf60..563d334 100644 Binary files a/apps/docs/public/demo/radiocli-now-playing.webm and b/apps/docs/public/demo/radiocli-now-playing.webm differ diff --git a/apps/docs/public/demo/silicon-valley-radio-on-internet.gif b/apps/docs/public/demo/silicon-valley-radio-on-internet.gif new file mode 100644 index 0000000..4223888 Binary files /dev/null and b/apps/docs/public/demo/silicon-valley-radio-on-internet.gif differ diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md index db74e3a..bbcd802 100644 --- a/docs/THIRD_PARTY_NOTICES.md +++ b/docs/THIRD_PARTY_NOTICES.md @@ -34,7 +34,9 @@ Earth vector data is public domain. ## node-airtunes2 -AirPlay support is provided as an optional integration with `node-airtunes2` -2.5.0, distributed under the GNU Affero General Public License v3. Its source, -license text, and attribution are available in the installed dependency and at -. +AirPlay support uses a source copy of `node-airtunes2` 2.5.0, distributed under +the GNU Affero General Public License v3, with RadioCLI's native Ed25519 security +fix. The original attribution, license, complete native source, changes and +archive provenance ship in `vendor/node-airtunes2/`. Runtime dependencies remain +optional. See `vendor/node-airtunes2/RADIOCLI_PATCH.md` and the original project +at . diff --git a/knip.json b/knip.json index 773cbac..0f03c64 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,14 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/smoke/*.ts", "src/player/airplay-worker.ts", "scripts/*.mjs"], - "project": ["src/**/*.ts", "src/**/*.tsx", "scripts/*.mjs"] + "entry": [ + "src/smoke/*.ts", + "src/player/airplay-worker.ts", + "scripts/*.mjs", + "vendor/node-airtunes2/lib/index.js" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "scripts/*.mjs" + ] } diff --git a/package-lock.json b/package-lock.json index 636d8db..41023d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,21 @@ { "name": "@ciphore/radiocli", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ciphore/radiocli", - "version": "0.2.4", + "version": "0.2.5", + "hasInstallScript": true, "license": "MIT", "dependencies": { + "@homebridge/dbus-native": "^0.7.9", "@modelcontextprotocol/server": "^2.0.0", "ink": "^7.0.5", "jsonc-parser": "^3.3.1", "react": "^19.2.6", + "string-width": "8.2.1", "zod": "^4.4.3" }, "bin": { @@ -26,13 +29,25 @@ "publint": "^0.3.21", "tsx": "^4.22.3", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.11" }, "engines": { "node": ">=22" }, "optionalDependencies": { - "node-airtunes2": "2.5.0" + "@noble/ed25519": "1.7.5", + "async": "3.2.6", + "axlsign": "git+https://github.com/ciderapp/curve25519-js.git#cae68782564b83ffa1c7c757e40a3944cb140290", + "big-integer": "1.6.52", + "bplist-creator": "0.1.1", + "bplist-parser": "0.3.2", + "chacha-js": "git+https://github.com/ciderapp/chacha20poly1305.git#f85941b8f5c62843a1459758cc3dbabad9f849cb", + "curve25519-js": "0.0.4", + "fast-srp-hap": "2.0.4", + "js-sha1": "0.7.0", + "lodash": "4.18.1", + "node-gyp": "12.3.0", + "python-struct": "1.1.3" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -48,73 +63,6 @@ "node": ">=18" } }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -591,6 +539,29 @@ "node": ">=18" } }, + "node_modules/@homebridge/dbus-native": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/@homebridge/dbus-native/-/dbus-native-0.7.9.tgz", + "integrity": "sha512-PFOeWyXfH2eoM4o6Zmj9PzCUDlrrbtrbAJVzHYXpXK5rh/dz84Ygu79WHKK3DWW2/oXieYPV6up+i1izSPbc3g==", + "license": "MIT", + "dependencies": { + "event-stream": "^4.0.1", + "hexy": "^0.4.0", + "long": "^5.3.2", + "minimist": "^1.2.8", + "safe-buffer": "^5.2.1", + "xml2js": "^0.6.2" + }, + "bin": { + "dbus2js": "bin/dbus2js.js" + } + }, + "node_modules/@homebridge/dbus-native/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -604,45 +575,13 @@ "node": ">=18.0.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "devOptional": true, + "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@modelcontextprotocol/core": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", @@ -1383,72 +1322,6 @@ "win32" ] }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/@publint/pack": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.4.tgz", @@ -1825,7 +1698,7 @@ "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1842,16 +1715,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1860,13 +1733,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1887,9 +1760,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1900,13 +1773,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1914,14 +1787,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1930,9 +1803,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1940,13 +1813,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1954,50 +1827,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@yao-pkg/pkg": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.16.1.tgz", - "integrity": "sha512-crUlnNFSReFNFuXDc4f3X2ignkFlc9kmEG7Bp/mJMA1jYyqR0lqjZGLgrSDYTYiNsYud8AzgA3RY1DrMdcUZWg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/generator": "^7.23.0", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "@yao-pkg/pkg-fetch": "3.5.16", - "into-stream": "^6.0.0", - "minimist": "^1.2.6", - "multistream": "^4.1.0", - "picocolors": "^1.1.0", - "picomatch": "^4.0.2", - "prebuild-install": "^7.1.1", - "resolve": "^1.22.0", - "stream-meter": "^1.0.4", - "tinyglobby": "^0.2.9" - }, - "bin": { - "pkg": "lib-es5/bin.js" - } - }, - "node_modules/@yao-pkg/pkg-fetch": { - "version": "3.5.16", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.16.tgz", - "integrity": "sha512-mCnZvZz0/Ylpk4TGyt34pqWJyBGYJM8c3dPoMRV8Knodv2QhcYS4iXb5kB/JNWkrRtCKukGZIKkMLXZ3TQlzPg==", - "license": "MIT", - "optional": true, - "dependencies": { - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.6", - "picocolors": "^1.1.0", - "progress": "^2.0.3", - "semver": "^7.3.5", - "tar-fs": "^2.1.1", - "yargs": "^16.2.0" - }, - "bin": { - "pkg-fetch": "lib-es5/bin.js" - } - }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -2008,44 +1837,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agent-base/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/agent-base/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true - }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -2085,13 +1876,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/arraybuffer-to-string": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arraybuffer-to-string/-/arraybuffer-to-string-1.0.2.tgz", - "integrity": "sha512-WbIYlLVmvIAyUBdQRRuyGOJRriOQy9OAsWcyURmsRQp9+g647hdMSS2VFKXbJLVw0daUu06hqwLXm9etVrXI9A==", - "license": "MIT", - "optional": true - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2127,27 +1911,6 @@ "license": "MIT", "optional": true }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -2158,50 +1921,6 @@ "node": ">=0.6" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bl/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "license": "MIT", - "optional": true - }, "node_modules/bplist-creator": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.1.tgz", @@ -2225,85 +1944,6 @@ "node": ">= 5.10.0" } }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "license": "MIT", - "optional": true - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/castv2": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/castv2/-/castv2-0.1.10.tgz", - "integrity": "sha512-3QWevHrjT22KdF08Y2a217IYCDQDP7vEJaY4n0lPBeC5UBYbMFMadDfVTsaQwq7wqsEgYUHElPGm3EO1ey+TNw==", - "license": "MIT", - "optional": true, - "dependencies": { - "debug": "^4.1.1", - "protobufjs": "^6.8.8" - } - }, - "node_modules/castv2-client": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/castv2-client/-/castv2-client-1.2.0.tgz", - "integrity": "sha512-2diOsC0vSSxa3QEOgoGBy9fZRHzNXatHz464Kje2OpwQ7GM5vulyrD0gLFOQ1P4rgLAFsYiSGQl4gK402nEEuA==", - "license": "MIT", - "optional": true, - "dependencies": { - "castv2": "~0.1.4", - "debug": "^2.2.0" - } - }, - "node_modules/castv2/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/castv2/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true - }, "node_modules/chacha-js": { "version": "2.1.1", "resolved": "git+ssh://git@github.com/ciderapp/chacha20poly1305.git#f85941b8f5c62843a1459758cc3dbabad9f849cb", @@ -2389,152 +2029,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "license": "ISC", - "optional": true, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", "license": "MIT", - "optional": true, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "license": "MIT", - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT", - "optional": true - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT", "optional": true }, @@ -2552,120 +2078,21 @@ "license": "MIT", "optional": true }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, - "node_modules/dns-js": { - "version": "0.2.1", - "resolved": "git+ssh://git@github.com/ciderapp/node-dns-js.git#212b6c903db40bcd501af741e51cd20d676acbc9", - "license": "MIT", - "optional": true, - "dependencies": { - "debug": "^2.1.0", - "qap": "^3.1.2" - }, - "engines": { - "node": ">= 4.1.0" - } - }, - "node_modules/electron-fetch": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/electron-fetch/-/electron-fetch-1.9.1.tgz", - "integrity": "sha512-M9qw6oUILGVrcENMSRRefE1MbHPIz0h79EKIeJWK9v563aT9Qkh8aEHPO1H5vi970wPirNY+jO9OpFoLiMsMGA==", - "license": "MIT", - "optional": true, - "dependencies": { - "encoding": "^0.1.13" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/elliptic": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "license": "MIT", - "optional": true, - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "optional": true, - "dependencies": { - "once": "^1.4.0" - } + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" }, "node_modules/env-paths": { "version": "2.2.1", @@ -2689,16 +2116,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -2758,16 +2175,6 @@ "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -2787,14 +2194,19 @@ "@types/estree": "^1.0.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "optional": true, - "engines": { - "node": ">=6" + "node_modules/event-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz", + "integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==", + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.1", + "from": "^0.1.7", + "map-stream": "0.0.7", + "pause-stream": "^0.0.11", + "split": "^1.0.1", + "stream-combiner": "^0.2.2", + "through": "^2.3.8" } }, "node_modules/expect-type": { @@ -2868,63 +2280,11 @@ "node": ">=18.3.0" } }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, - "node_modules/from2/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT", - "optional": true - }, - "node_modules/from2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "optional": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/from2/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT", - "optional": true - }, - "node_modules/from2/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT", - "optional": true + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "license": "MIT" }, "node_modules/fsevents": { "version": "2.3.3", @@ -2941,26 +2301,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "optional": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "optional": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -2986,13 +2326,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT", - "optional": true - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3000,115 +2333,18 @@ "license": "ISC", "optional": true }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "node_modules/hexy": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.4.0.tgz", + "integrity": "sha512-HxJ6HCV/zrSBeAVRNEhr3CE6yTlCYklMHfCVfJkp7kC+HJqSP1SQ+howWPiHK37t++ELUtgoAoGJut5HyRhIvg==", "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "optional": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "license": "MIT", - "optional": true, - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "bin": { + "hexy": "bin/hexy_cmd.js" }, "engines": { - "node": ">=0.10.0" + "node": ">=14.0.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -3128,13 +2364,6 @@ "license": "ISC", "optional": true }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "optional": true - }, "node_modules/ink": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ink/-/ink-7.0.5.tgz", @@ -3202,39 +2431,6 @@ } } }, - "node_modules/into-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", - "integrity": "sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==", - "license": "MIT", - "optional": true, - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "license": "MIT", - "optional": true, - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -3299,19 +2495,6 @@ "license": "MIT", "optional": true }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "optional": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -3655,44 +2838,11 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mdns-js": { - "version": "1.0.7", - "resolved": "git+ssh://git@github.com/ciderapp/node-mdns-js.git#a34c514981c4dded54ae47e49660a4497dfedf0c", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "debug": "^4.1.1", - "dns-js": "git+https://github.com/ciderapp/node-dns-js.git", - "semver": "^7.2.3" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/mdns-js/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mdns-js/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "node_modules/map-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", + "integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==", + "license": "MIT" }, "node_modules/mimic-fn": { "version": "2.1.0", @@ -3703,38 +2853,10 @@ "node": ">=6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC", - "optional": true - }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "license": "MIT", - "optional": true - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3763,13 +2885,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT", - "optional": true - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -3780,63 +2895,6 @@ "node": ">=4" } }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "optional": true - }, - "node_modules/multistream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", - "integrity": "sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "once": "^1.4.0", - "readable-stream": "^3.6.0" - } - }, - "node_modules/multistream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/multistream/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -3856,77 +2914,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", - "license": "MIT", - "optional": true - }, - "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-airtunes2": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/node-airtunes2/-/node-airtunes2-2.5.0.tgz", - "integrity": "sha512-OMbxDCTwakRwTEZ/PKA6HJOOHD1FiqYnw98RtGx/X3lFsNXyKPUMLyR43t9lQArRCYGsGgLuPwDv9Ehkw4mhHQ==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "@noble/ed25519": "^1.7.3", - "@yao-pkg/pkg": "^5.11.4", - "arraybuffer-to-string": "^1.0.2", - "async": "^3.2.5", - "axlsign": "github:ciderapp/curve25519-js", - "big-integer": "^1.6.52", - "bplist-creator": "^0.1.1", - "bplist-parser": "^0.3.2", - "castv2-client": "^1.2.0", - "chacha-js": "github:ciderapp/chacha20poly1305", - "curve25519-js": "^0.0.4", - "electron-fetch": "^1.9.1", - "elliptic": "^6.5.5", - "fast-srp-hap": "^2.0.4", - "js-sha1": "^0.7.0", - "lodash": "^4.17.21", - "mdns-js": "github:ciderapp/node-mdns-js", - "node-gyp": "^12.1.0", - "prebuild-install": "github:ciderapp/prebuild-install", - "python-struct": "^1.1.3", - "ws": "^8.16.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-gyp": { "version": "12.3.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", @@ -3979,16 +2966,6 @@ ], "license": "MIT" }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "optional": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -4084,16 +3061,6 @@ "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -4110,13 +3077,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT", - "optional": true - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4124,11 +3084,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -4173,32 +3145,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "git+ssh://git@github.com/ciderapp/prebuild-install.git#a305d02ccffa94de8863c0b3328b98dc6d3a4ef2", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.31.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -4209,54 +3155,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT", - "optional": true - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/protobufjs": { - "version": "7.6.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", - "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.1", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/protobufjs/node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0", - "optional": true - }, "node_modules/publint": { "version": "0.3.21", "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.21.tgz", @@ -4279,17 +3177,6 @@ "url": "https://bjornlu.com/sponsor" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "optional": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/python-struct": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/python-struct/-/python-struct-1.1.3.tgz", @@ -4300,42 +3187,6 @@ "long": "^4.0.0" } }, - "node_modules/qap": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/qap/-/qap-3.3.1.tgz", - "integrity": "sha512-U0MV9LRz4u19xaK4gssnwyc7XWTnFdmDGrgG9hvV6nchKeu3XeITTclugWKT9rLiLK2GvN3utSkKY90+1tEHkw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -4373,38 +3224,6 @@ "string_decoder": "~0.10.x" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "optional": true, - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4496,15 +3315,16 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "optional": true + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/scheduler": { "version": "0.27.0", @@ -4538,53 +3358,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/slice-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", @@ -4602,9 +3375,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", - "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4615,15 +3388,27 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -4660,54 +3445,14 @@ "node": ">= 0.10.0" } }, - "node_modules/stream-meter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", - "integrity": "sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "readable-stream": "^2.1.4" - } - }, - "node_modules/stream-meter/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT", - "optional": true - }, - "node_modules/stream-meter/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "optional": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/stream-meter/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT", - "optional": true - }, - "node_modules/stream-meter/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/stream-combiner": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", + "integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==", "license": "MIT", - "optional": true, "dependencies": { - "safe-buffer": "~5.1.0" + "duplexer": "~0.1.1", + "through": "~2.3.4" } }, "node_modules/string_decoder": { @@ -4761,19 +3506,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -4803,68 +3535,6 @@ "node": ">=18" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC", - "optional": true - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/tar-stream/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/terminal-size": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", @@ -4877,6 +3547,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4921,13 +3597,6 @@ "node": ">=14.0.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4955,19 +3624,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-fest": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", @@ -5021,16 +3677,9 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true - }, "node_modules/vite": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", @@ -5110,19 +3759,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5150,12 +3799,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5209,24 +3858,6 @@ "node": "20 || >=22" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -5292,13 +3923,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "optional": true - }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -5320,14 +3944,26 @@ } } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "optional": true, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, "engines": { - "node": ">=10" + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" } }, "node_modules/yallist": { @@ -5356,83 +3992,6 @@ "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "license": "MIT", - "optional": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", diff --git a/package.json b/package.json index 7ad95c7..311c72d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ciphore/radiocli", - "version": "0.2.4", + "version": "0.2.5", "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.", "type": "module", "license": "MIT", @@ -33,7 +33,16 @@ "SECURITY.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", - "docs" + "docs", + "scripts/build-airplay-native.mjs", + "vendor/node-airtunes2/lib", + "vendor/node-airtunes2/src", + "vendor/node-airtunes2/package.json", + "vendor/node-airtunes2/binding.gyp", + "vendor/node-airtunes2/LICENSE", + "vendor/node-airtunes2/README.md", + "vendor/node-airtunes2/RADIOCLI_PATCH.md", + "vendor/node-airtunes2/UPSTREAM.json" ], "bin": { "radiocli": "dist/cli.js" @@ -47,7 +56,7 @@ "check:whitespace": "node scripts/whitespace-check.mjs", "check:exports": "knip", "check:package": "publint", - "audit:runtime": "node scripts/audit-ci.mjs --omit=dev --omit=optional --audit-level=high", + "audit:runtime": "node scripts/audit-ci.mjs --omit=dev --include=optional --audit-level=low", "audit:docs": "node scripts/audit-ci.mjs --prefix=apps/docs --omit=dev --audit-level=high", "lint": "npm run check:unused && npm run check:whitespace && npm run check:exports", "docs:dev": "npm --prefix apps/docs run dev", @@ -56,27 +65,29 @@ "start": "node dist/cli.js", "test": "vitest run", "verify": "npm run check && npm run lint && npm run test && npm run build && npm run smoke:data", - "verify:release": "npm run verify && npm run smoke:playback && npm run pack:check && npm run fresh:check && npm run check:package", + "verify:release": "node scripts/verify-release.mjs", "prepack": "npm run build", - "prepublishOnly": "npm run check && npm run lint && npm run test && npm run smoke:data && npm run fresh:check && npm run check:package", + "prepublishOnly": "npm run verify:release", "pack:check": "npm pack --dry-run", "smoke:data": "tsx src/smoke/data-smoke.ts", "smoke:mcp": "node scripts/mcp-smoke.mjs", "smoke:playback": "tsx src/smoke/playback-smoke.ts", + "smoke:network": "tsx src/smoke/network-playback-smoke.ts", + "smoke:cli": "tsx src/smoke/cli-smoke.ts", "demo:script": "node scripts/demo-script.mjs", "demo:assets": "node scripts/capture-demo-assets.mjs", - "fresh:check": "node scripts/fresh-check.mjs" + "fresh:check": "node scripts/fresh-check.mjs", + "postinstall": "node scripts/build-airplay-native.mjs" }, "dependencies": { + "@homebridge/dbus-native": "^0.7.9", "@modelcontextprotocol/server": "^2.0.0", "ink": "^7.0.5", "jsonc-parser": "^3.3.1", "react": "^19.2.6", + "string-width": "8.2.1", "zod": "^4.4.3" }, - "overrides": { - "protobufjs": "^7.5.8" - }, "devDependencies": { "@types/node": "^22.15.0", "@types/react": "^19.2.15", @@ -85,12 +96,24 @@ "publint": "^0.3.21", "tsx": "^4.22.3", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.11" }, "engines": { "node": ">=22" }, "optionalDependencies": { - "node-airtunes2": "2.5.0" + "@noble/ed25519": "1.7.5", + "async": "3.2.6", + "axlsign": "git+https://github.com/ciderapp/curve25519-js.git#cae68782564b83ffa1c7c757e40a3944cb140290", + "big-integer": "1.6.52", + "bplist-creator": "0.1.1", + "bplist-parser": "0.3.2", + "chacha-js": "git+https://github.com/ciderapp/chacha20poly1305.git#f85941b8f5c62843a1459758cc3dbabad9f849cb", + "curve25519-js": "0.0.4", + "fast-srp-hap": "2.0.4", + "js-sha1": "0.7.0", + "lodash": "4.18.1", + "python-struct": "1.1.3", + "node-gyp": "12.3.0" } } diff --git a/scripts/audit-ci.mjs b/scripts/audit-ci.mjs index e0fecf4..fddfbf1 100644 --- a/scripts/audit-ci.mjs +++ b/scripts/audit-ci.mjs @@ -1,45 +1,38 @@ import {spawnSync} from 'node:child_process'; - -const npmCliPath = process.env.npm_execpath; -if (!npmCliPath) { - console.error('Unable to locate the npm CLI for the audit.'); - process.exit(1); -} - -const auditArguments = [ - npmCliPath, - 'audit', - ...process.argv.slice(2), - '--fetch-timeout=30000', - '--fetch-retries=1' -]; -const transientFailure = - /audit endpoint returned an error|service unavailable|network timeout|ECONNRESET|EAI_AGAIN|ETIMEDOUT/i; -const maxAttempts = 3; - -for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const result = spawnSync(process.execPath, auditArguments, { - encoding: 'utf8', - env: process.env - }); - - process.stdout.write(result.stdout ?? ''); - process.stderr.write(result.stderr ?? ''); - - if (result.status === 0) { - process.exit(0); - } - - const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; - if (!transientFailure.test(output)) { - process.exit(result.status ?? 1); - } - - if (attempt === maxAttempts) { - console.error('npm audit service remained unavailable after three attempts; continuing.'); - process.exit(0); +import {mkdirSync, writeFileSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {setTimeout as delay} from 'node:timers/promises'; +import {npmCommand} from './packed-smoke.mjs'; + +/** An unavailable audit is a distinct failure, never evidence of a clean audit. */ +export async function runAudit(args, {execute = spawnSync, wait = delay, env = process.env, write = text => process.stdout.write(text), writeError = text => process.stderr.write(text)} = {}) { + const npm = npmCommand({env}); + const auditArgs = [...npm.args, 'audit', ...args, '--fetch-timeout=30000', '--fetch-retries=1']; + const transient = /audit endpoint returned an error|service unavailable|network timeout|ECONNRESET|EAI_AGAIN|ETIMEDOUT|ENOTFOUND|ENETUNREACH|EHOSTUNREACH/i; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const result = execute(npm.command, auditArgs, {encoding: 'utf8', env, shell: false, windowsHide: true, timeout: 90_000, maxBuffer: 4 * 1024 * 1024}); + write(result.stdout ?? ''); + writeError(result.stderr ?? ''); + const unavailable = transient.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}\n${result.error?.code ?? ''}`); + const status = !result.error && result.status === 0 ? 'passed' : unavailable ? 'unavailable' : 'failed'; + if (status === 'unavailable' && attempt < 3) { + writeError(`npm audit service unavailable; retrying (${attempt}/3)...\n`); + await wait(attempt * 5000); + continue; + } + const evidence = {schemaVersion: 1, checkedAt: new Date().toISOString(), sourceRevision: env.RADIOCLI_SOURCE_REVISION ?? env.GITHUB_SHA ?? null, status, attempts: attempt, + exitCode: status === 'passed' ? 0 : status === 'unavailable' ? 2 : result.status || 1, + scope: args.some(arg => arg.startsWith('--prefix=')) ? args.find(arg => arg.startsWith('--prefix=')) : 'required-runtime', arguments: args}; + if (env.RADIOCLI_AUDIT_EVIDENCE_PATH) { + mkdirSync(dirname(resolve(env.RADIOCLI_AUDIT_EVIDENCE_PATH)), {recursive: true}); + writeFileSync(env.RADIOCLI_AUDIT_EVIDENCE_PATH, `${JSON.stringify(evidence, null, 2)}\n`); + } + if (status === 'unavailable') writeError('npm audit remained unavailable; no security result was obtained. Rerun the audit before release.\n'); + return evidence; } +} - console.error(`npm audit service unavailable; retrying (${attempt}/${maxAttempts})...`); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, attempt * 5_000); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await runAudit(process.argv.slice(2)).then(result => { process.exitCode = result.exitCode; }).catch(error => { console.error(error.message); process.exitCode = 1; }); } diff --git a/scripts/audit-ci.test-node.mjs b/scripts/audit-ci.test-node.mjs new file mode 100644 index 0000000..690e480 --- /dev/null +++ b/scripts/audit-ci.test-node.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; +import {runAudit} from './audit-ci.mjs'; + +function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-audit-')); + t.after(() => rmSync(directory, {force: true, recursive: true})); + const cli = join(directory, 'npm-cli.js'); + writeFileSync(cli, ''); + return {env: {npm_execpath: cli, RADIOCLI_SOURCE_REVISION: 'a'.repeat(40), RADIOCLI_AUDIT_EVIDENCE_PATH: join(directory, 'audit.json')}, + wait: async () => undefined, write: () => undefined, writeError: () => undefined}; +} + +test('audit retry success records a real successful outcome', async t => { + const options = fixture(t); + let attempts = 0; + const result = await runAudit(['--omit=dev'], {...options, execute: (_program, args, execution) => { + assert.ok(args.includes('--fetch-timeout=30000')); + assert.equal(execution.shell, false); + assert.equal(execution.timeout, 90_000); + return ++attempts === 1 ? {status: 1, stderr: 'ECONNRESET'} : {status: 0, stdout: 'found 0 vulnerabilities'}; + }}); + assert.equal(result.status, 'passed'); + assert.equal(result.attempts, 2); + assert.equal(result.exitCode, 0); + assert.deepEqual(JSON.parse(readFileSync(options.env.RADIOCLI_AUDIT_EVIDENCE_PATH, 'utf8')), result); +}); + +test('an exhausted unavailable audit remains an explicit nonzero unknown outcome', async t => { + const result = await runAudit([], {...fixture(t), execute: () => ({status: null, error: {code: 'ETIMEDOUT'}})}); + assert.equal(result.status, 'unavailable'); + assert.equal(result.exitCode, 2); + assert.equal(result.attempts, 3); +}); + +test('dependency findings are not retried or mislabeled as a service outage', async t => { + const result = await runAudit([], {...fixture(t), execute: () => ({status: 1, stdout: '1 high severity vulnerability'})}); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCode, 1); + assert.equal(result.attempts, 1); +}); diff --git a/scripts/build-airplay-native.mjs b/scripts/build-airplay-native.mjs new file mode 100644 index 0000000..8fbcb49 --- /dev/null +++ b/scripts/build-airplay-native.mjs @@ -0,0 +1,22 @@ +import {spawnSync} from 'node:child_process'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; + +const require = createRequire(import.meta.url); + +/** Optional Linux optimization. The sender retains its upstream JS fallback. */ +export function buildAirPlayNative({platform = process.platform, env = process.env, resolve = name => require.resolve(name), execute = spawnSync} = {}) { + const omitted = Object.entries(env).find(([name]) => name.toLowerCase() === 'npm_config_omit')?.[1] ?? ''; + if (platform !== 'linux' || omitted.split(/[\s,]+/).includes('optional')) return 'skipped'; + let entry; + try { entry = resolve('node-gyp/bin/node-gyp.js'); } catch { return 'unavailable'; } + try { + const result = execute(process.execPath, [entry, 'rebuild'], { + cwd: fileURLToPath(new URL('../vendor/node-airtunes2/', import.meta.url)), env, + shell: false, windowsHide: true, timeout: 120_000, stdio: 'ignore' + }); + return result.status === 0 && !result.error ? 'built' : 'fallback'; + } catch { return 'fallback'; } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) buildAirPlayNative(); diff --git a/scripts/build-airplay-native.test-node.mjs b/scripts/build-airplay-native.test-node.mjs new file mode 100644 index 0000000..5170a5c --- /dev/null +++ b/scripts/build-airplay-native.test-node.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {basename, dirname, resolve} from 'node:path'; +import {buildAirPlayNative} from './build-airplay-native.mjs'; + +test('portable installs skip native setup even with case-insensitive npm environment names', () => { + for (const env of [{npm_config_omit: 'dev optional'}, {NPM_CONFIG_OMIT: 'optional'}, {npm_config_omit: 'dev,optional'}]) { + assert.equal(buildAirPlayNative({platform: 'linux', env, resolve: () => { throw new Error('must not resolve'); }}), 'skipped'); + } +}); + +test('macOS keeps the upstream JS codec and unsupported native platforms do not build', () => { + for (const platform of ['darwin', 'win32', 'freebsd', 'openbsd', 'netbsd']) { + assert.equal(buildAirPlayNative({platform, execute: () => { throw new Error('must not run'); }}), 'skipped'); + } +}); + +test('missing tools and native build failures preserve installation and the JS fallback', () => { + assert.equal(buildAirPlayNative({platform: 'linux', resolve: () => { throw new Error('missing'); }}), 'unavailable'); + for (const execute of [() => ({status: 1}), () => ({status: null, error: {code: 'ETIMEDOUT'}}), () => { throw new Error('compiler missing'); }]) { + assert.equal(buildAirPlayNative({platform: 'linux', resolve: () => '/node-gyp.js', execute}), 'fallback'); + } +}); + +test('Linux uses the active Node and argument array in the bundled source directory', () => { + let calls = 0; + assert.equal(buildAirPlayNative({platform: 'linux', env: {}, resolve: () => '/path with spaces/node-gyp.js', execute: (program, args, options) => { + calls += 1; assert.equal(program, process.execPath); assert.deepEqual(args, ['/path with spaces/node-gyp.js', 'rebuild']); + assert.equal(options.shell, false); assert.equal(basename(resolve(options.cwd)), 'node-airtunes2'); assert.equal(basename(dirname(resolve(options.cwd))), 'vendor'); assert.equal(options.timeout, 120_000); + return {status: 0}; + }}), 'built'); + assert.equal(calls, 1); +}); diff --git a/scripts/capture-demo-assets.mjs b/scripts/capture-demo-assets.mjs index 1b12c38..d5c33b7 100644 --- a/scripts/capture-demo-assets.mjs +++ b/scripts/capture-demo-assets.mjs @@ -41,24 +41,8 @@ const tapes = [ 'Sleep 5s', 'Left', 'Sleep 1s', - 'Show', - 'Sleep 1s', - 'Type "v"', - 'Sleep 1100ms', - 'Type "v"', - 'Sleep 1100ms', - 'Type "v"', - 'Sleep 1100ms', - 'Type "v"', - 'Sleep 1100ms', - 'Type "t"', - 'Sleep 1s', - 'Type "t"', - 'Sleep 1s', - 'Type "v"', - 'Sleep 1100ms', - 'Type "t"', - 'Sleep 1s', + // The style tour is inserted after loading the built receiver registry. + 'Hide', 'Ctrl+C' ] }, @@ -184,6 +168,10 @@ async function main() { run('npm', ['run', 'build', '--silent']); } + const {receiverStyleNames} = await import('../dist/ui/visualizers/receiver-style-registry.js'); + const heroTape = tapes.find(tape => tape.name === 'radiocli-now-playing'); + heroTape.steps.splice(-2, 0, ...receiverTour(receiverStyleNames)); + rmSync(demoRoot, {force: true, recursive: true}); mkdirSync(demoHome, {recursive: true}); mkdirSync(tapeDir, {recursive: true}); @@ -276,7 +264,7 @@ function writeSeedStore() { activity: {sessions}, settings: { theme: 'green', - receiverStyle: 'pulse-grid', + receiverStyle: 'ultracode', receiverStyleVersion: 2, volume: 0, enableRadioGarden: false, @@ -297,6 +285,31 @@ function writeSeedStore() { writeFileSync(storePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); } +function receiverTour(receiverStyleNames) { + const styles = [ + 'ultracode', 'moire', 'gallop-fine', 'braille-wave', 'cube', + 'spinning-donut', 'fire', 'sumi-ocean', 'manhattan', 'crossfade' + ]; + const steps = []; + let currentIndex = 0; + + for (const [index, style] of styles.entries()) { + const nextIndex = receiverStyleNames.indexOf(style); + if (nextIndex < 0) throw new Error(`Unknown demo receiver style: ${style}`); + // Omit the intervening styles while navigating with the real TUI shortcut. + steps.push('Hide'); + const presses = (nextIndex - currentIndex + receiverStyleNames.length) % receiverStyleNames.length; + for (let press = 0; press < presses; press += 1) { + steps.push('Type "v"', 'Sleep 150ms'); + } + if (index > 0 && index % 3 === 0) steps.push('Type "t"', 'Sleep 150ms'); + steps.push('Show', 'Sleep 1900ms'); + currentIndex = nextIndex; + } + + return steps; +} + function buildDemoListeningSessions(now, stations) { const sessions = []; const addSession = (daysAgo, stationIndex, listenedSeconds, hour = 19) => { diff --git a/scripts/fresh-check.mjs b/scripts/fresh-check.mjs index a37ac75..db7bb99 100755 --- a/scripts/fresh-check.mjs +++ b/scripts/fresh-check.mjs @@ -1,13 +1,17 @@ #!/usr/bin/env node -import {existsSync, mkdtempSync, rmSync} from 'node:fs'; +import {copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import {npmCommand, runCommand, runPackedSmoke, smokeEnvironment} from './packed-smoke.mjs'; +import {sha256, sourceIdentity, upgradeBaselines} from './release-evidence.mjs'; +import {runUpgradeSmoke} from './upgrade-smoke.mjs'; const root = resolve(fileURLToPath(new URL('..', import.meta.url))); const args = process.argv.slice(2); -if (args.some(arg => arg !== '--require-mpv')) throw new Error('Usage: npm run fresh:check [-- --require-mpv]'); +if (args.some(arg => !['--require-mpv', '--require-clean'].includes(arg))) throw new Error('Usage: npm run fresh:check [-- --require-mpv] [--require-clean]'); +const source = sourceIdentity(root); +if (args.includes('--require-clean') && (!source.revision || source.dirty !== false)) throw new Error('Release evidence requires a clean committed checkout.'); const temporary = mkdtempSync(join(tmpdir(), 'radiocli-pack-')); try { @@ -19,16 +23,28 @@ try { 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}`); + if (JSON.stringify(sourceIdentity(root)) !== JSON.stringify(source)) throw new Error('Source identity changed while building the packed candidate. Rerun from a stable checkout.'); const evidenceRoot = process.env.RADIOCLI_SMOKE_EVIDENCE_DIR; + if (evidenceRoot) { + mkdirSync(evidenceRoot, {recursive: true}); + for (const name of ['normal.json', 'omit-optional.json', 'release-evidence.json', ...upgradeBaselines.map(version => `upgrade-${version}.json`)]) rmSync(join(evidenceRoot, name), {force: true}); + copyFileSync(tarball, join(evidenceRoot, info[0].filename)); + writeFileSync(join(evidenceRoot, 'candidate.json'), `${JSON.stringify({schemaVersion: 1, packedAt: new Date().toISOString(), filename: info[0].filename, tarballSha256: sha256(tarball), source}, null, 2)}\n`); + } for (const omitOptional of [false, true]) { await runPackedSmoke(tarball, { omitOptional, requireMpv: args.includes('--require-mpv'), + source, executionMethod: process.env.RADIOCLI_SMOKE_EXECUTION_METHOD ?? 'local process', evidencePath: evidenceRoot ? join(evidenceRoot, `${omitOptional ? 'omit-optional' : 'normal'}.json`) : undefined }); } - console.log('fresh_check=ok installs=normal,omit-optional'); + for (const baseline of upgradeBaselines) { + await runUpgradeSmoke(tarball, {baseline, source, + evidencePath: evidenceRoot ? join(evidenceRoot, `upgrade-${baseline}.json`) : undefined}); + } + console.log(`fresh_check=ok installs=normal,omit-optional upgrades=${upgradeBaselines.join(',')}`); } finally { rmSync(temporary, {force: true, recursive: true}); } diff --git a/scripts/generate-bidi-ranges.mjs b/scripts/generate-bidi-ranges.mjs new file mode 100644 index 0000000..78aae03 --- /dev/null +++ b/scripts/generate-bidi-ranges.mjs @@ -0,0 +1,84 @@ +// Regenerate with: node scripts/generate-bidi-ranges.mjs /path/to/DerivedBidiClass.txt +// Source: https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt +import {readFileSync, writeFileSync} from 'node:fs'; +import {createHash} from 'node:crypto'; + +const source = readFileSync(process.argv[2], 'utf8'); +if (createHash('sha256').update(source).digest('hex') !== '4867b4b7f0731ed1bfcd34cc6251211ff1542541fce0734b6fbda139ee80b3a4') { + throw new Error('Expected Unicode 17.0.0 DerivedBidiClass.txt'); +} + +// L is the default, including unassigned code points. Apply block defaults +// before explicit entries; otherwise marks and digits can become strong RTL. +const classes = new Uint8Array(0x110000); +const defaults = {Left_To_Right: 'L', Right_To_Left: 'R', Arabic_Letter: 'AL', European_Terminator: 'ET'}; +function fill(start, end, kind) { + classes.fill(kind === 'L' ? 0 : kind === 'R' || kind === 'AL' ? 1 : 2, parseInt(start, 16), parseInt(end ?? start, 16) + 1); +} +for (const [, start, end, kind] of source.matchAll(/^# @missing: ([0-9A-F]+)\.\.([0-9A-F]+); (\w+)/gm)) { + if (!(kind in defaults)) throw new Error(`Unknown bidi default: ${kind}`); + fill(start, end, defaults[kind]); +} +for (const [, start, end, kind] of source.matchAll(/^([0-9A-F]+)(?:\.\.([0-9A-F]+))?\s*;\s*(\w+)/gm)) { + fill(start, end, kind); +} + +function pattern(matches) { + const ranges = []; + for (let start = 0; start < classes.length; start++) { + if (!matches(classes[start])) continue; + let end = start; + while (end + 1 < classes.length && matches(classes[end + 1])) end++; + const escape = code => `\\u{${code.toString(16)}}`; + ranges.push(start === end ? escape(start) : `${escape(start)}-${escape(end)}`); + start = end; + } + return `/[${ranges.join('')}]/u`; +} + +const notice = `UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2026 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder.`; + +writeFileSync(new URL('../src/ui/bidi-ranges.ts', import.meta.url), + '// Generated by scripts/generate-bidi-ranges.mjs; do not edit.\n' + + '// Unicode 17.0.0: https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt\n' + + `/*\n${notice}\n*/\n` + + `export const strongDirectionPattern = ${pattern(kind => kind !== 2)};\n` + + `export const rightToLeftPattern = ${pattern(kind => kind === 1)};\n`); diff --git a/scripts/mcp-smoke.mjs b/scripts/mcp-smoke.mjs index 035ba78..5a1bf2a 100644 --- a/scripts/mcp-smoke.mjs +++ b/scripts/mcp-smoke.mjs @@ -1,4 +1,8 @@ +import assert from 'node:assert/strict'; import {spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {JsonLibraryStore} from '../dist/storage/store.js'; +import {defaultAgentControlSettings} from '../dist/types.js'; import {mkdtempSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {fileURLToPath} from 'node:url'; @@ -6,9 +10,16 @@ import {dirname, join} from 'node:path'; const root = dirname(dirname(fileURLToPath(import.meta.url))); const smokeHome = mkdtempSync(join(tmpdir(), 'radiocli-mcp-smoke-')); +const store = new JsonLibraryStore(join(smokeHome, 'radiocli.json')); +const station = {id: 'mcp-fixture', provider: 'playlist', name: 'Café 日本 FM', tags: [], streamUrl: 'https://example.invalid/live'}; +store.addImported([station]); +store.toggleFavorite(station); +store.startListeningSession(station, new Date('2026-01-01T08:00:00Z')); +store.finishActiveListeningSession(new Date('2026-01-01T08:02:00Z')); +store.updateSettings({enableNearbyLocation: false}); const child = spawn(process.execPath, [join(root, 'dist', 'cli.js'), 'mcp', 'serve'], { cwd: root, - env: {...process.env, RADIOCLI_HOME: smokeHome}, + env: {...process.env, RADIOCLI_HOME: smokeHome, RADIOCLI_OFFLINE: '1'}, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); @@ -47,6 +58,52 @@ const requiredTools = [ 'radio_airplay_submit_code' ]; +const closed = once(child, 'close'); +const pending = new Map(); +let requestId = 2; +const call = (name, args = {}) => new Promise((resolve, reject) => { + const id = ++requestId; + pending.set(id, {resolve, reject}); + child.stdin.write(`${JSON.stringify({jsonrpc: '2.0', id, method: 'tools/call', params: {name, arguments: args}})}\n`); +}); +const value = response => { + assert.ok(!response.error && !response.result?.isError, JSON.stringify(response)); + return JSON.parse(response.result.content[0].text); +}; +const errorText = response => { + assert.ok(response.error || response.result?.isError, 'Invalid tool calls must fail.'); + return JSON.stringify(response); +}; + +async function exerciseTools() { + assert.match(errorText(await call('radio_status')), /Agent control is disabled/); + store.updateSettings({agentControl: {...defaultAgentControlSettings, enabled: true}}); + assert.equal(value(await call('radio_status')).connected, false); + const favorites = value(await call('radio_browse', {kind: 'favorites'})); + assert.equal(favorites[0].id, 'playlist:mcp-fixture'); + assert.equal(favorites[0].name, station.name); + assert.match(errorText(await call('radio_browse', {kind: 'nearby'})), /disabled/); + const stats = value(await call('radio_stats')); + assert.equal(stats.totalSeconds, 120); + assert.equal(stats.favoriteStation.name, station.name); + assert.ok(value(await call('radio_get_appearance')).themes.includes('ruby')); + value(await call('radio_set_appearance', {theme: 'ruby', receiver_style: 'mesh'})); + assert.equal(value(await call('radio_get_appearance')).current.receiverStyle, 'mesh'); + assert.equal(new JsonLibraryStore(store.filePath).settings().theme, 'ruby'); + assert.match(errorText(await call('radio_set_appearance', {theme: 'invalid'})), /invalid|Invalid/); + assert.match(errorText(await call('radio_set_volume', {volume: 101})), /100/); + assert.deepEqual(value(await call('radio_alarm_list')), []); + assert.match(errorText(await call('radio_alarm_remove', {alarm_id: 'absent', confirm: false})), /confirm=true/); + assert.match(errorText(await call('radio_alarm_create', {station_id: 'playlist:mcp-fixture', schedule: {type: 'once', at: '2000-01-01T08:00:00Z'}})), /future/); + assert.deepEqual(value(await call('radio_alarm_list')), []); + assert.ok(value(await call('radio_stop')).ok); + assert.ok(value(await call('radio_stop')).ok); + value(await call('radio_set_favorite', {station_id: 'playlist:mcp-fixture', favorite: false})); + assert.deepEqual(value(await call('radio_browse', {kind: 'favorites'})), []); + assert.equal(new JsonLibraryStore(store.filePath).snapshot().favorites.length, 0); + console.log(`MCP tool calls passed on ${process.platform}: ${requestId - 2} requests, persisted settings/favorites, privacy and validation.`); +} + try { await new Promise((resolve, reject) => { let stdout = ''; @@ -56,10 +113,12 @@ try { if (settled) return; settled = true; clearTimeout(timeout); + for (const request of pending.values()) request.reject(error ?? new Error('MCP smoke closed.')); + pending.clear(); child.kill(); error ? reject(error) : resolve(); }; - const timeout = setTimeout(() => finish(new Error(`MCP smoke timed out.${stderr ? ` ${stderr.trim()}` : ''}`)), 5_000); + const timeout = setTimeout(() => finish(new Error(`MCP smoke timed out.${stderr ? ` ${stderr.trim()}` : ''}`)), 30_000); child.stderr.on('data', chunk => { stderr += String(chunk); }); child.stdin.on('error', error => finish(error)); child.once('error', error => finish(error)); @@ -80,7 +139,10 @@ try { finish(new Error(`MCP server wrote non-JSON stdout: ${error instanceof Error ? error.message : String(error)}`)); return; } - if (response.id === 1) { + if (pending.has(response.id)) { + pending.get(response.id).resolve(response); + pending.delete(response.id); + } else if (response.id === 1) { if (response.result?.serverInfo?.name !== 'radiocli') { finish(new Error('MCP initialize response did not identify RadioCLI.')); return; @@ -102,7 +164,7 @@ try { return; } console.log(`MCP smoke passed on ${process.platform}: ${tools.length} tools.`); - finish(); + void exerciseTools().then(() => finish(), finish); return; } } @@ -118,5 +180,7 @@ try { })}\n`); }); } finally { + child.kill(); + await closed; rmSync(smokeHome, {recursive: true, force: true}); } diff --git a/scripts/packed-smoke.mjs b/scripts/packed-smoke.mjs index 022472a..9ec707c 100644 --- a/scripts/packed-smoke.mjs +++ b/scripts/packed-smoke.mjs @@ -6,6 +6,7 @@ import {existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, import {arch, endianness, release, tmpdir} from 'node:os'; import {dirname, join, resolve} from 'node:path'; import {fileURLToPath, pathToFileURL} from 'node:url'; +import {createRequire} from 'node:module'; import {setTimeout as delay} from 'node:timers/promises'; const script = fileURLToPath(import.meta.url); @@ -47,9 +48,10 @@ export function smokeEnvironment(home) { } /** 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} = {}) { +export async function runPackedSmoke(tarball, {omitOptional = false, requireMpv = false, executionMethod = 'local process', evidencePath, source = null} = {}) { tarball = resolve(tarball); assert.ok(existsSync(tarball), `Packed tarball is missing: ${tarball}`); + const tarballSha256 = createHash('sha256').update(readFileSync(tarball)).digest('hex'); 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 & # % 日本 ')); @@ -84,6 +86,13 @@ export async function runPackedSmoke(tarball, {omitOptional = false, requireMpv 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.'); + assert.equal(doctor.airPlay.available, !omitOptional, 'Optional sender availability must match the actual fresh installation mode.'); + assert.equal(doctor.airPlay.safe, !omitOptional, 'The fixed sender must pass safety checks only when its runtime dependencies are present.'); + assert.deepEqual(doctor.airPlay.warningPackages, []); + assert.deepEqual(doctor.airPlay.vulnerablePackages, []); + const edges = runCommand(process.execPath, ['--import', guard, script, '--exercise-edge-cases', packageRoot, home], {cwd: project, env, timeout: 45_000}); + assert.match(edges.stdout, /packed_edge_cases=ok/); + process.stdout.write(edges.stdout); // A missing offline cache must fail promptly; stale persisted data must work. // The preload makes accidental live-directory access an explicit failure. @@ -135,13 +144,15 @@ export async function runPackedSmoke(tarball, {omitOptional = false, requireMpv 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.'); + assert.equal(createHash('sha256').update(readFileSync(tarball)).digest('hex'), tarballSha256, 'The candidate tarball changed while it was being tested.'); const evidence = { + schemaVersion: 1, checkedAt: new Date().toISOString(), source, 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'), + tarballSha256, host: doctor.host, binWrapper, offlineCache: 'passed', mcpTools, playback, - checks: ['help', 'version', 'doctor-json', 'offline-cache-hit-and-miss', 'stream-import', 'playlist-import', 'export', 'MCP-stdio'], result: 'passed' + checks: ['help', 'version', 'doctor-json', 'sender-optional-runtime', 'airplay-legacy-crypto', 'alarm-id-isolation', 'portable-library-restore', 'unicode-display-width', 'cache-clock-correction', 'offline-cache-hit-and-miss', 'stream-import', 'playlist-import', 'export', 'MCP-stdio'], result: 'passed' }; if (evidencePath) { mkdirSync(dirname(resolve(evidencePath)), {recursive: true}); @@ -149,11 +160,115 @@ export async function runPackedSmoke(tarball, {omitOptional = false, requireMpv } console.log(`packed_smoke=${JSON.stringify(evidence)}`); return evidence; + } catch (error) { + if (evidencePath) { + mkdirSync(dirname(resolve(evidencePath)), {recursive: true}); + writeFileSync(evidencePath, `${JSON.stringify({schemaVersion: 1, checkedAt: new Date().toISOString(), source, tarballSha256, + platform: process.platform, architecture: arch(), node: process.version, install: omitOptional ? 'omit-optional' : 'normal', result: 'failed', + failure: 'The packed smoke did not complete. Review the command log.'}, null, 2)}\n`); + } + throw error; } finally { rmSync(temporary, {recursive: true, force: true}); } } +async function exerciseEdgeCases(packageRoot, home) { + await exercisePortableLibrary(packageRoot, home); + const require = createRequire(import.meta.url); + const crypto = require(join(packageRoot, 'vendor', 'node-airtunes2', 'lib', 'ed25519.js')); + const seed = '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'; + assert.equal(crypto.publicKey(seed), 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'); + assert.equal(crypto.sign(seed, ''), ('e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555f' + + 'b8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b').toUpperCase()); + const {acquireOccurrenceLock} = await import(pathToFileURL(join(packageRoot, 'dist', 'alarms', 'runner.js')).href); + const {ProviderCache} = await import(pathToFileURL(join(packageRoot, 'dist', 'providers', 'cache.js')).href); + const {displayWidth, padDisplayEnd, truncate, sliceDisplay} = await import(pathToFileURL(join(packageRoot, 'dist', 'ui', 'format.js')).href); + const scheduledAt = '2030-01-01T08:00:00.000Z'; + for (const [index, ids] of [['wake/a', 'wake?a'], ['wake', 'WAKE'], ['é', 'e\u0301'], ['\ud800', '\ud801']].entries()) { + const directory = join(home, `edge-locks-${index}`); + const claims = ids.map(id => acquireOccurrenceLock(id, scheduledAt, directory)); + assert.ok(claims.every(Boolean), 'Distinct restored alarm IDs must claim independently on this filesystem.'); + for (const claim of claims) claim(); + for (const id of ids) assert.equal(acquireOccurrenceLock(id, scheduledAt, directory), null, 'Completed occurrences must remain deduplicated.'); + } + for (const [text, width] of [['🇯🇵', 2], ['1️⃣', 2], ['©', 1], ['♥︎', 1], ['©️', 2]]) { + assert.equal(displayWidth(text), width); + assert.equal(displayWidth(padDisplayEnd(text, 8)), 8); + assert.ok(displayWidth(truncate(`${text} Station`, 4)) <= 4); + } + assert.equal(sliceDisplay('🇯🇵1️⃣ Radio', 3, 4), '1️⃣ R'); + const originalNow = Date.now; + let now = originalNow() + 86_400_000; + Date.now = () => now; + try { + const path = join(home, 'edge-cache.json'); + const memory = new ProviderCache(path); + await memory.setAsync('atlas', ['future']); + now -= 86_400_000; + const legacy = join(home, 'edge-cache-legacy.json'); + writeFileSync(legacy, JSON.stringify({version: 1, entries: {atlas: {createdAt: now + 86_400_000, value: ['future']}}})); + for (const cache of [memory, new ProviderCache(path), new ProviderCache(legacy)]) { + assert.equal(cache.get('atlas', 1000), null); + assert.equal(await cache.getAsync('atlas', 1000), null); + assert.equal(cache.getStale('atlas'), null); + assert.equal(await cache.getStaleAsync('atlas'), null); + } + await memory.setAsync('atlas', ['refreshed']); + assert.deepEqual(await memory.getAsync('atlas', 1000), ['refreshed']); + now += 1001; + assert.equal(memory.get('atlas', 1000), null); + } finally { Date.now = originalNow; } + console.log('packed_edge_cases=ok alarm-ids=isolated unicode-width=bounded cache-clock-correction=expired'); +} + +async function exercisePortableLibrary(packageRoot, home) { + const {JsonLibraryStore} = await import(pathToFileURL(join(packageRoot, 'dist', 'storage', 'store.js')).href); + const {computeListeningStats} = await import(pathToFileURL(join(packageRoot, 'dist', 'activity', 'stats.js')).href); + const {parsePlaylistFile, stationFromUrl, writeM3u} = await import(pathToFileURL(join(packageRoot, 'dist', 'playlists', 'playlist.js')).href); + const directory = join(home, 'portable library café 日本'); + mkdirSync(directory, {recursive: true}); + const source = new JsonLibraryStore(join(directory, 'source.json')); + const station = stationFromUrl('https://example.invalid/live?a=1&b=2', 'Café 日本 FM'); + source.addImported([station]); + source.toggleFavorite(station); + source.addRecent(station); + source.savePreset('portable', station); + source.recordTrack(station, 'Artist – 日本'); + source.addSearch('jazz 日本'); + source.startListeningSession(station, new Date('2026-01-01T08:00:00Z')); + source.finishActiveListeningSession(new Date('2026-01-01T08:02:00Z')); + source.updateSettings({theme: 'ruby', receiverStyle: 'mesh', volume: 42, enableNearbyLocation: false}); + source.addAlarm({label: 'Portable alarm', enabled: false, station, + schedule: {type: 'recurring', time: '08:00', weekdays: [1, 3, 5], timezone: 'Asia/Tokyo'}, + playback: {volume: 60, fadeSeconds: 5, stopAfterMinutes: 30}, + reliability: {missedRunGraceMinutes: 10, wakeIfSupported: false}}); + const backup = source.exportBackup(join(directory, 'profile backup.json')); + const target = new JsonLibraryStore(join(directory, 'destination.json')); + target.updateSettings({theme: 'amber'}); + const restored = target.importBackup(backup); + // JSON backups omit optional undefined properties by design. Compare their + // portable values rather than incidental in-memory property presence. + const portable = state => JSON.parse(JSON.stringify(state)); + assert.deepEqual(portable(restored.state), portable(source.snapshot()), 'Portable restore must retain all library collections and preferences.'); + assert.equal(new JsonLibraryStore(restored.safetyBackupPath).settings().theme, 'amber'); + const reopened = new JsonLibraryStore(target.filePath); + assert.deepEqual(portable(reopened.snapshot()), portable(source.snapshot()), 'The restored library must survive a new process owner.'); + assert.equal(computeListeningStats(reopened.snapshot().activity.sessions).totalSeconds, 120); + assert.equal(reopened.listAlarms()[0].enabled, false, 'Restoring definitions must not activate a disabled alarm.'); + const playlist = join(directory, 'export.m3u'); + writeM3u(playlist, reopened.snapshot().imported); + assert.deepEqual(parsePlaylistFile(playlist).map(item => [item.name, item.streamUrl]), [[station.name, station.streamUrl]]); + const xspf = join(directory, 'stations.xspf'); + writeFileSync(xspf, `Café 日本 FM`); + assert.deepEqual(parsePlaylistFile(xspf).map(item => [item.name, item.streamUrl]), [[station.name, station.streamUrl]]); + const invalid = join(directory, 'invalid.json'); + writeFileSync(invalid, '{"not":"a RadioCLI backup"}'); + assert.throws(() => reopened.importBackup(invalid)); + assert.deepEqual(portable(new JsonLibraryStore(target.filePath).snapshot()), portable(source.snapshot()), 'Invalid backups must leave saved data intact.'); + console.log('packed_portable_library=ok favorites recents imports presets tracks search stats settings alarms invalid-restore XSPF'); +} + function seedProviderCache(home) { const entries = {}; const add = (path, params, value) => { @@ -285,6 +400,7 @@ function silentWav() { async function main(args) { if (args[0] === '--exercise-playback') return exercisePlayback(args[1], args[2]); + if (args[0] === '--exercise-edge-cases') return exerciseEdgeCases(args[1], args[2]); const options = {}; let tarball; for (let index = 0; index < args.length; index++) { diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs new file mode 100644 index 0000000..1bdb3c0 --- /dev/null +++ b/scripts/release-evidence.mjs @@ -0,0 +1,87 @@ +import {spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const upgradeBaselines = ['0.2.3', '0.2.4']; + +export function sourceIdentity(root, execute = spawnSync) { + const readGit = args => { + const value = execute('git', args, {cwd: root, encoding: 'utf8', shell: false, windowsHide: true, timeout: 5000}); + return value.status === 0 && !value.error ? value.stdout.trim() : null; + }; + const revision = readGit(['rev-parse', 'HEAD']); + const status = readGit(['status', '--porcelain', '--untracked-files=normal']); + return {revision, dirty: status === null ? null : status !== '', lockfileSha256: existsSync(join(root, 'package-lock.json')) ? sha256(join(root, 'package-lock.json')) : null}; +} + +export function sha256(path) { return createHash('sha256').update(readFileSync(path)).digest('hex'); } + +/** Verify saved evidence for one runtime. Other devices remain explicitly unverified. */ +export function buildReleaseEvidence({candidate, normal, portable, audit, network, upgrades = [], checks = [], expectedRevision}) { + checks = Array.isArray(checks) ? checks.filter(check => check && typeof check === 'object') : []; + upgrades = Array.isArray(upgrades) ? upgrades : []; + const reasons = []; + if (!candidate?.source?.revision || !/^[a-f\d]{40,64}$/i.test(candidate.source.revision)) reasons.push('Missing source revision.'); + if (candidate?.source?.dirty !== false) reasons.push('The candidate checkout was dirty or its state is unknown.'); + if (!/^[a-f\d]{64}$/i.test(candidate?.source?.lockfileSha256 ?? '')) reasons.push('Missing dependency lockfile SHA-256.'); + if (expectedRevision && candidate?.source?.revision !== expectedRevision) reasons.push('The candidate does not match the requested revision.'); + if (!/^[a-f\d]{64}$/i.test(candidate?.tarballSha256 ?? '')) reasons.push('Missing artifact SHA-256.'); + for (const [mode, record] of [['normal', normal], ['omit-optional', portable]]) { + if (record?.result !== 'passed' || record.install !== mode) reasons.push(`Missing successful ${mode} packed installation.`); + if (record?.tarballSha256 !== candidate?.tarballSha256) reasons.push(`${mode} tested a different artifact.`); + if (JSON.stringify(record?.source) !== JSON.stringify(candidate?.source)) reasons.push(`${mode} source identity differs from the candidate.`); + if (typeof record?.playback !== 'string' || !record.playback.includes('mpv: local WAV')) reasons.push(`${mode} lacks native mpv playback evidence.`); + } + if (![normal?.platform, normal?.architecture, normal?.node].every(value => typeof value === 'string' && value)) reasons.push('Missing runtime identity.'); + if (normal?.platform !== portable?.platform || normal?.architecture !== portable?.architecture || normal?.node !== portable?.node) reasons.push('Installation modes used different runtimes.'); + for (const baseline of upgradeBaselines) { + const record = upgrades.find(item => item?.baseline === baseline); + if (record?.result !== 'passed' || record.tarballSha256 !== candidate?.tarballSha256 || JSON.stringify(record?.source) !== JSON.stringify(candidate?.source) + || ['platform', 'architecture', 'node'].some(key => record?.[key] !== normal?.[key]) + || !['in-place-install', 'saved-library-preservation', 'all-supported-receivers', 'installed-renderers', 'retired-style-migration', 'old-process-live-reads-and-writes', 'background-polling'].every(check => record?.checks?.includes(check))) { + reasons.push(`Missing matching successful upgrade evidence from ${baseline}.`); + } + } + if (audit?.status !== 'passed' || audit.scope !== 'required-runtime' || audit.sourceRevision !== candidate?.source?.revision) reasons.push('The required runtime audit is missing, unavailable, failed, or for a different revision or dependency scope.'); + if (!Array.isArray(audit?.arguments) || !audit.arguments.includes('--include=optional') || !audit.arguments.includes('--audit-level=low')) reasons.push('The runtime audit must include optional dependencies and all vulnerability severities.'); + if (network?.result !== 'passed' || network.sourceRevision !== candidate?.source?.revision || ['platform', 'architecture', 'node'].some(key => network?.[key] !== normal?.[key])) reasons.push('Native network evidence is missing or belongs to another revision/runtime.'); + for (const name of ['mpv_tls_rejection', 'finite_eof', 'mpv_disconnect', 'mpv_stall', 'mpv_nested_tls']) { + if (typeof network?.checks?.[name] !== 'string' || !network.checks[name].startsWith('passed:')) reasons.push(`Required native network check did not pass: ${name}.`); + } + const requiredChecks = ['typecheck', 'lint', 'tests', 'harness', 'build', 'package', 'native-tui', 'native-network', 'source-mcp', 'background-cli', 'packed']; + for (const name of requiredChecks) if (!checks.some(check => check.name === name && check.status === 'passed')) reasons.push(`Required check did not pass: ${name}.`); + return {schemaVersion: 1, recordedAt: new Date().toISOString(), scope: 'one-runtime-packed-candidate', result: reasons.length ? 'incomplete' : 'passed', reasons, + candidate, runtime: {platform: normal?.platform ?? null, architecture: normal?.architecture ?? null, node: normal?.node ?? null}, + checks, audit: audit ?? null, network: network ?? null, upgrades, installs: {normal: normal ?? null, omitOptional: portable ?? null}, + unverified: ['physical speaker/headphone output', 'physical AirPlay receiver', 'terminal application and hardware media keys', 'suspend/resume and wake delivery', 'other operating systems and architectures'], + knownLimitations: ['FFmpeg/OpenSSL builds can accept a mismatched IP-address certificate even with TLS verification enabled; use DNS station URLs. Network HLS/DASH/IMF inputs require mpv because FFmpeg fallbacks cannot enforce verification on their nested requests.']}; +} + +export function recordReleaseEvidence(directory, {expectedRevision, checks} = {}) { + const read = name => { try { return JSON.parse(readFileSync(join(directory, name), 'utf8')); } catch { return null; } }; + const evidence = buildReleaseEvidence({candidate: read('candidate.json'), normal: read('normal.json'), portable: read('omit-optional.json'), audit: read('audit-runtime.json'), + network: read('network-playback.json'), + upgrades: upgradeBaselines.map(baseline => read(`upgrade-${baseline}.json`)), + expectedRevision, checks: checks ?? read('checks.json') ?? []}); + const filename = evidence.candidate?.filename; + if (typeof filename !== 'string' || !filename || filename !== filename.replace(/^.*[\\/]/, '') || !existsSync(join(directory, filename)) || sha256(join(directory, filename)) !== evidence.candidate?.tarballSha256) { + evidence.result = 'incomplete'; + evidence.reasons.push('The preserved tarball is missing or its SHA-256 differs.'); + } + mkdirSync(directory, {recursive: true}); + writeFileSync(join(directory, 'release-evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`); + return evidence; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [directory = 'output/release-evidence'] = process.argv.slice(2); + // GitHub step outcomes arrive as structured environment data, never shell code. + const steps = process.env.RADIOCLI_CHECK_OUTCOMES ? JSON.parse(process.env.RADIOCLI_CHECK_OUTCOMES) : null; + const checks = steps ? Object.entries(steps).map(([name, step]) => ({name, status: step.outcome === 'success' ? 'passed' : step.outcome})) : undefined; + const result = recordReleaseEvidence(resolve(directory), {expectedRevision: process.env.GITHUB_SHA, checks}); + console.log(`release_evidence=${result.result} scope=${result.scope}`); + for (const reason of result.reasons) console.error(reason); + process.exitCode = result.result === 'passed' ? 0 : 1; +} diff --git a/scripts/release-evidence.test-node.mjs b/scripts/release-evidence.test-node.mjs new file mode 100644 index 0000000..b61ce84 --- /dev/null +++ b/scripts/release-evidence.test-node.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; +import {buildReleaseEvidence, recordReleaseEvidence, sha256, sourceIdentity} from './release-evidence.mjs'; + +function complete() { + const source = {revision: 'a'.repeat(40), dirty: false, lockfileSha256: 'b'.repeat(64)}; + const candidate = {filename: 'candidate.tgz', source, tarballSha256: 'c'.repeat(64)}; + const installed = {source, tarballSha256: candidate.tarballSha256, result: 'passed', platform: 'linux', architecture: 'arm64', node: 'v22.0.0', playback: 'mpv: local WAV; null audio'}; + return {candidate, normal: {...installed, install: 'normal'}, portable: {...installed, install: 'omit-optional'}, audit: {status: 'passed', scope: 'required-runtime', sourceRevision: source.revision, arguments: ['--omit=dev', '--include=optional', '--audit-level=low']}, + network: {result: 'passed', sourceRevision: source.revision, platform: installed.platform, architecture: installed.architecture, node: installed.node, + checks: {mpv_tls_rejection: 'passed: untrusted TLS', finite_eof: 'passed: stopped', mpv_disconnect: 'passed: recovered', mpv_stall: 'passed: recovered', mpv_nested_tls: 'passed: untrusted TLS'}}, + upgrades: ['0.2.3', '0.2.4'].map(baseline => ({...installed, baseline, + checks: ['in-place-install', 'saved-library-preservation', 'all-supported-receivers', 'installed-renderers', 'retired-style-migration', 'old-process-live-reads-and-writes', 'background-polling']})), + checks: ['typecheck', 'lint', 'tests', 'harness', 'build', 'package', 'native-tui', 'native-network', 'source-mcp', 'background-cli', 'packed'].map(name => ({name, status: 'passed'}))}; +} + +test('evidence verifies only the tested artifact and runtime, retaining physical-device limits', () => { + const result = buildReleaseEvidence(complete()); + assert.equal(result.result, 'passed'); + assert.equal(result.scope, 'one-runtime-packed-candidate'); + assert.ok(result.unverified.includes('physical AirPlay receiver')); +}); + +for (const [name, mutate] of [ + ['dirty source', data => { data.candidate.source.dirty = true; }], + ['different artifact', data => { data.portable.tarballSha256 = 'd'.repeat(64); }], + ['different source', data => { data.normal.source = {...data.normal.source, revision: 'd'.repeat(40)}; }], + ['unavailable audit', data => { data.audit.status = 'unavailable'; }], + ['different audit scope', data => { data.audit.scope = '--prefix=apps/docs'; }], + ['audit omitting optional dependencies', data => { data.audit.arguments = ['--omit=dev', '--omit=optional', '--audit-level=low']; }], + ['audit ignoring low severity findings', data => { data.audit.arguments = ['--omit=dev', '--audit-level=high']; }], + ['failed check', data => { data.checks[0].status = 'failed'; }], + ['skipped native network checks', data => { data.checks.find(check => check.name === 'native-network').status = 'skipped'; }], + ['skipped native TUI recovery', data => { data.checks.find(check => check.name === 'native-tui').status = 'skipped'; }], + ['missing background control checks', data => { data.checks = data.checks.filter(check => check.name !== 'background-cli'); }], + ['missing upgrade evidence', data => { data.upgrades = []; }], + ['missing one upgrade baseline', data => { data.upgrades.pop(); }], + ['stale upgrade artifact', data => { data.upgrades[0].tarballSha256 = 'd'.repeat(64); }], + ['foreign upgrade runtime', data => { data.upgrades[0].platform = 'win32'; }], + ['missing old-process regression', data => { data.upgrades[0].checks = []; }], + ['failed packed pipeline', data => { data.checks.find(check => check.name === 'packed').status = 'failed'; }], + ['stale native network evidence', data => { data.network.sourceRevision = 'd'.repeat(40); }], + ['foreign native network evidence', data => { data.network.architecture = 'x64'; }], + ['empty native network checks', data => { data.network.checks = {}; }], + ['unverified mandatory network check', data => { data.network.checks.mpv_stall = 'unverified: not run'; }], + ['missing playback', data => { data.portable.playback = 'not exercised: mpv unavailable'; }], + ['wrong requested revision', data => { data.expectedRevision = 'e'.repeat(40); }] +]) test(`release evidence rejects ${name}`, () => { + const data = complete(); mutate(data); + const result = buildReleaseEvidence(data); + assert.equal(result.result, 'incomplete'); + assert.ok(result.reasons.length); +}); + +test('each mandatory network check is required while optional native checks can remain unverified', () => { + for (const name of ['mpv_tls_rejection', 'finite_eof', 'mpv_disconnect', 'mpv_stall', 'mpv_nested_tls']) { + const data = complete(); delete data.network.checks[name]; + assert.equal(buildReleaseEvidence(data).result, 'incomplete', name); + } + const data = complete(); + Object.assign(data.network.checks, {mpv_private_ca: 'unverified: Schannel', vlc: 'unverified: not installed'}); + assert.equal(buildReleaseEvidence(data).result, 'passed'); +}); + +test('the preserved tarball must match the artifact tested in both installation modes', t => { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-evidence-')); + t.after(() => rmSync(directory, {force: true, recursive: true})); + const data = complete(); + const tarball = join(directory, data.candidate.filename); + writeFileSync(tarball, 'fixture archive'); + for (const item of [data.candidate, data.normal, data.portable, ...data.upgrades]) item.tarballSha256 = sha256(tarball); + for (const [filename, value] of [['candidate.json', data.candidate], ['normal.json', data.normal], ['omit-optional.json', data.portable], ['audit-runtime.json', data.audit], ['network-playback.json', data.network], ['checks.json', data.checks], ...data.upgrades.map(record => [`upgrade-${record.baseline}.json`, record])]) writeFileSync(join(directory, filename), JSON.stringify(value)); + assert.equal(recordReleaseEvidence(directory).result, 'passed'); + writeFileSync(tarball, 'changed archive'); + assert.equal(recordReleaseEvidence(directory).result, 'incomplete'); + assert.equal(JSON.parse(readFileSync(join(directory, 'release-evidence.json'), 'utf8')).result, 'incomplete'); +}); + +test('unknown Git state is not recorded as a clean candidate', t => { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-source-')); + t.after(() => rmSync(directory, {force: true, recursive: true})); + const identity = sourceIdentity(directory, () => ({status: 1, stdout: ''})); + assert.deepEqual(identity, {revision: null, dirty: null, lockfileSha256: null}); +}); diff --git a/scripts/upgrade-smoke.mjs b/scripts/upgrade-smoke.mjs new file mode 100644 index 0000000..206aa07 --- /dev/null +++ b/scripts/upgrade-smoke.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {setTimeout as delay} from 'node:timers/promises'; +import {npmCommand, runCommand, smokeEnvironment} from './packed-smoke.mjs'; +import {sha256, upgradeBaselines} from './release-evidence.mjs'; + +// Keep the old module in memory while npm replaces its files, just like a +// background client surviving an upgrade. Reopen its store on each read so +// even older releases without refresh() exercise their original schema. +const driver = ` +const {JsonLibraryStore} = await import(process.argv[2]); +const file = process.argv[3]; +function details(error) { + return {message: error.message, code: error.code, syscall: error.syscall, + cause: error.cause ? details(error.cause) : undefined}; +} +process.on('message', ({id, method, args = []}) => { + try { + const store = new JsonLibraryStore(file); + const result = method === 'snapshot' ? store.snapshot() : store[method](...args); + process.send({id, result}); + } catch (error) { process.send({id, error: details(error)}); } +}); +const timer = setInterval(() => { + try { new JsonLibraryStore(file).snapshot(); } + catch (error) { process.send({error: details(error)}); } +}, 100); +process.on('disconnect', () => { clearInterval(timer); process.exit(); }); +process.send({id: 0, result: new JsonLibraryStore(file).snapshot()}); +`; + +function storeProcess(script, storeModule, file, env, label) { + const child = spawn(process.execPath, [script, pathToFileURL(storeModule).href, file], {env, shell: false, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe', 'ipc']}); + const pending = new Map(); + let nextId = 1; + let stderr = ''; + let terminalError; + child.stderr.on('data', chunk => { stderr += String(chunk); }); + const exited = new Promise(resolveExit => child.once('exit', resolveExit)); + const request = (id, method, args) => new Promise((resolveResult, reject) => { + if (terminalError) { reject(terminalError); return; } + const timeout = setTimeout(() => { pending.delete(id); reject(new Error(`Upgrade store request timed out: ${stderr}`)); }, 30_000); + pending.set(id, {method: method ?? 'startup', resolve: value => { clearTimeout(timeout); resolveResult(value); }, reject: error => { clearTimeout(timeout); reject(error); }}); + if (method) child.send({id, method, args}, error => { if (error) pending.get(id)?.reject(error); }); + }); + child.on('message', message => { + const waiting = pending.get(message.id); + if (waiting) { + pending.delete(message.id); + message.error ? waiting.reject(new Error(`${label} ${waiting.method}: ${JSON.stringify(message.error)}`)) : waiting.resolve(message.result); + } else if (message.error) { + fail(new Error(`${label} background snapshot: ${JSON.stringify(message.error)}`)); + } + }); + const fail = error => { terminalError = error; for (const item of pending.values()) item.reject(error); pending.clear(); }; + child.once('error', fail); + child.once('close', code => fail(new Error(`Upgrade store process exited (${code}): ${stderr}`))); + return { + ready: request(0), + call: (method, ...args) => request(nextId++, method, args), + async close() { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill(); + const force = setTimeout(() => child.kill('SIGKILL'), 1000); + try { await exited; } finally { clearTimeout(force); child.stderr.destroy(); } + } + }; +} + +/** Install the published baseline, replace it in place with the candidate, and + * use both generations against one private library. Never touches user data. */ +export async function runUpgradeSmoke(tarball, {baseline, source = null, evidencePath} = {}) { + assert.ok(upgradeBaselines.includes(baseline), 'Choose a pinned published upgrade baseline.'); + tarball = resolve(tarball); + const temporary = mkdtempSync(join(tmpdir(), 'radiocli upgrade & 日本 ')); + const project = join(temporary, 'project'); + const home = join(temporary, 'state'); + mkdirSync(project); mkdirSync(home); + const env = smokeEnvironment(home); + const npm = npmCommand(); + const install = target => runCommand(npm.command, [...npm.args, 'install', '--no-audit', '--fund=false', '--omit=dev', '--omit=optional', '--ignore-scripts=false', target], {cwd: project, env, timeout: 300_000}); + const clients = []; + try { + writeFileSync(join(project, 'package.json'), '{"private":true,"type":"module"}\n'); + install(`@ciphore/radiocli@${baseline}`); + const packageRoot = join(project, 'node_modules', '@ciphore', 'radiocli'); + const module = join(packageRoot, 'dist', 'storage', 'store.js'); + const script = join(temporary, 'store-driver.mjs'); + const file = join(home, 'radiocli.json'); + writeFileSync(script, driver); + const old = storeProcess(script, module, file, env, `baseline ${baseline}`); clients.push(old); + await old.ready; + const station = {id: 'upgrade-fixture', provider: 'playlist', name: 'Retained 日本 station', tags: ['upgrade'], streamUrl: 'https://example.invalid/upgrade'}; + await old.call('toggleFavorite', station); + await old.call('addRecent', station); + await old.call('recordTrack', station, 'Retained track'); + await old.call('addSearch', 'Retained search'); + await old.call('addAlarm', {label: 'Retained alarm', enabled: false, station, + schedule: {type: 'once', at: '2050-01-01T08:00:00Z'}, + playback: {volume: 37, fadeSeconds: 0, stopAfterMinutes: 30}, + reliability: {missedRunGraceMinutes: 5, wakeIfSupported: false, keepAwakeUntilAlarm: false}}); + await old.call('updateSettings', {receiverStyle: 'mesh', theme: 'amber', volume: 37}); + const before = await old.call('snapshot'); + install(tarball); + const candidate = storeProcess(script, module, file, env, `candidate over ${baseline}`); clients.push(candidate); + const upgraded = await candidate.ready; + for (const key of ['favorites', 'recent', 'trackHistory', 'searchHistory', 'alarms']) assert.deepEqual(upgraded[key], before[key], `Upgrade lost ${key}.`); + assert.equal(upgraded.settings.receiverStyle, 'mesh', 'Upgrade changed the saved legacy receiver.'); + assert.equal(upgraded.settings.theme, 'amber'); + const rendered = JSON.parse(runCommand(process.execPath, ['--input-type=module', '-e', ` + import {createHash} from 'node:crypto'; + const m = await import(process.argv[1]); + const {buildVisualizer} = await import(process.argv[2]); + const playback = {state:'playing', backend:'mpv', volume:70, muted:false, ready:true}; + const hashes = m.receiverStyleNames.map(style => createHash('sha256').update(JSON.stringify(buildVisualizer(style, 37, 100, 14, null, playback, 'green'))).digest('hex')); + console.log(JSON.stringify({names:m.receiverStyleNames, defaultStyle:m.defaultReceiverStyle, hashes})); + `, pathToFileURL(join(packageRoot, 'dist', 'ui', 'visualizers', 'receiver-style-registry.js')).href, + pathToFileURL(join(packageRoot, 'dist', 'ui', 'visualizers', 'receiver-visualizers.js')).href], {cwd: project, env}).stdout); + const registry = rendered.names; + assert.equal(rendered.defaultStyle, 'ultracode'); + assert.equal(registry[0], 'ultracode'); + assert.equal(registry[8], 'gallop-fine'); + assert.equal(registry.length, 63); + assert.deepEqual(registry.slice(-2), ['alpine', 'crossfade']); + assert.equal(rendered.hashes.length, registry.length); + for (const receiverStyle of registry) { + await candidate.call('updateSettings', {receiverStyle}); + // Force both a legacy read and a legacy write, in addition to polling. + assert.equal((await old.call('snapshot')).settings.receiverStyle, receiverStyle); + await old.call('updateSettings', {volume: 41}); + const retained = await candidate.call('snapshot'); + assert.equal(retained.settings.receiverStyle, receiverStyle); + assert.equal(retained.settings.volume, 41); + for (const key of ['favorites', 'recent', 'trackHistory', 'searchHistory', 'alarms']) assert.deepEqual(retained[key], before[key], `Mixed-version writes lost ${key}.`); + } + await delay(1200); + assert.equal((await candidate.call('snapshot')).settings.receiverStyle, 'crossfade'); + assert.equal(readdirSync(home).some(name => name.includes('.bad-')), false, 'An old process quarantined the shared library.'); + await candidate.call('updateSettings', {receiverStyle: 'ultracode'}); + assert.equal((await candidate.call('snapshot')).settings.receiverStyle, 'ultracode'); + writeFileSync(`${file}.receiver.json`, JSON.stringify({version: 1, receiverStyle: 'lighthouse', libraryReceiverStyle: 'ultracode'})); + const migrated = await candidate.call('snapshot'); + assert.equal(migrated.settings.receiverStyle, 'ultracode', 'A retired preview selection did not migrate.'); + for (const key of ['favorites', 'recent', 'trackHistory', 'searchHistory', 'alarms']) assert.deepEqual(migrated[key], before[key], `Preview migration lost ${key}.`); + await candidate.call('updateSettings', {volume: 42}); + const evidence = {schemaVersion: 1, checkedAt: new Date().toISOString(), source, baseline, tarballSha256: sha256(tarball), + package: JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version, + platform: process.platform, architecture: process.arch, node: process.version, + checks: ['in-place-install', 'saved-library-preservation', 'legacy-style-preservation', 'canonical-style-order', 'all-supported-receivers', 'installed-renderers', 'retired-style-migration', 'old-process-live-reads-and-writes', 'background-polling', 'return-to-default'], result: 'passed'}; + if (evidencePath) { mkdirSync(dirname(evidencePath), {recursive: true}); writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); } + console.log(`upgrade_smoke=${JSON.stringify(evidence)}`); + return evidence; + } finally { + await Promise.all(clients.map(client => client.close())); + rmSync(temporary, {recursive: true, force: true}); + } +} diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..790b9d2 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,53 @@ +import {spawn} from 'node:child_process'; +import {mkdirSync, rmSync, writeFileSync} from 'node:fs'; +import {join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {npmCommand} from './packed-smoke.mjs'; +import {recordReleaseEvidence, sourceIdentity, upgradeBaselines} from './release-evidence.mjs'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const source = sourceIdentity(root); +if (!source.revision || source.dirty !== false) throw new Error('Release verification requires a clean committed checkout. Commit the reviewed changes before collecting release evidence.'); +const directory = resolve(process.env.RADIOCLI_SMOKE_EVIDENCE_DIR ?? join(root, 'output', 'release-evidence')); +mkdirSync(directory, {recursive: true}); +for (const name of ['candidate.json', 'normal.json', 'omit-optional.json', 'audit-runtime.json', 'network-playback.json', 'checks.json', 'release-evidence.json', ...upgradeBaselines.map(version => `upgrade-${version}.json`)]) rmSync(join(directory, name), {force: true}); +const env = {...process.env, RADIOCLI_SOURCE_REVISION: source.revision, RADIOCLI_SMOKE_EVIDENCE_DIR: directory, RADIOCLI_AUDIT_EVIDENCE_PATH: join(directory, 'audit-runtime.json')}; +const npm = npmCommand({env}); +const checks = []; +const gates = [ + ['typecheck', ['run', 'check']], ['lint', ['run', 'lint']], ['tests', ['run', 'test']], + ['harness', ['--test', 'scripts/packed-smoke.test-node.mjs', 'scripts/install-smoke-mpv.test-node.mjs', 'scripts/release-evidence.test-node.mjs', 'scripts/audit-ci.test-node.mjs', 'scripts/build-airplay-native.test-node.mjs']], + ['build', ['run', 'build']], + ['native-tui', ['exec', '--', 'vitest', 'run', 'src/agent/tui-recovery.native.test.ts']], + ['audit', ['run', 'audit:runtime']], ['live-data', ['run', 'smoke:data']], + ['source-playback', ['run', 'smoke:playback']], ['native-network', ['run', 'smoke:network']], + ['source-mcp', ['run', 'smoke:mcp']], ['background-cli', ['run', 'smoke:cli']], + ['packed', ['run', 'fresh:check', '--', '--require-mpv', '--require-clean']], + ['pack-preview', ['run', 'pack:check']], ['package', ['run', 'check:package']], + ['docs-install', ['ci', '--prefix', 'apps/docs']], ['docs-audit', ['run', 'audit:docs']], + ['docs-typecheck', ['run', 'docs:check']], ['docs-build', ['run', 'docs:build']] +]; +try { + for (const [name, args] of gates) { + console.log(`release_check=${name}`); + const started = Date.now(); + const status = await new Promise(resolveStatus => { + const checkEnv = name === 'native-tui' ? {...env, RADIOCLI_REQUIRE_TUI_RECOVERY: '1', RADIOCLI_TUI_RECOVERY_BUILT: '1'} + : name === 'docs-audit' ? {...env, RADIOCLI_AUDIT_EVIDENCE_PATH: join(directory, 'audit-docs.json')} : env; + const child = spawn(name === 'harness' ? process.execPath : npm.command, name === 'harness' ? args : [...npm.args, ...args], {cwd: root, env: checkEnv, shell: false, windowsHide: true, stdio: 'inherit'}); + child.once('error', () => resolveStatus(1)); + child.once('close', code => resolveStatus(code ?? 1)); + }); + checks.push({name, status: status === 0 ? 'passed' : 'failed', elapsedMs: Date.now() - started}); + writeFileSync(join(directory, 'checks.json'), `${JSON.stringify(checks, null, 2)}\n`); + if (status !== 0) { process.exitCode = 1; break; } + } +} finally { + const evidence = recordReleaseEvidence(directory, {expectedRevision: source.revision, checks}); + if (JSON.stringify(sourceIdentity(root)) !== JSON.stringify(source)) { + evidence.result = 'incomplete'; evidence.reasons.push('Source identity changed during release verification.'); + writeFileSync(join(directory, 'release-evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`); + } + if (evidence.result !== 'passed' || checks.some(check => check.status !== 'passed')) process.exitCode = 1; + console.log(`release_evidence=${evidence.result} directory=${directory}`); +} diff --git a/src/agent/alarm-service.ts b/src/agent/alarm-service.ts index c113820..1705cab 100644 --- a/src/agent/alarm-service.ts +++ b/src/agent/alarm-service.ts @@ -1,6 +1,8 @@ import type {Alarm, AlarmCreateInput, AlarmSchedule, IsoWeekday, Station} from '../types.js'; -import {JsonLibraryStore} from '../storage/store.js'; -import {canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone, nextOccurrenceForAlarm} from '../alarms/schedule.js'; +import type {JsonLibraryStore} from '../storage/store.js'; +import type {LibraryRepository} from '../storage/repository.js'; +import {AlarmOccurrenceCache} from '../alarms/occurrence-cache.js'; +import {canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone} from '../alarms/schedule.js'; import {connectActiveAlarms, type ActiveAlarmClient, type ActiveAlarmStatus} from '../alarms/active-session.js'; import {createSchedulerService, type SchedulerService} from '../alarms/scheduler.js'; @@ -28,8 +30,9 @@ export type AgentAlarmUpdate = Partial> & { }; export class AgentAlarmService { + private readonly occurrences = new AlarmOccurrenceCache(); constructor( - private readonly store: JsonLibraryStore, + private readonly store: JsonLibraryStore | LibraryRepository, private readonly resolveStation: (id: string) => Promise, private readonly scheduler: SchedulerService = createSchedulerService(), private readonly handoffToInteractive?: (status: ActiveAlarmStatus) => Promise @@ -70,7 +73,7 @@ export class AgentAlarmService { keepAwakeUntilAlarm: input.keepAwakeUntilAlarm ?? false } }; - const alarm = this.store.addAlarm(alarmInput); + const alarm = await this.store.addAlarm(alarmInput); await this.syncSaved(alarm); return this.describe(alarm); } @@ -81,7 +84,7 @@ export class AgentAlarmService { const fallbackStation = input.clearFallback ? undefined : input.fallbackStationId ? await this.requiredStation(input.fallbackStationId) : alarm.playback.fallbackStation; - const updated = this.store.updateAlarm(alarm.id, { + const updated = await this.store.updateAlarm(alarm.id, { label: input.label === undefined ? alarm.label : cleanLabel(input.label), enabled: input.enabled ?? alarm.enabled, station, @@ -103,7 +106,7 @@ export class AgentAlarmService { } async setEnabled(id: string, enabled: boolean): Promise> { - const alarm = this.store.toggleAlarm(this.requiredAlarm(id).id, enabled); + const alarm = await this.store.toggleAlarm(this.requiredAlarm(id).id, enabled); await this.syncSaved(alarm); return this.describe(alarm); } @@ -111,8 +114,9 @@ export class AgentAlarmService { async remove(id: string, confirm: boolean): Promise<{ok: true; removed: string}> { if (!confirm) throw new Error('Alarm removal requires confirm=true. List alarms first and confirm the exact alarm ID with the user.'); const alarm = this.requiredAlarm(id); - await this.scheduler.remove(alarm.id); - if (!this.store.removeAlarm(alarm.id)) throw new Error(`Alarm not found: ${id}`); + const disabled = await this.store.toggleAlarm(alarm.id, false); + await this.scheduler.remove(disabled.id); + if (!await this.store.removeAlarm(alarm.id)) throw new Error(`Alarm not found: ${id}`); return {ok: true, removed: alarm.id}; } @@ -146,7 +150,7 @@ export class AgentAlarmService { enabled: alarm.enabled, station: {id: `${alarm.station.provider}:${alarm.station.id}`, name: alarm.station.name}, schedule: alarm.schedule, - nextOccurrence: nextOccurrenceForAlarm(alarm, new Date())?.toISOString() ?? null, + nextOccurrence: this.occurrences.next(alarm, new Date())?.toISOString() ?? null, playback: { volume: alarm.playback.volume, fadeSeconds: alarm.playback.fadeSeconds, diff --git a/src/agent/cli.ts b/src/agent/cli.ts index 93ac83f..efdef03 100644 --- a/src/agent/cli.ts +++ b/src/agent/cli.ts @@ -1,3 +1,6 @@ +import {airPlayPolicy} from '../player/airplay-policy.js'; +import {readSettingsWithoutMigration} from '../storage/read-settings.js'; +import {defaultLibraryPath} from '../storage/store.js'; import type {AgentRuntime} from './service.js'; import {AgentRadioService, stationForAgent} from './service.js'; import {configureMcpIntegrations, mcpIntegrationReport, portableMcpConfig} from './mcp-install.js'; @@ -52,7 +55,7 @@ export async function runAgentCliCommand(args: string[], runtime: AgentRuntime): else if (command === 'airplay') value = await runAirPlayCommand(rest, service); else if (command === 'preset') value = service.configureCompletionPreset(parsePreset(rest)); else if (command === 'done') value = await service.runCompletionPreset(); - else throw new Error('Usage: radiocli agent '); + else throw new Error(agentCommandUsage()); console.log(JSON.stringify(value, null, 2)); } @@ -158,3 +161,11 @@ function assertMcpInstallSucceeded(results: Awaited `${result.client} (${result.detail})`).join('; ')}`); } + +export function agentCommandUsage(): string { + let enabled = airPlayPolicy().enabled; + if (process.platform === 'linux') { + try { enabled = airPlayPolicy({linuxAirPlayEnabled: readSettingsWithoutMigration(defaultLibraryPath())?.linuxAirPlayEnabled === true}).enabled; } catch { enabled = false; } + } + return `Usage: radiocli agent `; +} diff --git a/src/agent/command-schema.ts b/src/agent/command-schema.ts new file mode 100644 index 0000000..701f784 --- /dev/null +++ b/src/agent/command-schema.ts @@ -0,0 +1,50 @@ +import {z} from 'zod'; +import {receiverStyleNames, themeNames} from '../types.js'; +import {stationSchema} from '../storage/station-schema.js'; +import type {RadioSessionCommand} from './session.js'; + +const empty = (type: T) => z.strictObject({type: z.literal(type)}); +const adjacent = {preservePause: z.boolean().optional(), preserveStop: z.boolean().optional()}; +// A TUI list can contain many discovery pages, beyond one provider page. +const stationQueue = z.array(stationSchema).max(10_000); +const agentControl = z.strictObject({ + enabled: z.boolean(), openUiOnPlay: z.boolean(), focusNowPlaying: z.boolean(), + completionPreset: z.strictObject({ + action: z.enum(['play', 'pause', 'resume', 'stop']), source: z.enum(['recent', 'favorite', 'popular', 'country']), + countryCode: z.string().length(2).optional(), ifPlaying: z.enum(['keep', 'replace']), openUi: z.boolean().optional() + }) +}); +const commandSchema: z.ZodType = z.discriminatedUnion('type', [ + empty('status'), empty('pause'), empty('alarm-preempt'), empty('airplay-list'), empty('airplay-local'), empty('tui-cancel'), empty('tui-refresh'), empty('tui-toggle-muted'), empty('tui-toggle-pause'), + z.strictObject({type: z.literal('tui-sleep'), until: z.number().int().nonnegative().max(8.64e15).nullable()}), + z.strictObject({type: z.literal('tui-adjust-volume'), delta: z.number().min(-100).max(100)}), + z.strictObject({type: z.literal('desktop-transport'), action: z.enum(['play', 'pause', 'toggle', 'stop', 'next', 'previous'])}), + z.strictObject({type: z.literal('play'), station: stationSchema, queue: stationQueue.optional(), openNowPlaying: z.boolean().optional(), ifPlaying: z.enum(['keep', 'replace']).optional(), startPaused: z.boolean().optional()}), + z.strictObject({type: z.literal('tui-attach'), pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), clientId: z.string().uuid()}), + z.strictObject({type: z.literal('tui-detach'), clientId: z.string().uuid()}), + z.strictObject({type: z.literal('tui-select'), station: stationSchema, queue: stationQueue}), + z.strictObject({type: z.literal('tui-output'), deviceId: z.string().min(1).max(4096).nullable()}), + z.strictObject({type: z.literal('resume'), restartStopped: z.boolean().optional()}), + z.strictObject({type: z.literal('stop'), retainSelection: z.boolean().optional()}), + z.strictObject({type: z.literal('next'), ...adjacent}), z.strictObject({type: z.literal('previous'), ...adjacent}), + z.strictObject({type: z.literal('set-volume'), volume: z.number().min(0).max(100), unmute: z.boolean().optional(), allowStopped: z.boolean().optional()}), + z.strictObject({type: z.literal('set-muted'), muted: z.boolean(), allowStopped: z.boolean().optional()}), + z.strictObject({type: z.literal('set-favorite'), favorite: z.boolean(), station: stationSchema.optional()}), + z.strictObject({type: z.literal('airplay-select'), deviceId: z.string().min(1).max(4096)}), + z.strictObject({type: z.literal('airplay-passcode'), code: z.string().min(1).max(128)}), + z.strictObject({type: z.literal('update-settings'), settings: z.strictObject({theme: z.enum(themeNames).optional(), receiverStyle: z.enum(receiverStyleNames).optional(), agentControl: agentControl.optional()})}) +]); + +export function parseRadioCommand(value: unknown): RadioSessionCommand { + const parsed = commandSchema.safeParse(value); + if (!parsed.success) throw new Error(`Invalid radio command: ${parsed.error.issues.map(issue => `${issue.path.join('.') || 'command'}: ${issue.message}`).slice(0, 3).join('; ')}`); + return parsed.data; +} + +export function parseRadioRequest(value: unknown): {command: RadioSessionCommand; requestId?: string} { + if (value && typeof value === 'object' && 'version' in value) { + const envelope = z.strictObject({version: z.literal(2), requestId: z.string().uuid(), command: z.unknown()}).parse(value); + return {command: parseRadioCommand(envelope.command), requestId: envelope.requestId}; + } + return {command: parseRadioCommand(value)}; +} diff --git a/src/agent/headless-host.test.ts b/src/agent/headless-host.test.ts index 20c1210..ae1dcf5 100644 --- a/src/agent/headless-host.test.ts +++ b/src/agent/headless-host.test.ts @@ -1,4 +1,5 @@ import {chmodSync, existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {randomUUID} from 'node:crypto'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -8,11 +9,15 @@ 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'; +import * as radioSession from './session.js'; +import * as desktopMedia from '../platform/desktop-media.js'; +import * as airPlayDiscovery from '../player/airplay-discovery.js'; +import * as backendInstall from '../player/backend-install.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; +const signalNames = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const; let root: string; let playback: PlaybackState; let originalListeners: Map>; @@ -20,15 +25,15 @@ let originalListeners: Map>; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'radiocli headless 音楽-')); vi.stubEnv('RADIOCLI_HOME', root); - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']}); + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date']}); 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, 'play').mockImplementation(async (station, url, options) => { + playback = {...playback, backend: 'mpv', state: options?.startPaused ? 'paused' : '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}; @@ -45,6 +50,7 @@ beforeEach(() => { playback = {...playback, volume}; return {ok: true}; }); + vi.spyOn(PlayerController.prototype, 'setMuted').mockImplementation(async muted => { playback = {...playback, muted}; return {ok: true}; }); vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async station => ({url: station.streamUrl!, station})); vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(true); }); @@ -79,9 +85,10 @@ function hostSignalHandler(signal: NodeJS.Signals): NodeJS.SignalsListener | und } async function startHost(): Promise<{client: RadioSessionClient; libraryPath: string; sessionPath: string}> { - new JsonLibraryStore().updateSettings({shareDirectoryVotes: false}); + const seed = new JsonLibraryStore(); + seed.updateSettings({shareDirectoryVotes: false, agentControl: {...seed.settings().agentControl!, enabled: true}}); let startupError: unknown; - void runHeadlessAgentHost().catch(error => {startupError = error;}); + void runHeadlessAgentHost({worker: false}).catch(error => {startupError = error;}); await vi.waitFor(() => { if (startupError) throw startupError; expect(hostSignalHandler('SIGTERM')).toBeDefined(); @@ -101,6 +108,248 @@ function failHistory(method: typeof historyMethods[number], code = 'ENOSPC'): vo } describe('headless host optional persistence', () => { + it.each(['mpv', 'ffplay', 'vlc', 'airplay'] as const)('reattaches a crashed %s terminal to the same playback and queue while rejecting a second live view', async backend => { + const {client, sessionPath} = await startHost(); + const cli = (await connectRadioSession(sessionPath, {source: 'cli'}))!; + await cli.call({type: 'play', station: first, queue: [first, second]}); + playback = {...playback, backend}; + const original = await cli.status(); + const oldPid = 987654; + const oldClient = randomUUID(); + const nextClient = randomUUID(); + await expect(client.call({type: 'tui-attach', pid: oldPid, clientId: oldClient})).rejects.toThrow('local CLI'); + expect(await cli.call({type: 'tui-attach', pid: oldPid, clientId: oldClient})).toMatchObject({ok: true, status: {owner: 'tui', station: first, queue: [first, second]}}); + expect(await cli.call({type: 'tui-attach', pid: process.pid, clientId: nextClient})).toMatchObject({ok: false, message: expect.stringContaining('another terminal')}); + expect(await cli.call({type: 'tui-detach', clientId: nextClient})).toMatchObject({ok: false}); + vi.mocked(process.kill).mockImplementation((pid, signal) => { + if (pid === oldPid && signal === 0) throw Object.assign(new Error('dead'), {code: 'ESRCH'}); + return true; + }); + expect((await cli.status()).owner).toBe('headless'); + const recovered = await cli.call({type: 'tui-attach', pid: process.pid, clientId: nextClient}); + expect(recovered).toMatchObject({ok: true, status: {owner: 'tui', playback: original.playback, station: first, queue: [first, second]}}); + expect(PlayerController.prototype.play).toHaveBeenCalledOnce(); + expect((await cli.call({type: 'next'})).status.station).toEqual(second); + await cli.call({type: 'stop'}); + await vi.advanceTimersByTimeAsync(100); + expect(existsSync(sessionPath)).toBe(true); + expect(await cli.call({type: 'tui-detach', clientId: nextClient})).toMatchObject({ok: true}); + expect(playback.ready).toBe(false); + await vi.advanceTimersByTimeAsync(50); + await vi.waitFor(() => expect(process.exit).toHaveBeenCalledWith(0)); + expect(existsSync(sessionPath)).toBe(false); + expect(existsSync(`${sessionPath}.owner`)).toBe(false); + expect(process.kill).not.toHaveBeenCalledWith(process.pid, 'SIGTERM'); + }); + + it('retains a TUI queue spanning more than one thousand loaded discovery results', async () => { + const {sessionPath} = await startHost(); + const cli = (await connectRadioSession(sessionPath, {source: 'cli'}))!; + await cli.call({type: 'tui-attach', pid: process.pid, clientId: randomUUID()}); + const queue = Array.from({length: 1200}, (_, index) => ({...first, id: `loaded-${index}`})); + const tuned = await cli.call({type: 'play', station: queue[1100]!, queue}); + expect(tuned.status.station).toEqual(queue[1100]); + expect((await cli.status()).queue).toEqual(queue); + }); + + it('keeps the sleep timer in the playback owner after the terminal disappears', async () => { + const {sessionPath} = await startHost(); + const cli = (await connectRadioSession(sessionPath, {source: 'cli'}))!; + await cli.call({type: 'tui-attach', pid: 987654, clientId: randomUUID()}); + await cli.call({type: 'play', station: first}); + const until = Date.now() + 1000; + expect((await cli.call({type: 'tui-sleep', until})).status.tui?.sleepUntil).toBe(until); + vi.mocked(process.kill).mockImplementation((pid, signal) => { + if (pid === 987654 && signal === 0) throw Object.assign(new Error('dead'), {code: 'ESRCH'}); + return true; + }); + await vi.advanceTimersByTimeAsync(1100); + const status = await cli.status(); + expect(status.playback.state).toBe('stopped'); + expect(status.tui?.sleepUntil).toBeNull(); + expect(status.station).toEqual(first); + }); + + it('publishes live metadata, saves track history, and clears the title on retune', async () => { + let emitMetadata: Parameters[0] | undefined; + const unsubscribe = vi.fn(); + vi.spyOn(PlayerController.prototype, 'onMetadata').mockImplementation(listener => { emitMetadata = listener; return unsubscribe; }); + const {client, libraryPath, sessionPath} = await startHost(); + await client.call({type: 'play', station: first, queue: [first, second]}); + emitMetadata!({title: 'Artist - Live song', updatedAt: '2030-01-01T00:00:00Z'}); + expect((await client.status()).nowPlaying?.title).toBe('Artist - Live song'); + expect(new JsonLibraryStore(libraryPath).snapshot().trackHistory[0]).toMatchObject({title: 'Artist - Live song', stationName: first.name}); + await client.call({type: 'next'}); + expect((await client.status()).nowPlaying).toBeNull(); + hostSignalHandler('SIGTERM')!('SIGTERM'); + await vi.waitFor(() => expect(existsSync(sessionPath)).toBe(false)); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it('rejects an RPC attempt to enable experimental output through appearance settings', async () => { + const {client, libraryPath} = await startHost(); + await expect(client.call({type: 'update-settings', settings: {linuxAirPlayEnabled: true}} as unknown as RadioSessionCommand)).rejects.toThrow('Invalid radio command'); + expect(new JsonLibraryStore(libraryPath).snapshot().settings.linuxAirPlayEnabled).not.toBe(true); + }); + + it('blocks disabled output commands at the owner before discovery or sender access', async () => { + vi.spyOn(PlayerController.prototype, 'airPlayPolicy').mockReturnValue({eligible: true, enabled: false, state: 'disabled', message: 'Experimental Linux AirPlay is disabled. Run radiocli setup --airplay.'}); + const discover = vi.spyOn(PlayerController.prototype, 'refreshAirPlayDevices'); + const select = vi.spyOn(PlayerController.prototype, 'selectAirPlayDevice'); + const passcode = vi.spyOn(PlayerController.prototype, 'submitAirPlayPasscode'); + const {client} = await startHost(); + for (const command of [{type: 'airplay-list'}, {type: 'airplay-select', deviceId: 'saved'}, {type: 'airplay-passcode', code: '1234'}] as RadioSessionCommand[]) { + expect(await client.call(command)).toMatchObject({ok: false, message: expect.stringContaining('setup --airplay')}); + } + expect(discover).not.toHaveBeenCalled(); expect(select).not.toHaveBeenCalled(); expect(passcode).not.toHaveBeenCalled(); + expect(await client.call({type: 'play', station: first})).toMatchObject({ok: true}); + }); + + it('discovers and explicitly selects a receiver, submits a code, switches local, and yields AirPlay to an alarm', async () => { + new JsonLibraryStore().updateSettings({linuxAirPlayEnabled: true}); + vi.spyOn(PlayerController.prototype, 'airPlayPolicy').mockReturnValue({eligible: true, enabled: true, state: 'enabled', message: 'Enabled.'}); + vi.mocked(PlayerController.prototype.refreshDetectedBackends).mockReturnValue(['mpv', 'airplay']); + vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv', 'airplay']); + const receiver = {id: 'ID@Office', name: 'Office', host: '192.168.1.10', port: 5000, txt: ['pw=true'], requiresPassword: true, airplay2: false}; + const discover = vi.spyOn(airPlayDiscovery, 'discoverAirPlayDevices').mockResolvedValue([receiver]); + const select = vi.spyOn(PlayerController.prototype, 'selectAirPlayDevice'); + const passcode = vi.spyOn(PlayerController.prototype, 'submitAirPlayPasscode').mockReturnValue({ok: true}); + const {client} = await startHost(); + expect(discover).not.toHaveBeenCalled(); + expect(await client.call({type: 'airplay-list'})).toMatchObject({ok: true, data: [receiver]}); + expect(select).not.toHaveBeenCalled(); + expect(await client.call({type: 'airplay-select', deviceId: 'missing'})).toMatchObject({ok: false}); + expect(await client.call({type: 'airplay-select', deviceId: receiver.id})).toMatchObject({ok: true, status: {output: {preferredBackend: 'airplay'}}}); + expect(select).toHaveBeenCalledWith(receiver.id); + await client.call({type: 'airplay-passcode', code: '1234'}); + expect(passcode).toHaveBeenCalledWith('1234'); + expect(JSON.stringify(new JsonLibraryStore().snapshot())).not.toContain('1234'); + expect(await client.call({type: 'airplay-local'})).toMatchObject({ok: true, status: {output: {preferredBackend: 'mpv'}}}); + expect(select).toHaveBeenCalledWith(null); + await client.call({type: 'airplay-select', deviceId: receiver.id}); + await client.call({type: 'play', station: first}); + playback = {...playback, backend: 'airplay'}; + expect(await client.call({type: 'alarm-preempt'})).toMatchObject({ok: true, status: {station: null, playback: {state: 'stopped'}}}); + expect(PlayerController.prototype.stop).toHaveBeenCalled(); + }); + + it.each([ + 'The Avahi discovery service is not ready. Local playback remains available.', + 'AirPlay playback requires FFmpeg.', + 'The optional AirPlay sender could not load. Local playback remains available.' + ])('propagates a Linux readiness failure and preserves local controls: %s', async message => { + new JsonLibraryStore().updateSettings({linuxAirPlayEnabled: true}); + vi.spyOn(PlayerController.prototype, 'airPlayPolicy').mockReturnValue({eligible: true, enabled: true, state: 'enabled', message: 'Enabled.'}); + vi.mocked(PlayerController.prototype.refreshDetectedBackends).mockReturnValue(['mpv', 'airplay']); + const discover = vi.spyOn(airPlayDiscovery, 'discoverAirPlayDevices').mockRejectedValue(new Error(message)); + const {client} = await startHost(); + await expect(client.call({type: 'airplay-list'})).rejects.toThrow(message); + await expect(client.call({type: 'airplay-select', deviceId: 'saved'})).rejects.toThrow(message); + expect(await client.call({type: 'play', station: first})).toMatchObject({ok: true}); + discover.mockClear(); + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + expect(await client.call({type: 'airplay-list'})).toMatchObject({ok: false, message: 'AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.'}); + expect(discover).not.toHaveBeenCalled(); + expect(await client.call({type: 'pause'})).toMatchObject({ok: true}); + }); + + it('executes an earlier HTTP Resume before a later desktop Stop while HTTP Pause is busy', async () => { + const start = vi.spyOn(desktopMedia, 'startDesktopMediaControls').mockReturnValue({close: vi.fn(), refresh: vi.fn()}); + const serialize = radioSession.serializeRadioCommands; + let resumeAccepted = false; + vi.spyOn(radioSession, 'serializeRadioCommands').mockImplementation(handle => { + const dispatch = serialize(handle); + return (command, permitted) => { + if (command.type === 'resume') resumeAccepted = true; + return dispatch(command, permitted); + }; + }); + const {client} = await startHost(); + const desktop = start.mock.calls[0]![0]; + await client.call({type: 'play', station: first, queue: [first, second]}); + let entered = false; + let release: () => void = () => undefined; + const held = new Promise(resolve => { release = resolve; }); + vi.mocked(PlayerController.prototype.pause).mockImplementationOnce(async () => { + entered = true; + await held; + playback = {...playback, state: 'paused'}; + return {ok: true}; + }); + const requests = [client.call({type: 'pause'})]; + try { + await vi.waitFor(() => expect(entered).toBe(true)); + requests.push(client.call({type: 'resume'})); + await vi.waitFor(() => expect(resumeAccepted).toBe(true)); + requests.push(desktop.command({type: 'desktop-transport', action: 'stop'})); + release(); + const results = await Promise.all(requests); + expect(results.map(result => result.status.playback.state)).toEqual(['paused', 'playing', 'stopped']); + expect(await client.status()).toMatchObject({station: first, queue: [first, second], playback: {state: 'stopped'}}); + } finally { + release(); + await Promise.allSettled(requests); + } + }); + + it.each([ + ['Pause then Play', ['pause', 'play'], 'first'], + ['two toggles', ['toggle', 'toggle'], 'first'], + ['Stop then Play', ['stop', 'play'], 'first'], + ['Stop then Next then Play', ['stop', 'next', 'play'], 'second'] + ] as const)('resolves %s against current state after a busy agent command', async (_label, actions, expectedStation) => { + const start = vi.spyOn(desktopMedia, 'startDesktopMediaControls').mockReturnValue({close: vi.fn(), refresh: vi.fn()}); + const {client} = await startHost(); + const desktop = start.mock.calls[0]![0]; + await client.call({type: 'play', station: first, queue: [first, second]}); + let release: (() => void) | undefined; + let entered = false; + vi.mocked(ProviderManager.prototype.resolve).mockImplementationOnce(async station => { + entered = true; + await new Promise(resolve => { release = resolve; }); + return {url: station.streamUrl!}; + }); + const busyAgent = client.call({type: 'play', station: first, queue: [first, second]}); + await vi.waitFor(() => expect(entered).toBe(true)); + const requests = actions.map(action => desktop.command({type: 'desktop-transport', action})); + release!(); + const results = await Promise.allSettled([busyAgent, ...requests]); + if ((actions as readonly string[]).includes('stop')) expect(results[0]).toMatchObject({status: 'rejected', reason: expect.objectContaining({message: expect.stringContaining('canceled')})}); + else expect(results[0]).toMatchObject({status: 'fulfilled', value: {ok: true}}); + expect(results.slice(1).every(result => result.status === 'fulfilled' && result.value.ok)).toBe(true); + expect(await client.status()).toMatchObject({station: {id: expectedStation}, playback: {state: 'playing'}}); + }); + + it('runs desktop controls through the existing owner queue, preserves pause, and yields to alarms', async () => { + const close = vi.fn(); + const refresh = vi.fn(); + const start = vi.spyOn(desktopMedia, 'startDesktopMediaControls').mockReturnValue({close, refresh}); + const {client} = await startHost(); + const desktop = start.mock.calls[0]![0]; + await client.call({type: 'play', station: first, queue: [first, second]}); + await desktop.command({type: 'desktop-transport', action: 'pause'}); + expect(playback.state).toBe('paused'); + const next = await desktop.command({type: 'desktop-transport', action: 'next'}); + expect(next).toMatchObject({ok: true, status: {station: second, playback: {state: 'paused'}}}); + expect(PlayerController.prototype.play).toHaveBeenLastCalledWith(second, second.streamUrl, expect.objectContaining({startPaused: true, signal: expect.any(AbortSignal)})); + expect(new JsonLibraryStore().snapshot().activity.sessions.some(item => item.station.id === second.id && !item.endedAt)).toBe(false); + const stopped = await desktop.command({type: 'desktop-transport', action: 'stop'}); + expect(stopped.status).toMatchObject({station: second, queue: [first, second], playback: {state: 'stopped'}}); + await desktop.command({type: 'desktop-transport', action: 'next'}); + expect(playback.state).toBe('stopped'); + const resumed = await desktop.command({type: 'desktop-transport', action: 'play'}); + expect(resumed.status).toMatchObject({station: first, playback: {state: 'playing'}}); + await desktop.command({type: 'set-muted', muted: true}); + await desktop.command({type: 'set-volume', volume: 37, unmute: true}); + expect(playback).toMatchObject({volume: 37, muted: false}); + vi.advanceTimersByTime(50); + expect(process.kill).not.toHaveBeenCalledWith(process.pid, 'SIGTERM'); + await client.call({type: 'alarm-preempt'}); + expect(desktop.suspended()).toBe(true); + await expect(desktop.command({type: 'desktop-transport', action: 'next'})).rejects.toThrow('suspended'); + hostSignalHandler('SIGHUP')!('SIGHUP'); + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + }); 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.'); @@ -118,10 +367,10 @@ describe('headless host optional persistence', () => { 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.advanceTimersByTimeAsync(50); + expect(process.kill).not.toHaveBeenCalledWith(process.pid, 'SIGTERM'); await vi.waitFor(() => expect(existsSync(sessionPath)).toBe(false)); + expect(process.exit).toHaveBeenCalledWith(0); expect(existsSync(`${sessionPath}.owner`)).toBe(false); }); diff --git a/src/agent/headless-host.ts b/src/agent/headless-host.ts index e1bf25b..e3f529f 100644 --- a/src/agent/headless-host.ts +++ b/src/agent/headless-host.ts @@ -1,98 +1,170 @@ import {PlayerController} from '../player/player-controller.js'; import {ProviderManager} from '../providers/provider-manager.js'; import {JsonLibraryStore, stationKey} from '../storage/store.js'; +import {LibraryRepository} from '../storage/repository.js'; +import {RadioEngine} from '../player/radio-engine.js'; import type {Station} from '../types.js'; -import {startRadioSession, type RadioSessionCommand, type RadioSessionResult, type RadioSessionStatus} from './session.js'; -import {airPlayMacOSOnlyMessage, detectPlaybackBackends, isAirPlayPlatformSupported} from '../player/backend-install.js'; +import {allowedRadioSessionSettings, resolveDesktopTransport, serializeRadioCommands, startRadioSession, type RadioSessionCommand, type RadioSessionResult, type RadioSessionStatus} from './session.js'; +import {desktopMediaPolicy, startDesktopMediaControls} from '../platform/desktop-media.js'; +import {detectPlaybackBackends} from '../player/backend-install.js'; +import {networkPolicy} from '../platform/network.js'; -export async function runHeadlessAgentHost(): Promise { - const store = new JsonLibraryStore(); +export async function runHeadlessAgentHost(options: {store?: JsonLibraryStore; worker?: boolean} = {}): Promise { + const store = new LibraryRepository(options.store ?? new JsonLibraryStore(), {worker: options.worker}); const providers = new ProviderManager(); - const player = new PlayerController(() => store.snapshot().settings); + const player = new PlayerController(() => store.settings()); player.refreshDetectedBackends(); - let station: Station | null = null; - let queue: Station[] = []; + const engine = new RadioEngine(player, providers, store, () => store.settings()); let persistenceWarning: string | undefined; - - 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.`; + let desktopSuspended = false; + let shuttingDown = false; + let media: ReturnType | undefined; + let tui: {pid: number; clientId: string} | undefined; + let terminalProtocol = false; + let focusNowPlaying = 0; + let sleepUntil: number | null = null; + let sleepTimer: NodeJS.Timeout | undefined; + const scheduleSleep = () => { + if (sleepUntil === null) return; + const remaining = sleepUntil - Date.now(); + if (remaining <= 0) { + sleepUntil = null; + void dispatch({type: 'stop', retainSelection: true}).catch(() => undefined); + } else { + sleepTimer = setTimeout(scheduleSleep, Math.min(2_147_483_647, remaining)); + sleepTimer.unref(); } }; - 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 tuiPresent = () => { + if (!tui) return false; + try { process.kill(tui.pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; } }; - const status = (): RadioSessionStatus => ({owner: 'headless', playback: player.getState(), station, queue, persistenceWarning, output: { - preferredBackend: store.snapshot().settings.preferredBackend, - preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice - }}); + const saveLibrary = async (write: () => Promise): Promise => { + await write(); + persistenceWarning = undefined; + engine.clearWarning(); + }; + const status = (): RadioSessionStatus => { + const current = engine.snapshot(); + const settings = store.settings(); + return {owner: tuiPresent() ? 'tui' : 'headless', playback: player.getState(), station: current.station, nowPlaying: current.nowPlaying, + queue: current.queue, persistenceWarning: persistenceWarning ?? current.warning, output: { + preferredBackend: settings.preferredBackend === 'airplay' && player.airPlaySelectionRequired() ? 'auto' : settings.preferredBackend, + preferredAirPlayDevice: settings.preferredAirPlayDevice + }, ...(terminalProtocol ? {tui: {tuningStation: current.tuningStation, message: current.message, focusNowPlaying, sleepUntil, diagnostics: player.diagnostics(), + devices: player.detectedAirPlayDevices(), selectionRequired: player.airPlaySelectionRequired(), desktopMediaCompatible: player.desktopMediaCompatible()}} : {})}; + }; 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 => { - 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]; - persistHistory(() => store.addRecent(next)); - persistHistory(() => store.startListeningSession(next)); - return result(`Playing ${next.name}.`); + const play = async (next: Station, nextQueue: Station[] = [next], startPaused = false, signal?: AbortSignal): Promise => { + signal?.throwIfAborted(); + // Earlier accepted output saves must reach their durable acknowledgement + // before this tune chooses a backend. Stop can still cancel the wait. + await store.readFresh(); + signal?.throwIfAborted(); + desktopSuspended = false; + const tuned = await engine.tune(next, {queue: nextQueue, startPaused, signal}); + if (shuttingDown) return result('RadioCLI is shutting down.', false); + media?.refresh(); + return result(`${startPaused ? 'Selected' : 'Playing'} ${tuned.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); + const handle = async (command: RadioSessionCommand, signal?: AbortSignal): Promise => { + if (shuttingDown) return result('RadioCLI is shutting down.', false); + signal?.throwIfAborted(); + const {station, queue} = engine.snapshot(); + if (command.type === 'tui-attach') { + if (tuiPresent() && tui?.clientId !== command.clientId) return result('RadioCLI is already open in another terminal.', false); + tui = {pid: command.pid, clientId: command.clientId}; + terminalProtocol = true; + return result(station ? `Reconnected to ${station.name}.` : 'RadioCLI connected.'); + } + if (command.type === 'tui-detach') { + if (tui?.clientId !== command.clientId) return result('This terminal no longer owns the RadioCLI view.', false); + tui = undefined; + shuttingDown = true; + await engine.stop(); + // Let the command response finish, then close our own resources directly. + // Sending SIGTERM to ourselves terminates Windows without running cleanup. + setTimeout(onShutdown, 50).unref(); + return result('Closed RadioCLI.'); + } + if (command.type === 'tui-cancel') { engine.cancel(); return result('Canceled pending tune.'); } + if (command.type === 'tui-sleep') { + clearTimeout(sleepTimer); + sleepUntil = command.until; + scheduleSleep(); + return result('Sleep timer updated.'); } + if (command.type === 'tui-adjust-volume') return handle({type: 'set-volume', volume: Math.max(0, Math.min(100, player.getState().volume + command.delta))}, signal); + if (command.type === 'tui-toggle-muted') return handle({type: 'set-muted', muted: !player.getState().muted}, signal); + if (command.type === 'tui-toggle-pause') { + const control = await player.togglePause(); + await engine.flushHistory(); + return result(control.message ?? 'Playback updated.', control.ok); + } + if (command.type === 'tui-select') { engine.select(command.station, command.queue); media?.refresh(); return result(`Selected ${command.station.name}.`); } + if (command.type === 'tui-refresh') { await store.readFresh(); player.refreshDetectedBackends(); return result('Playback settings refreshed.'); } + if (command.type === 'tui-output') { + await store.readFresh(); + const selected = await player.selectAirPlayDevice(command.deviceId); + return result(selected ? 'Audio output selected.' : 'Audio output is unavailable.', selected); + } + if (command.type === 'desktop-transport') { + const resolved = resolveDesktopTransport(command.action, status()); + return resolved ? handle(resolved, signal) : result('Playback state unchanged.'); + } + if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !player.airPlayPolicy().enabled) { + return result(player.airPlayPolicy().message, false, command.type === 'airplay-list' ? [] : undefined); + } + if (['airplay-list', 'airplay-select'].includes(command.type) && networkPolicy().offline) return result('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []); 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 ?? ''}.`); - return play(command.station, command.queue); + if (command.openNowPlaying) focusNowPlaying++; + return play(command.station, command.queue, command.startPaused, signal); } if (command.type === 'pause') { - const control = await player.pause(); - if (control.ok) persistHistory(() => store.finishActiveListeningSession()); + const control = await engine.pause(); return result(control.message ?? 'Paused.', control.ok); } if (command.type === 'resume') { - const control = await player.resume(); - if (control.ok && station) persistHistory(() => store.startListeningSession(station!)); + if (command.restartStopped && station && ['idle', 'stopped'].includes(player.getState().state)) return play(station, queue, false, signal); + const control = await engine.resume(); return result(control.message ?? 'Resumed.', control.ok); } if (command.type === 'stop') { - persistHistory(() => store.finishActiveListeningSession()); - await player.stop(); - station = null; - queue = []; - setTimeout(() => process.kill(process.pid, 'SIGTERM'), 50).unref(); + await engine.stop({retainSelection: command.retainSelection}); + if (command.retainSelection) { media?.refresh(); return result('Stopped playback.'); } + if (!tuiPresent()) { + shuttingDown = true; + setTimeout(onShutdown, 50).unref(); + } return result('Stopped RadioCLI.'); } if (command.type === 'alarm-preempt') { - persistHistory(() => store.finishActiveListeningSession()); - await player.stop(); - station = null; - queue = []; + desktopSuspended = true; + media?.refresh(); + await engine.stop(); return result('Interactive playback yielded to the alarm.'); } if (command.type === 'set-volume') { - const control = await player.setVolume(command.volume); - if (control.ok) saveLibrary(() => store.updateSettings({volume: player.getState().volume})); + const control = command.unmute || command.allowStopped ? await player.setVolume(command.volume, {allowStopped: true}) : await player.setVolume(command.volume); + if (control.ok) await saveLibrary(() => store.updateSettings({volume: player.getState().volume})); + if (control.ok && command.unmute && command.volume > 0) { + const unmuted = await player.setMuted(false, {allowStopped: true}); + if (!unmuted.ok) return result(unmuted.message ?? 'The output could not be unmuted.', false); + } 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); } + if (command.type === 'set-muted') { const control = await player.setMuted(command.muted, {allowStopped: command.allowStopped}); return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok); } if (command.type === 'set-favorite') { const target = command.station ?? station; if (!target) return result('No active station to favorite.', false); const current = store.isFavorite(target); - if (current !== command.favorite) saveLibrary(() => store.toggleFavorite(target)); - if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes) void providers.vote(target); + if (current !== command.favorite) await saveLibrary(() => store.toggleFavorite(target)); + if (command.favorite && !current && store.settings().shareDirectoryVotes) void providers.vote(target); return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`); } if (command.type === 'airplay-list') { @@ -101,52 +173,75 @@ export async function runHeadlessAgentHost(): Promise { } if (command.type === 'airplay-select') { const devices = await player.refreshAirPlayDevices(); + signal?.throwIfAborted(); const device = devices.find(item => item.id === command.deviceId); 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); - saveLibrary(() => { - store.updateSettings({preferredAirPlayDevice: device.id}); - store.updateSettings({preferredBackend: 'airplay'}); - }); - if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue); + if (device.local) return switchToLocal(signal); + if (!player.refreshDetectedBackends().includes('airplay')) return result('AirPlay playback is unavailable; run radiocli doctor.', false); + await saveLibrary(() => store.updateSettings({preferredAirPlayDevice: device.id, preferredBackend: 'airplay'})); + await player.selectAirPlayDevice(device.id); + signal?.throwIfAborted(); + if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue, false, signal); return result(`Audio output set to AirPlay receiver ${device.name}.`); } - if (command.type === 'airplay-local') return switchToLocal(); + if (command.type === 'airplay-local') return switchToLocal(signal); if (command.type === 'airplay-passcode') { - const control = player.submitAirPlayPasscode(command.code); + const control = await player.submitAirPlayPasscode(command.code); return result(control.message ?? 'AirPlay code sent.', control.ok); } if (command.type === 'update-settings') { - saveLibrary(() => store.updateSettings(command.settings)); + if (!allowedRadioSessionSettings(command.settings)) return result('Session settings only accept appearance and agent control. Use radiocli setup --airplay to enable Linux AirPlay.', false); + await saveLibrary(() => store.updateSettings(command.settings)); return result('RadioCLI settings updated.'); } + if (command.type !== 'next' && command.type !== 'previous') throw new Error('Unsupported radio command.'); const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station!)) : -1; const delta = command.type === 'next' ? 1 : -1; const next = queue.length ? queue[(Math.max(0, currentIndex) + delta + queue.length) % queue.length] : undefined; - return next ? play(next, queue) : result('No playback queue is available.', false); + if (next && command.preserveStop && ['idle', 'stopped'].includes(player.getState().state)) { engine.select(next, queue); media?.refresh(); return result(`Selected ${next.name}.`); } + return next ? play(next, queue, Boolean(command.preservePause && player.getState().state === 'paused'), signal) : result('No playback queue is available.', false); }; - const switchToLocal = async (): Promise => { + const switchToLocal = async (signal?: AbortSignal): Promise => { + signal?.throwIfAborted(); 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); - saveLibrary(() => store.updateSettings({preferredBackend: backend})); - if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue); + await saveLibrary(() => store.updateSettings({preferredBackend: backend})); + await player.selectAirPlayDevice(null); + signal?.throwIfAborted(); + const {station, queue} = engine.snapshot(); + if (station && ['playing', 'paused', 'loading'].includes(player.getState().state)) return play(station, queue, false, signal); return result(`Audio output set to this device (${backend}).`); }; - const session = await startRadioSession(handle); - const checkpoint = setInterval(() => { - if (player.getState().state === 'playing') persistHistory(() => store.checkpointActiveListeningSession()); - }, 30_000); - const shutdown = async () => { - clearInterval(checkpoint); - persistHistory(() => store.finishActiveListeningSession()); - await player.stop(); - await session.close(); - }; - process.once('SIGTERM', () => { void shutdown().finally(() => process.exit(0)); }); - process.once('SIGINT', () => { void shutdown().finally(() => process.exit(0)); }); + const dispatch = serializeRadioCommands(handle, {responsive: true}); + const session = await startRadioSession((command, source) => dispatch(command, async () => { + if (command.type.startsWith('tui-') && source !== 'cli') throw new Error('Terminal attachment requires a local CLI client.'); + if (source !== 'cli' && command.type !== 'status' && command.type !== 'alarm-preempt') { + await store.readFresh(); + if (!store.settings().agentControl?.enabled) throw new Error('Agent controls are disabled.'); + } + return true; + }), undefined, {queue: 'owner'}).catch(async error => { + try { await engine.close(); } finally { await store.close(); } + throw error; + }); + media = startDesktopMediaControls({player, identity: store.filePath, policy: desktopMediaPolicy(store.settings()), status, + command: (command, permitted) => dispatch(command, () => permitted?.() !== false && !desktopSuspended), suspended: () => desktopSuspended}); + const unsubscribe = engine.onChange(() => media?.refresh()); + let shutdownPromise: Promise | undefined; + const signals = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const; + const shutdown = () => shutdownPromise ??= (async () => { + shuttingDown = true; + clearTimeout(sleepTimer); + desktopSuspended = true; + for (const signal of signals) process.removeListener(signal, onShutdown); + media?.close(); + unsubscribe(); + try { await engine.close(); } finally { await session.close(); await store.close(); } + })(); + const onShutdown = () => { void shutdown().finally(() => process.exit(0)); }; + for (const signal of signals) process.once(signal, onShutdown); await new Promise(() => undefined); } diff --git a/src/agent/launcher.ts b/src/agent/launcher.ts index 9d02026..a4a87f4 100644 --- a/src/agent/launcher.ts +++ b/src/agent/launcher.ts @@ -8,5 +8,6 @@ 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})); + const loader = cliPath.endsWith('.tsx') ? ['--import', import.meta.resolve('tsx')] : []; + await waitForLaunch(spawn(nodePath, [...loader, cliPath, 'agent-host'], {detached: true, stdio: 'ignore', windowsHide: true})); } diff --git a/src/agent/mcp-server.ts b/src/agent/mcp-server.ts index 7a03196..54a6b6a 100644 --- a/src/agent/mcp-server.ts +++ b/src/agent/mcp-server.ts @@ -1,15 +1,17 @@ import {McpServer} from '@modelcontextprotocol/server'; import {serveStdio} from '@modelcontextprotocol/server/stdio'; import {z} from 'zod'; +import {JsonLibraryStore} from '../storage/store.js'; +import {LibraryRepository} from '../storage/repository.js'; import {appVersion} from '../version.js'; import {receiverStyleNames, themeNames} from '../types.js'; import {AgentRadioService, stationForAgent, type AgentRuntime} from './service.js'; export const radioCliMcpInstructions = `RadioCLI is the authoritative handler for requests about RadioCLI, “the radio”, “my radio”, recent stations, favorites, or stations from a place. Never substitute a web browser, web search, Computer Use, or another radio/music service. “Play my most recent radio station” means call radio_play with source="recent" and index=0. “Play a station from New York” means call radio_search with query="New York", then radio_play with one returned opaque station ID. Call these tools directly; do not delegate a radio-only operation or create a goal merely to monitor it. -RadioCLI controls internet radio, alarms, and macOS AirPlay locally. Discover unidentified stations with radio_search or radio_browse, then use only returned opaque IDs; never invent IDs or stream URLs. Ordinary stop never dismisses an alarm: list active alarms and use alarm controls, with an exact ID when several ring. Never remove an alarm without explicit user confirmation. List AirPlay receivers before selecting one. Never install an update without explicit approval. +RadioCLI controls internet radio, alarms, and experimental AirPlay locally. Discover unidentified stations with radio_search or radio_browse, then use only returned opaque IDs; never invent IDs or stream URLs. Ordinary stop never dismisses an alarm: list active alarms and use alarm controls, with an exact ID when several ring. Never remove an alarm without explicit user confirmation. List AirPlay receivers before selecting one. Never install an update without explicit approval. -For “this station”, omit station_id when favoriting. A request such as “do X, then play or stop the radio” is a normal completion action in the current task: perform X, then call radio_run_completion_preset once X is genuinely complete. Do not create or set a goal, scheduled task, automation, reminder, task record, or separate monitoring task merely to defer or track that radio action. Use one of those mechanisms only when the user explicitly requests it for a separate reason. AirPlay is macOS-only. Submit a receiver code only when the user provides it; codes are never saved. Use radio_update_status for version or upgrade questions, and tell the user to restart their MCP client after an update.`; +For “this station”, omit station_id when favoriting. A request such as “do X, then play or stop the radio” is a normal completion action in the current task: perform X, then call radio_run_completion_preset once X is genuinely complete. Do not create or set a goal, scheduled task, automation, reminder, task record, or separate monitoring task merely to defer or track that radio action. Use one of those mechanisms only when the user explicitly requests it for a separate reason. AirPlay is experimental on macOS and native Linux; Linux receiver selection is required each session. Submit a receiver code only when the user provides it; codes are never saved. Use radio_update_status for version or upgrade questions, and tell the user to restart their MCP client after an update.`; const alarmScheduleSchema = z.discriminatedUnion('type', [ z.object({type: z.literal('once'), at: z.string().min(1).describe('Absolute ISO-8601 minute including an offset or Z, with zero seconds.')}), @@ -34,7 +36,9 @@ const alarmOptionsSchema = { }; export async function runMcpServer(runtime: AgentRuntime): Promise { - const service = new AgentRadioService(runtime); + const store = new LibraryRepository(new JsonLibraryStore()); + const service = new AgentRadioService(runtime, store); + process.once('beforeExit', () => { void store.close().catch(() => undefined); }); serveStdio(() => createMcpServer(service), {onerror: error => console.error(`RadioCLI MCP: ${error.message}`)}); } @@ -44,6 +48,7 @@ function createMcpServer(service: AgentRadioService): McpServer { const config = schema ? {description, inputSchema: schema} : {description}; server.registerTool(name, config, async raw => { try { + await service.refresh(); const value = await handler(raw as T); return {content: [{type: 'text' as const, text: JSON.stringify(value, null, 2)}]}; } catch (error) { @@ -128,7 +133,7 @@ function createMcpServer(service: AgentRadioService): McpServer { occurrence_at: z.string().min(1).optional(), snooze_minutes: z.number().int().min(1).max(1440).optional() }).refine(value => value.action === 'snooze' || value.snooze_minutes === undefined, {message: 'snooze_minutes is only valid for the snooze action.'}), ({action, alarm_id, occurrence_at, snooze_minutes}) => service.alarmControl({action, alarmId: alarm_id, occurrenceAt: occurrence_at, snoozeMinutes: snooze_minutes})); - tool('radio_airplay_list', 'Discover AirPlay receivers visible to this Mac. Returns opaque receiver IDs; always call this before selecting a receiver.', undefined, async () => (await service.listAirPlayDevices()).map(device => ({ + tool('radio_airplay_list', 'Discover AirPlay receivers visible to this device. Returns opaque receiver IDs; always call this before selecting a receiver.', undefined, async () => (await service.listAirPlayDevices()).map(device => ({ id: device.id, name: device.name, host: device.host, @@ -138,7 +143,7 @@ function createMcpServer(service: AgentRadioService): McpServer { local: device.local ?? false }))); tool('radio_airplay_select', 'Switch current or future interactive playback to an exact AirPlay receiver ID returned by radio_airplay_list. Opens RadioCLI when needed unless open_ui=false.', z.object({device_id: z.string().min(1), open_ui: z.boolean().optional()}), ({device_id, open_ui}) => service.selectAirPlayDevice(device_id, open_ui)); - tool('radio_airplay_use_local', 'Switch interactive playback from AirPlay back to this Mac using the best available local backend.', z.object({open_ui: z.boolean().optional()}).optional(), args => service.useLocalOutput(args?.open_ui)); + tool('radio_airplay_use_local', 'Switch interactive playback from AirPlay back to this device using the best available local backend.', z.object({open_ui: z.boolean().optional()}).optional(), args => service.useLocalOutput(args?.open_ui)); tool('radio_airplay_submit_code', 'Submit a receiver-displayed AirPlay code to the active AirPlay session. Use only a code the user explicitly provides; RadioCLI never saves it.', z.object({code: z.string().min(1).max(64)}), ({code}) => service.submitAirPlayPasscode(code)); return server; } diff --git a/src/agent/service.test.ts b/src/agent/service.test.ts index 362d71f..5c5ac59 100644 --- a/src/agent/service.test.ts +++ b/src/agent/service.test.ts @@ -7,16 +7,100 @@ import {defaultAgentControlSettings} from '../types.js'; import type {Station} from '../types.js'; import {startActiveAlarmSession} from '../alarms/active-session.js'; import {startRadioSession, type RadioSessionStatus} from './session.js'; +import * as airPlayPolicyModule from '../player/airplay-policy.js'; +import * as backendInstall from '../player/backend-install.js'; +import {PlayerController} from '../player/player-controller.js'; import {AgentRadioService} from './service.js'; +import {ProviderManager} from '../providers/provider-manager.js'; const roots: string[] = []; afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllEnvs(); for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); }); describe('agent radio service policy', () => { + it('honors Nearby privacy before geolocation and returns useful failures and bounded results', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-nearby-')); + roots.push(root); + const store = new JsonLibraryStore(join(root, 'library.json')); + store.updateSettings({enableNearbyLocation: false, agentControl: {...defaultAgentControlSettings, enabled: true}}); + const providers = new ProviderManager(); + const detect = vi.spyOn(providers, 'detectLocation').mockResolvedValue(null); + const nearby = vi.spyOn(providers, 'nearby').mockResolvedValue([]); + const service = new AgentRadioService({nodePath: '/node', cliPath: '/radiocli.js'}, store, providers); + await expect(service.browse('nearby')).rejects.toThrow('disabled'); + expect(detect).not.toHaveBeenCalled(); + expect(nearby).not.toHaveBeenCalled(); + store.updateSettings({enableNearbyLocation: true}); + await expect(service.browse('nearby')).rejects.toThrow('approximate location'); + expect(nearby).not.toHaveBeenCalled(); + const location = {latitude: 35.6762, longitude: 139.6503, source: 'fixture'}; + const station: Station = {id: 'tokyo', provider: 'radio-browser', name: 'Tokyo', tags: []}; + detect.mockResolvedValue(location); + nearby.mockResolvedValue([station]); + await expect(service.browse('nearby', 500)).resolves.toEqual([station]); + expect(nearby).toHaveBeenCalledWith(location, 50); + nearby.mockRejectedValue(new Error('Directory unavailable')); + await expect(service.browse('nearby')).rejects.toThrow('Directory unavailable'); + }); + + it.each(['agent', 'cli'] as const)('requires Linux opt-in even when %s control is authorized', async source => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-airplay-')); + roots.push(root); vi.stubEnv('RADIOCLI_HOME', root); + const store = new JsonLibraryStore(join(root, 'library.json')); + store.updateSettings({agentControl: {...defaultAgentControlSettings, enabled: true}, preferredAirPlayDevice: 'saved'}); + const policy = airPlayPolicyModule.airPlayPolicy; + vi.spyOn(airPlayPolicyModule, 'airPlayPolicy').mockImplementation(settings => policy(settings, {platform: 'linux', env: {}, release: ''})); + const discovery = vi.spyOn(PlayerController.prototype, 'refreshAirPlayDevices'); + const service = new AgentRadioService({nodePath: '/node', cliPath: '/radiocli.js'}, store, undefined, source); + await expect(service.listAirPlayDevices()).rejects.toThrow('setup --airplay'); + await expect(service.selectAirPlayDevice('saved')).rejects.toThrow('setup --airplay'); + await expect(service.submitAirPlayPasscode('1234')).rejects.toThrow('setup --airplay'); + expect(discovery).not.toHaveBeenCalled(); + expect(store.snapshot().settings.linuxAirPlayEnabled).not.toBe(true); + }); + + it.each(['agent', 'cli'] as const)('propagates an active TUI prerequisite failure to %s callers', async source => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-airplay-error-')); + roots.push(root); vi.stubEnv('RADIOCLI_HOME', root); + const store = new JsonLibraryStore(join(root, 'library.json')); + store.updateSettings({linuxAirPlayEnabled: true, agentControl: {...defaultAgentControlSettings, enabled: true}}); + vi.spyOn(airPlayPolicyModule, 'airPlayPolicy').mockReturnValue({eligible: true, enabled: true, state: 'enabled', message: 'Enabled.'}); + const status: RadioSessionStatus = {owner: 'tui', playback: {backend: 'mpv', state: 'idle', volume: 70, muted: false, ready: false}, station: null, queue: []}; + const radio = await startRadioSession(async command => command.type === 'airplay-list' + ? {ok: false, message: 'AirPlay setup required. Run radiocli setup --airplay, then radiocli doctor.', status, data: []} + : {ok: true, message: 'Ready.', status}); + try { + const service = new AgentRadioService({nodePath: '/node', cliPath: '/radiocli.js'}, store, undefined, source); + await expect(service.listAirPlayDevices()).rejects.toThrow('setup --airplay'); + await expect(service.selectAirPlayDevice('saved')).rejects.toThrow('setup --airplay'); + } finally { await radio.close(); } + }); + + it.each(['ffmpeg', 'avahi-browse', 'sender'])('explains missing %s to standalone callers before discovery', async missing => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-airplay-missing-')); + roots.push(root); vi.stubEnv('RADIOCLI_HOME', root); + const store = new JsonLibraryStore(join(root, 'library.json')); + store.updateSettings({linuxAirPlayEnabled: true, agentControl: {...defaultAgentControlSettings, enabled: true}}); + const detect = backendInstall.detectPlaybackBackends; + vi.spyOn(backendInstall, 'detectPlaybackBackends').mockImplementation(options => detect({...options, + platform: 'linux', env: {}, release: '6.6', + hasCommand: command => ['mpv', 'ffmpeg', 'avahi-browse'].includes(command) && command !== missing, + hasAirPlaySender: () => missing !== 'sender' + })); + vi.spyOn(airPlayPolicyModule, 'airPlayPolicy').mockReturnValue({eligible: true, enabled: true, state: 'enabled', message: 'Enabled.'}); + const discovery = vi.spyOn(PlayerController.prototype, 'refreshAirPlayDevices').mockRejectedValue(new Error('Unexpected discovery.')); + for (const source of ['agent', 'cli'] as const) { + const service = new AgentRadioService({nodePath: '/node', cliPath: '/radiocli.js'}, store, undefined, source); + await expect(service.listAirPlayDevices()).rejects.toThrow('radiocli doctor'); + await expect(service.selectAirPlayDevice('saved')).rejects.toThrow('radiocli doctor'); + } + expect(discovery).not.toHaveBeenCalled(); + }); + it('blocks mutations while disabled and keeps repeated stop idempotent when enabled', async () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-service-')); roots.push(root); diff --git a/src/agent/service.ts b/src/agent/service.ts index 5e06eea..1085af1 100644 --- a/src/agent/service.ts +++ b/src/agent/service.ts @@ -1,14 +1,18 @@ import {randomInt} from 'node:crypto'; import type {AgentControlSettings, AirPlayDevice, AppSettings, Station, ThemeName, ReceiverStyle} from '../types.js'; import {defaultAgentControlSettings, themeNames, receiverStyleNames} from '../types.js'; +import {LibraryRepository} from '../storage/repository.js'; import {JsonLibraryStore, stationKey} from '../storage/store.js'; +import {parseRadioCommand} from './command-schema.js'; +import {matchesStationId} from '../storage/station-identity.js'; import {ProviderManager} from '../providers/provider-manager.js'; import {computeListeningStats} from '../activity/stats.js'; import {connectActiveAlarms, type ActiveAlarmStatus} from '../alarms/active-session.js'; import {connectRadioSession, ensureRadioSession, type RadioSessionCommand, type RadioSessionResult} from './session.js'; import {launchHeadlessHost, launchRadioTui} from './launcher.js'; import {PlayerController} from '../player/player-controller.js'; -import {airPlayMacOSOnlyMessage, detectPlaybackBackends, isAirPlayPlatformSupported} from '../player/backend-install.js'; +import {airPlayPolicy} from '../player/airplay-policy.js'; +import {detectPlaybackBackends} 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'; @@ -22,8 +26,10 @@ export class AgentRadioService { constructor( private readonly runtime: AgentRuntime, - private readonly store = new JsonLibraryStore(), - private readonly providers = new ProviderManager() + private readonly store: JsonLibraryStore | LibraryRepository = new JsonLibraryStore(), + private readonly providers = new ProviderManager(), + // Local shell commands do not enable or register agent/MCP integrations. + private readonly source: 'agent' | 'cli' = 'agent' ) { this.alarms = new AgentAlarmService( this.store, @@ -33,7 +39,10 @@ export class AgentRadioService { ); } - private settings(): AppSettings { return this.store.snapshot().settings; } + async refresh(): Promise { if (this.store instanceof LibraryRepository) await this.store.readFresh(); } + private settings(): AppSettings { return this.store.settings(); } + + private connect() { return connectRadioSession(undefined, {source: this.source}); } appearance(): {themes: typeof themeNames; receiverStyles: typeof receiverStyleNames; current: AppSettings} { this.assertEnabled(); @@ -42,7 +51,7 @@ export class AgentRadioService { async status(): Promise> { this.assertEnabled(); - const client = await connectRadioSession(); + const client = await this.connect(); const state = this.store.snapshot(); return { connected: Boolean(client), @@ -121,22 +130,28 @@ export class AgentRadioService { ? await this.findStation(input.stationId, queue) : queue[input.random ? randomInt(queue.length) : Math.max(0, input.index ?? 0)]; if (!station) throw new Error(input.stationId ? `Unknown station ID: ${input.stationId}. Search or browse first.` : 'No station is available for that request.'); - const ordered = [station, ...queue.filter(item => stationKey(item) !== stationKey(station))]; + return this.playStation(station, queue, input); + } + + async playStation(station: Station, queue: Station[] = [station], options: {openUi?: boolean; ifPlaying?: 'keep' | 'replace'} = {}): Promise { + this.assertEnabled(); + if ((await connectActiveAlarms()).length > 0) throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.'); + const ordered = queue.some(item => stationKey(item) === stationKey(station)) ? queue : [station, ...queue]; return this.send({ type: 'play', station, queue: ordered, - openNowPlaying: this.agentSettings().focusNowPlaying, - ifPlaying: input.ifPlaying ?? 'replace' - }, input.openUi); + openNowPlaying: this.source === 'agent' && this.agentSettings().focusNowPlaying, + ifPlaying: options.ifPlaying ?? 'replace' + }, options.openUi); } async control(command: Exclude, openUi?: boolean): Promise { this.assertEnabled(); - const client = await connectRadioSession(); + const client = await this.connect(); if (!client && command.type === 'set-volume') { const volume = Math.min(100, Math.max(0, command.volume)); - this.store.updateSettings({volume}); + await this.store.updateSettings({volume}); return idleResult(`Saved volume ${volume}; no playback session is active.`); } if (!client && ['stop', 'pause', 'resume', 'next', 'previous', 'set-volume', 'set-muted'].includes(command.type)) { @@ -148,13 +163,20 @@ export class AgentRadioService { async setFavorite(favorite: boolean, stationId?: string): Promise { this.assertEnabled(); - const client = await connectRadioSession(); + const client = await this.connect(); if (!stationId && client) return client.call({type: 'set-favorite', favorite}); const station = stationId ? await this.findStation(stationId, this.savedStations()) : undefined; if (!station) throw new Error('No station was supplied and no active station is available.'); + return this.favoriteStation(station, favorite); + } + + async favoriteStation(station: Station, favorite: boolean): Promise { + this.assertEnabled(); + const client = await this.connect(); + if (client) return client.call({type: 'set-favorite', favorite, station}); const isFavorite = this.store.isFavorite(station); - if (isFavorite !== favorite) this.store.toggleFavorite(station); - if (favorite && !isFavorite && this.store.snapshot().settings.shareDirectoryVotes) void this.providers.vote(station); + if (isFavorite !== favorite) await this.store.toggleFavorite(station); + if (favorite && !isFavorite && this.store.snapshot().settings.shareDirectoryVotes) void this.providers.vote(station).catch(() => undefined); return {ok: true, message: `${favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}`, station}; } @@ -162,9 +184,9 @@ export class AgentRadioService { this.assertEnabled(); if (input.theme && !themeNames.includes(input.theme)) throw new Error(`Unknown theme: ${input.theme}`); if (input.receiverStyle && !receiverStyleNames.includes(input.receiverStyle)) throw new Error(`Unknown receiver style: ${input.receiverStyle}`); - const client = await connectRadioSession(); + const client = await this.connect(); if (client) return client.call({type: 'update-settings', settings: input}); - return this.store.updateSettings(input).settings; + return (await this.store.updateSettings(input)).settings; } async runCompletionPreset(): Promise { @@ -173,21 +195,21 @@ export class AgentRadioService { return this.control({type: preset.action}, preset.openUi); } - configureCompletionPreset(input: Partial): AgentControlSettings { + async configureCompletionPreset(input: Partial): Promise { this.assertEnabled(); const agentControl = this.agentSettings(); const completionPreset = {...agentControl.completionPreset, ...input}; if (completionPreset.source === 'country' && !completionPreset.countryCode) throw new Error('A two-letter countryCode is required for the country preset source.'); if (completionPreset.countryCode) completionPreset.countryCode = completionPreset.countryCode.toUpperCase(); - return this.store.updateSettings({agentControl: {...agentControl, completionPreset}}).settings.agentControl!; + return (await this.store.updateSettings({agentControl: {...agentControl, completionPreset}})).settings.agentControl!; } async updateAgentPreferences(input: {openUiOnPlay?: boolean; focusNowPlaying?: boolean}): Promise { this.assertEnabled(); const agentControl = {...this.agentSettings(), ...input}; - const client = await connectRadioSession(); + const client = await this.connect(); if (client) return client.call({type: 'update-settings', settings: {agentControl}}); - return this.store.updateSettings({agentControl}).settings.agentControl; + return (await this.store.updateSettings({agentControl})).settings.agentControl; } alarmList(): Promise[]> { this.assertEnabled(); return this.alarms.list(); } @@ -202,11 +224,15 @@ export class AgentRadioService { async listAirPlayDevices(): Promise { this.assertEnabled(); this.assertAirPlayPlatform(); - const client = await connectRadioSession(); - if (client) return (await client.call({type: 'airplay-list'})).data ?? []; + const client = await this.connect(); + if (client) { + const result = await client.call({type: 'airplay-list'}); + if (!result.ok) throw new Error(result.message); + return result.data ?? []; + } const player = new PlayerController(() => this.store.snapshot().settings); const backends = player.refreshDetectedBackends(); - if (!backends.includes('airplay')) throw new Error('AirPlay is unavailable on this Mac. Run radiocli doctor to check ffmpeg, dns-sd, and the sender package.'); + if (!backends.includes('airplay')) throw new Error('AirPlay is unavailable on this device. Run radiocli doctor to check FFmpeg, discovery, and the optional sender.'); return player.refreshAirPlayDevices(); } @@ -217,17 +243,17 @@ export class AgentRadioService { const device = devices.find(item => item.id === deviceId); if (!device) throw new Error('Unknown AirPlay receiver ID. Refresh the receiver list and use an exact returned ID.'); if (device.local) return this.useLocalOutput(openUi); - this.store.updateSettings({preferredAirPlayDevice: device.id}); + await this.store.updateSettings({preferredAirPlayDevice: device.id}); return this.send({type: 'airplay-select', deviceId: device.id}, openUi); } async useLocalOutput(openUi?: boolean): Promise { this.assertEnabled(); - const client = await connectRadioSession(); + const client = await this.connect(); if (!client) { const backend = preferredLocalBackend(detectPlaybackBackends()); if (!backend) throw new Error('No local playback backend is available. Run radiocli setup to install mpv.'); - this.store.updateSettings({preferredBackend: backend}); + await this.store.updateSettings({preferredBackend: backend}); return idleResult(`Audio output set to this device (${backend}).`); } return this.send({type: 'airplay-local'}, openUi); @@ -236,7 +262,7 @@ export class AgentRadioService { async submitAirPlayPasscode(code: string): Promise { this.assertEnabled(); this.assertAirPlayPlatform(); - const client = await connectRadioSession(); + const client = await this.connect(); if (!client) throw new Error('No active AirPlay session is waiting for a receiver code.'); return client.call({type: 'airplay-passcode', code}); } @@ -262,15 +288,15 @@ export class AgentRadioService { private async send(command: RadioSessionCommand, openUi?: boolean, allowActiveAlarm = false): Promise { const settings = this.settings(); const agentControl = settings.agentControl ?? defaultAgentControlSettings; - if (!agentControl.enabled) throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.'); - let client = await connectRadioSession(); + this.assertEnabled(); + let client = await this.connect(); if (!client) { if (!allowActiveAlarm && (await connectActiveAlarms()).length > 0) throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.'); - const shouldOpen = openUi ?? agentControl.openUiOnPlay; + const shouldOpen = openUi ?? (this.source === 'agent' && agentControl.openUiOnPlay); client = await ensureRadioSession(async () => { if (shouldOpen) await launchRadioTui(this.runtime.nodePath, this.runtime.cliPath, encodeAgentCommand({type: 'status'})); else await launchHeadlessHost(this.runtime.nodePath, this.runtime.cliPath); - }); + }, undefined, {source: this.source}); } return client.call(command); } @@ -295,8 +321,9 @@ export class AgentRadioService { private async findStation(id: string, candidates: Station[]): Promise { const normalized = id.trim(); - const local = this.stationCache.get(normalized) - ?? candidates.find(station => opaqueStationId(station) === normalized || station.id === normalized); + const matches = new Map(candidates.filter(station => matchesStationId(station, normalized)).map(station => [stationKey(station), station])); + if (matches.size > 1) throw new Error('This legacy station ID is ambiguous. Search or browse again and use the current station ID.'); + const local = this.stationCache.get(normalized) ?? [...matches.values()][0]; if (local) return local; const separator = normalized.indexOf(':'); const provider = separator > 0 ? normalized.slice(0, separator) : 'radio-browser'; @@ -308,7 +335,12 @@ export class AgentRadioService { } private remember(stations: Station[]): Station[] { - for (const station of stations) this.stationCache.set(opaqueStationId(station), station); + for (const station of stations) { + const key = opaqueStationId(station); + this.stationCache.delete(key); + this.stationCache.set(key, station); + } + while (this.stationCache.size > 1024) this.stationCache.delete(this.stationCache.keys().next().value!); return stations; } @@ -317,11 +349,12 @@ export class AgentRadioService { } private assertEnabled(): void { - if (!this.agentSettings().enabled) throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.'); + if (this.source === 'agent' && !this.agentSettings().enabled) throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.'); } private assertAirPlayPlatform(): void { - if (!isAirPlayPlatformSupported()) throw new Error(airPlayMacOSOnlyMessage); + const policy = airPlayPolicy(this.settings()); + if (!policy.enabled) throw new Error(policy.message); } } @@ -342,7 +375,7 @@ export function stationForAgent(station: Station): Record { } function encodeAgentCommand(command: RadioSessionCommand): string { return Buffer.from(JSON.stringify(command)).toString('base64url'); } -export function decodeAgentCommand(value: string): RadioSessionCommand { return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as RadioSessionCommand; } +export function decodeAgentCommand(value: string): RadioSessionCommand { return parseRadioCommand(JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))); } function clampLimit(value: number, max = 30): number { return Math.min(max, Math.max(1, Math.round(value))); } function idleResult(message: string): RadioSessionResult { diff --git a/src/agent/session-architecture.test.ts b/src/agent/session-architecture.test.ts new file mode 100644 index 0000000..2df8e7c --- /dev/null +++ b/src/agent/session-architecture.test.ts @@ -0,0 +1,174 @@ +import {randomUUID} from 'node:crypto'; +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {createServer} from 'node:http'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {parseRadioCommand} from './command-schema.js'; +import {connectRadioSession, ensureRadioSession, serializeRadioCommands, startRadioSession, type RadioSessionResult} from './session.js'; + +const station = {id: 'first', provider: 'playlist' as const, name: 'First', tags: [], streamUrl: 'https://example.test/live'}; +const idle: RadioSessionResult = {ok: true, message: 'ok', status: {owner: 'headless', station: null, queue: [], playback: {backend: 'none', state: 'idle', ready: false, volume: 70, muted: false}}}; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); }); +function deferred() { let resolve!: () => void; const promise = new Promise(finish => { resolve = finish; }); return {promise, resolve}; } +function sessionPath() { const root = mkdtempSync(join(tmpdir(), 'radiocli-command-contract-')); roots.push(root); return join(root, 'session.json'); } + +describe('responsive control protocol', () => { + it('never launches a playback owner for an already canceled request', async () => { + const controller = new AbortController(); controller.abort(); const start = vi.fn(async () => {}); + await expect(connectRadioSession(sessionPath(), {signal: controller.signal})).rejects.toMatchObject({name: 'AbortError'}); + await expect(ensureRadioSession(start, 1000, {signal: controller.signal})).rejects.toMatchObject({name: 'AbortError'}); + expect(start).not.toHaveBeenCalled(); + }); + + it.each([ + {type: 'invented-command'}, {type: 'set-volume', volume: NaN}, {type: 'set-volume', volume: 101}, + {type: 'play', station: {id: 'x'}}, {type: 'next', preserveStop: 'yes'}, + {type: 'update-settings', settings: {enableExperimentalLinuxAirPlay: true}} + ])('rejects malformed commands before dispatch: %j', command => { + expect(() => parseRadioCommand(command)).toThrow('Invalid radio command'); + }); + + it('answers status and Stop while resolution is blocked, cancels queued transport, and admits a subsequent tune', async () => { + const held = deferred(); + const order: string[] = []; + const dispatch = serializeRadioCommands(async (command, signal) => { + order.push(command.type === 'play' ? `play:${command.station.id}` : command.type); + if (command.type === 'play' && command.station.id === 'first') { await held.promise; signal?.throwIfAborted(); } + return idle; + }, {responsive: true}); + const play = dispatch({type: 'play', station}); + const pause = dispatch({type: 'pause'}); + await vi.waitFor(() => expect(order).toEqual(['play:first'])); + await expect(dispatch({type: 'status'})).resolves.toEqual(idle); + await expect(dispatch({type: 'stop'})).resolves.toEqual(idle); + await expect(play).rejects.toMatchObject({name: 'AbortError'}); + await expect(pause).rejects.toMatchObject({name: 'AbortError'}); + await expect(dispatch({type: 'play', station: {...station, id: 'second'}})).resolves.toEqual(idle); + expect(order).toEqual(['play:first', 'status', 'stop', 'play:second']); + held.resolve(); + }); + + it('preserves desktop Pause/Play order across a pending tune without blocking preference updates', async () => { + const held = deferred(); const order: string[] = []; + const dispatch = serializeRadioCommands(async command => { + order.push(command.type === 'desktop-transport' ? command.action : command.type); + if (command.type === 'play') await held.promise; + return idle; + }, {responsive: true}); + const play = dispatch({type: 'play', station}); + await vi.waitFor(() => expect(order).toEqual(['play'])); + await dispatch({type: 'update-settings', settings: {theme: 'amber'}}); + const pause = dispatch({type: 'desktop-transport', action: 'pause'}); + const resume = dispatch({type: 'desktop-transport', action: 'play'}); + expect(order).toEqual(['play', 'update-settings']); + held.resolve(); await Promise.all([play, pause, resume]); + expect(order).toEqual(['play', 'update-settings', 'pause', 'play']); + }); + + it('cancels pending output discovery for an authorized replacement, without canceling later transport', async () => { + const held = deferred(); const signals = new Map(); const finished: string[] = []; + const dispatch = serializeRadioCommands(async (command, signal) => { + signals.set(command.type, signal); + if (command.type === 'airplay-select') { await held.promise; signal?.throwIfAborted(); } + finished.push(command.type); + return idle; + }, {responsive: true}); + const selection = dispatch({type: 'airplay-select', deviceId: 'receiver'}); + await vi.waitFor(() => expect(signals.has('airplay-select')).toBe(true)); + const play = dispatch({type: 'play', station}); + const pause = dispatch({type: 'pause'}); + await expect(play).resolves.toEqual(idle); + await expect(selection).rejects.toMatchObject({name: 'AbortError'}); + await expect(pause).resolves.toEqual(idle); + expect(signals.get('airplay-select')?.aborted).toBe(true); + expect(signals.get('pause')?.aborted).toBe(false); + held.resolve(); + await Promise.resolve(); + expect(finished).toEqual(['play', 'pause']); + }); + + it('does not let an unauthorized replacement cancel accepted output discovery', async () => { + const held = deferred(); let signal: AbortSignal | undefined; + const dispatch = serializeRadioCommands(async (command, current) => { + if (command.type === 'airplay-select') { signal = current; await held.promise; } + return idle; + }, {responsive: true}); + const selection = dispatch({type: 'airplay-select', deviceId: 'receiver'}); + await vi.waitFor(() => expect(signal).toBeDefined()); + await expect(dispatch({type: 'play', station}, () => false)).rejects.toThrow('suspended'); + expect(signal?.aborted).toBe(false); + held.resolve(); await expect(selection).resolves.toEqual(idle); + }); + + it.each(['stop', 'play'] as const)('preserves the earlier tune fence after a denied %s', async type => { + const held = deferred(); const order: string[] = []; + const dispatch = serializeRadioCommands(async command => { + order.push(command.type); + if (command.type === 'play') await held.promise; + return idle; + }, {responsive: true}); + const play = dispatch({type: 'play', station}); + await vi.waitFor(() => expect(order).toEqual(['play'])); + await expect(dispatch(type === 'play' ? {type, station} : {type}, () => false)).rejects.toThrow('suspended'); + const pause = dispatch({type: 'pause'}); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(order).toEqual(['play']); + held.resolve(); await Promise.all([play, pause]); + expect(order).toEqual(['play', 'pause']); + }); + + it('does not cancel playback for an unauthorized Stop and retains priority access when admission is full', async () => { + const held = deferred(); let signal: AbortSignal | undefined; + const dispatch = serializeRadioCommands(async (command, current) => { + if (command.type === 'play') { signal = current; await held.promise; } + return idle; + }, {responsive: true, limit: 1}); + const play = dispatch({type: 'play', station}); + await vi.waitFor(() => expect(signal).toBeDefined()); + await expect(dispatch({type: 'stop'}, () => false)).rejects.toThrow('suspended'); + expect(signal?.aborted).toBe(false); + await expect(dispatch({type: 'pause'})).rejects.toThrow('busy'); + await expect(dispatch({type: 'status'})).resolves.toEqual(idle); + await dispatch({type: 'stop'}); + await expect(play).rejects.toMatchObject({name: 'AbortError'}); + held.resolve(); + }); + + it('deduplicates concurrent and retried request IDs and rejects an ID with a different payload', async () => { + const path = sessionPath(); let writes = 0; const held = deferred(); + const server = await startRadioSession(async command => { + if (command.type === 'set-muted') { writes++; await held.promise; } + return idle; + }, path, {queue: 'owner'}); + try { + const client = (await connectRadioSession(path))!; const id = randomUUID(); + const first = client.call({type: 'set-muted', muted: true}, id); + const retry = client.call({type: 'set-muted', muted: true}, id); + await vi.waitFor(() => expect(writes).toBe(1)); + await expect(client.call({type: 'set-muted', muted: false}, id)).rejects.toThrow('different command'); + held.resolve(); await Promise.all([first, retry]); + await client.call({type: 'set-muted', muted: true}, id); + expect(writes).toBe(1); + const discovery = JSON.parse(readFileSync(path, 'utf8')) as {host: string; port: number; token: string}; + const host = discovery.host.includes(':') ? `[${discovery.host}]` : discovery.host; + const response = await fetch(`http://${host}:${discovery.port}/command`, {method: 'POST', headers: {authorization: `Bearer ${discovery.token}`}, body: JSON.stringify({type: 'typo'})}); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({error: expect.stringContaining('Invalid radio command')}); + expect(writes).toBe(1); + } finally { held.resolve(); await server.close(); } + }); + + it('rejects malformed HTTP responses without an uncaught parser exception', async () => { + const path = sessionPath(); let malformed = false; + const server = createServer((req, res) => { req.resume(); res.end(malformed ? '{invalid' : JSON.stringify(idle)); }); + 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('Missing listener'); + writeFileSync(path, JSON.stringify({version: 1, host: '127.0.0.1', port: address.port, token: 'test', pid: process.pid})); + const client = (await connectRadioSession(path))!; malformed = true; + await expect(client.status()).rejects.toThrow(); + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } + }); +}); diff --git a/src/agent/session.test.ts b/src/agent/session.test.ts index 7671847..b07aaa3 100644 --- a/src/agent/session.test.ts +++ b/src/agent/session.test.ts @@ -3,8 +3,8 @@ 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'; -import {connectRadioSession, startRadioSession, type RadioSessionCommand, type RadioSessionStatus} from './session.js'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {connectRadioSession, resolveDesktopTransport, serializeRadioCommands, startRadioSession, type RadioSessionCommand, type RadioSessionStatus} from './session.js'; const roots: string[] = []; const idle: RadioSessionStatus = { @@ -19,6 +19,39 @@ afterEach(() => { }); describe('agent radio session', () => { + it('resolves capability no-ops and pause/stop preservation only from the state at execution', () => { + const station = {provider: 'playlist' as const, id: 'first', name: 'First', tags: []}; + const selected = {...idle, station, queue: [station, {...station, id: 'second'}], playback: {...idle.playback, backend: 'mpv', state: 'stopped' as const}}; + expect(resolveDesktopTransport('play', idle)).toBeNull(); + expect(resolveDesktopTransport('next', idle)).toBeNull(); + expect(resolveDesktopTransport('stop', idle)).toEqual({type: 'stop', retainSelection: true}); + expect(resolveDesktopTransport('stop', selected)).toEqual({type: 'stop', retainSelection: true}); + expect(resolveDesktopTransport('pause', selected)).toBeNull(); + expect(resolveDesktopTransport('play', selected)).toEqual({type: 'resume', restartStopped: true}); + expect(resolveDesktopTransport('next', selected)).toEqual({type: 'next', preservePause: true, preserveStop: true}); + expect(resolveDesktopTransport('play', {...selected, playback: {...selected.playback, state: 'playing', ready: true}})).toBeNull(); + expect(resolveDesktopTransport('toggle', {...selected, playback: {...selected.playback, state: 'paused', ready: true}})).toEqual({type: 'resume'}); + expect(resolveDesktopTransport('pause', {...selected, playback: {...selected.playback, backend: 'ffplay', state: 'playing', ready: true}})).toBeNull(); + expect(() => resolveDesktopTransport('toggle', {...selected, playback: {...selected.playback, backend: 'ffplay', state: 'playing', ready: true}})).toThrow('cannot toggle'); + }); + it('shares one command queue across agent and desktop controls and checks alarm ownership when work starts', async () => { + let suspended = false; + const order: string[] = []; + const dispatch = serializeRadioCommands(async command => { + order.push(command.type); + if (command.type === 'alarm-preempt') suspended = true; + if (command.type === 'pause') throw new Error('Output unavailable'); + return {ok: true, message: 'ok', status: idle}; + }); + const failed = dispatch({type: 'pause'}); + const alarm = dispatch({type: 'alarm-preempt'}); + const desktop = dispatch({type: 'next', preservePause: true}, () => !suspended); + await expect(failed).rejects.toThrow('Output unavailable'); + await expect(alarm).resolves.toMatchObject({ok: true}); + await expect(desktop).rejects.toThrow('suspended'); + expect(order).toEqual(['pause', 'alarm-preempt']); + await expect(dispatch({type: 'status'})).resolves.toMatchObject({ok: true}); + }); it('authenticates requests and serializes simultaneous agent commands', async () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-session-')); roots.push(root); @@ -45,6 +78,45 @@ describe('agent radio session', () => { expect(await connectRadioSession(path)).toBeNull(); }); + it('submits accepted HTTP commands to the shared owner queue before later desktop actions', async () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-shared-session-')); + roots.push(root); + const path = join(root, 'session.json'); + const accepted: string[] = []; + const executed: string[] = []; + let release: () => void = () => undefined; + const held = new Promise(resolve => { release = resolve; }); + const dispatch = serializeRadioCommands(async command => { + executed.push(command.type); + if (command.type === 'pause') await held; + return {ok: true, message: command.type, status: idle}; + }); + const server = await startRadioSession(command => { + accepted.push(command.type); + return dispatch(command); + }, path, {queue: 'owner'}); + const requests: Promise[] = []; + try { + const client = await connectRadioSession(path); + expect(client).not.toBeNull(); + accepted.length = 0; + executed.length = 0; + requests.push(client!.call({type: 'pause'})); + await vi.waitFor(() => expect(executed).toEqual(['pause'])); + requests.push(client!.call({type: 'resume'})); + await vi.waitFor(() => expect(accepted).toEqual(['pause', 'resume'])); + requests.push(dispatch({type: 'desktop-transport', action: 'stop'})); + expect(executed).toEqual(['pause']); + release(); + await Promise.all(requests); + expect(executed).toEqual(['pause', 'resume', 'desktop-transport']); + } finally { + release(); + await Promise.allSettled(requests); + await server.close(); + } + }); + it('allows only one playback owner for a discovery path', async () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-agent-owner-')); roots.push(root); diff --git a/src/agent/session.ts b/src/agent/session.ts index 0be284f..5d626c0 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -1,9 +1,10 @@ -import {randomBytes} from 'node:crypto'; +import {createHash, randomBytes, randomUUID} from 'node:crypto'; +import {parseRadioCommand, parseRadioRequest} from './command-schema.js'; import {chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; import {createServer, request} from 'node:http'; import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; -import type {AirPlayDevice, AppSettings, PlaybackState, Station} from '../types.js'; +import type {AirPlayDevice, AppSettings, IcyNowPlaying, PlaybackDiagnostics, PlaybackState, Station} from '../types.js'; import {isLoopbackHost, listenLoopback, type LoopbackHost} from '../platform/loopback.js'; export type RadioSessionStatus = { @@ -11,21 +12,43 @@ export type RadioSessionStatus = { owner: 'tui' | 'headless'; playback: PlaybackState; station: Station | null; + nowPlaying?: IcyNowPlaying | null; queue: Station[]; output?: Pick; + tui?: { + tuningStation: Station | null; + message?: string; + focusNowPlaying?: number; + sleepUntil?: number | null; + diagnostics: PlaybackDiagnostics; + devices: AirPlayDevice[]; + selectionRequired: boolean; + desktopMediaCompatible: boolean; + }; }; export type RadioSessionCommand = + | {type: 'desktop-transport'; action: 'play' | 'pause' | 'toggle' | 'stop' | 'next' | 'previous'} | {type: 'status'} - | {type: 'play'; station: Station; queue?: Station[]; openNowPlaying?: boolean; ifPlaying?: 'keep' | 'replace'} + | {type: 'play'; station: Station; queue?: Station[]; openNowPlaying?: boolean; ifPlaying?: 'keep' | 'replace'; startPaused?: boolean} + | {type: 'tui-attach'; pid: number; clientId: string} + | {type: 'tui-detach'; clientId: string} + | {type: 'tui-cancel'} + | {type: 'tui-select'; station: Station; queue: Station[]} + | {type: 'tui-output'; deviceId: string | null} + | {type: 'tui-refresh'} + | {type: 'tui-sleep'; until: number | null} + | {type: 'tui-adjust-volume'; delta: number} + | {type: 'tui-toggle-muted'} + | {type: 'tui-toggle-pause'} | {type: 'pause'} - | {type: 'resume'} - | {type: 'stop'} + | {type: 'resume'; restartStopped?: boolean} + | {type: 'stop'; retainSelection?: boolean} | {type: 'alarm-preempt'} - | {type: 'next'} - | {type: 'previous'} - | {type: 'set-volume'; volume: number} - | {type: 'set-muted'; muted: boolean} + | {type: 'next'; preservePause?: boolean; preserveStop?: boolean} + | {type: 'previous'; preservePause?: boolean; preserveStop?: boolean} + | {type: 'set-volume'; volume: number; unmute?: boolean; allowStopped?: boolean} + | {type: 'set-muted'; muted: boolean; allowStopped?: boolean} | {type: 'set-favorite'; favorite: boolean; station?: Station} | {type: 'airplay-list'} | {type: 'airplay-select'; deviceId: string} @@ -33,22 +56,122 @@ export type RadioSessionCommand = | {type: 'airplay-passcode'; code: string} | {type: 'update-settings'; settings: Partial>}; +/** The wire protocol accepts appearance/MCP preferences, never experimental-output consent. */ +export function allowedRadioSessionSettings(value: unknown): boolean { + return value !== null && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value).every(key => ['theme', 'receiverStyle', 'agentControl'].includes(key)); +} + export type RadioSessionResult = {ok: boolean; message: string; status: RadioSessionStatus; data?: AirPlayDevice[]}; -type Discovery = {version: 1; host: LoopbackHost; port: number; token: string; pid: number; createdAt: string}; + +/** Resolve desktop intent at the owner queue boundary, after earlier commands + * have completed. In particular, Play/PlayPause must not use a stale snapshot. */ +export function resolveDesktopTransport(action: Extract['action'], status: RadioSessionStatus): Exclude | null { + const {playback, station, queue} = status; + const stopped = ['idle', 'stopped'].includes(playback.state); + if (action === 'play') { + if (playback.state === 'paused') return {type: 'resume'}; + return stopped && station ? {type: 'resume', restartStopped: true} : null; + } + if (action === 'pause') return playback.state === 'playing' && playback.backend === 'mpv' ? {type: 'pause'} : null; + if (action === 'toggle') { + if (!station || playback.backend !== 'mpv') throw new Error('This output cannot toggle pause.'); + return playback.state === 'playing' ? {type: 'pause'} : playback.state === 'paused' ? {type: 'resume'} : stopped ? {type: 'resume', restartStopped: true} : null; + } + // Even an idle backend can have an unresolved tune or delayed retry to cancel. + if (action === 'stop') return {type: 'stop', retainSelection: true}; + return station && queue.length > 1 && (stopped || playback.state === 'playing' || playback.state === 'paused') + ? {type: action, preservePause: true, preserveStop: true} : null; +} + +/** Agent and desktop controls must use the same owner queue. A failed command + * cannot strand the queue or allow a later command to overtake an active one. */ +export function serializeRadioCommands( + handle: (command: RadioSessionCommand, signal?: AbortSignal) => Promise, + options: {responsive?: boolean; limit?: number} = {} +): (command: RadioSessionCommand, permitted?: () => boolean | Promise) => Promise { + let pending = Promise.resolve(); + let tuning = Promise.resolve(); + let admitted = 0; + const tunes = new Set(); + return (input, permitted) => { + let command: RadioSessionCommand; + try { command = parseRadioCommand(input); } catch (error) { return Promise.reject(error); } + const priority = command.type === 'stop' || command.type === 'alarm-preempt' || command.type === 'tui-cancel' + || command.type === 'desktop-transport' && command.action === 'stop'; + const checkPermission = async () => { if (permitted && !await permitted()) throw new Error('Desktop media controls are suspended.'); }; + if (options.responsive && command.type === 'status') return Promise.resolve().then(async () => { await checkPermission(); return handle(command); }); + if (options.responsive && priority) { + const interrupted = [...tunes]; + const priorPending = pending; + const priorTuning = tuning; + const work = checkPermission().then(() => { + for (const controller of interrupted) controller.abort(new DOMException('Tuning canceled by Stop or alarm.', 'AbortError')); + return handle(command); + }); + pending = work.then(() => undefined, () => priorPending); + tuning = work.then(() => undefined, () => priorTuning); + return work; + } + if (admitted >= (options.limit ?? 128)) return Promise.reject(new Error('RadioCLI control queue is busy. Status and Stop remain available.')); + admitted++; + const replacesTune = ['play', 'airplay-select', 'airplay-local'].includes(command.type); + const transport = replacesTune || ['pause', 'resume', 'next', 'previous', 'desktop-transport'].includes(command.type); + const long = options.responsive && (['play', 'resume', 'next', 'previous', 'airplay-select', 'airplay-local'].includes(command.type) || command.type === 'desktop-transport' && ['play', 'next', 'previous'].includes(command.action)); + const replaced = options.responsive && replacesTune ? [...tunes] : []; + const priorTuning = tuning; + let executing = false; + const controller = new AbortController(); + if (options.responsive && transport) tunes.add(controller); + const fence = options.responsive && transport && !replacesTune ? Promise.all([pending, tuning]).then(() => undefined) : pending; + const start = abortableControl(fence, controller.signal).then(async () => { + await checkPermission(); controller.signal.throwIfAborted(); + for (const earlier of replaced) earlier.abort(new DOMException('Tuning canceled by a replacement.', 'AbortError')); + executing = true; + const result = handle(command, controller.signal); + return {result}; + }); + const result = abortableControl(start.then(({result}) => result), controller.signal).finally(() => { admitted--; tunes.delete(controller); }); + // Tune completion is separate from command admission. A later tune replaces + // an active one, while Stop and status bypass both queues entirely. + pending = (long ? start : result).then(() => undefined, () => undefined); + if (long) tuning = result.then(() => undefined, () => executing ? undefined : priorTuning); + // Cancellation is an expected control outcome. Keep the rejection available + // to the caller without leaking it while another transport action is awaited. + void result.catch(() => undefined); + return result; + }; +} + +function abortableControl(work: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const abort = () => { signal.removeEventListener('abort', abort); reject(signal.reason); }; + signal.addEventListener('abort', abort, {once: true}); + void work.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); }); + }); +} +type Discovery = {version: 1; protocol?: 2; host: LoopbackHost; port: number; token: string; pid: number; createdAt: string}; export type RadioSessionClient = { - call(command: RadioSessionCommand): Promise; + call(command: RadioSessionCommand, requestId?: string): Promise; status(): Promise; }; +type SessionClientOptions = {source?: 'agent' | 'cli'; signal?: AbortSignal}; + +/** Use queue: 'owner' only when handle submits directly to the owner's shared + * serialized dispatcher. Other callers retain the session's own FIFO. */ export async function startRadioSession( - handle: (command: RadioSessionCommand) => Promise, - filePath = radioSessionPath() + handle: (command: RadioSessionCommand, source?: 'agent' | 'cli') => Promise, + filePath = radioSessionPath(), + options: {queue?: 'session' | 'owner'} = {} ): Promise<{close(): Promise}> { const ownerPath = `${filePath}.owner`; acquireOwner(ownerPath); const token = randomBytes(32).toString('hex'); let serial = Promise.resolve(); + const requests = new Map; settledAt?: number}>(); const server = createServer((req, res) => { res.setHeader('content-type', 'application/json'); if (req.headers.authorization !== `Bearer ${token}`) { @@ -61,11 +184,34 @@ export async function startRadioSession( res.end('{}'); return; } - const work = serial.then(async () => { + const accept = async () => { const body = await readBody(req); - return handle(JSON.parse(body) as RadioSessionCommand); - }); - serial = work.then(() => undefined, () => undefined); + const {command, requestId} = parseRadioRequest(JSON.parse(body)); + const execute = () => handle(command, req.headers['x-radiocli-client'] === 'cli' ? 'cli' : 'agent'); + if (!requestId || command.type === 'status') return execute(); + const fingerprint = createHash('sha256').update(JSON.stringify(command)).digest('hex'); + const existing = requests.get(requestId); + if (existing) { + if (existing.fingerprint !== fingerprint) throw new Error('This request ID was already used for a different command.'); + return existing.result; + } + for (const [id, entry] of requests) if (entry.settledAt && (Date.now() - entry.settledAt > 300_000 || requests.size >= 256)) requests.delete(id); + if (requests.size >= 256) { + // Stop/preemption are intrinsically idempotent and must remain available + // even when ordinary requests occupy the entire bounded outcome cache. + if (command.type === 'stop' || command.type === 'alarm-preempt' || command.type === 'desktop-transport' && command.action === 'stop') return execute(); + throw new Error('RadioCLI has too many active control requests.'); + } + const entry: {fingerprint: string; result: Promise; settledAt?: number} = {fingerprint, result: Promise.resolve().then(execute)}; + requests.set(requestId, entry); + void entry.result.then(() => { entry.settledAt = Date.now(); }, () => { entry.settledAt = Date.now(); }); + return entry.result; + }; + // An owner shared with desktop controls already serializes accepted work. + // Submit each authenticated, complete request directly to that queue so a + // later desktop action cannot overtake an earlier HTTP request here. + const work = options.queue === 'owner' ? accept() : serial.then(accept); + if (options.queue !== 'owner') serial = work.then(() => undefined, () => undefined); void work.then(result => res.end(JSON.stringify(result))).catch(error => { res.statusCode = 400; res.end(JSON.stringify({error: error instanceof Error ? error.message : 'Radio control failed.'})); @@ -75,6 +221,7 @@ export async function startRadioSession( const address = await listenLoopback(server); const discovery: Discovery = { version: 1, + protocol: 2, host: address.host, port: address.port, token, @@ -96,7 +243,8 @@ export async function startRadioSession( } } -export async function connectRadioSession(filePath = radioSessionPath()): Promise { +export async function connectRadioSession(filePath = radioSessionPath(), options: SessionClientOptions = {}): Promise { + options.signal?.throwIfAborted(); if (!existsSync(filePath)) return null; let discovery: Discovery; try { @@ -108,38 +256,51 @@ export async function connectRadioSession(filePath = radioSessionPath()): Promis rmSync(filePath, {force: true}); return null; } - const call = async (command: RadioSessionCommand): Promise => { - const payload = JSON.stringify(command); + const call = async (command: RadioSessionCommand, requestId = randomUUID()): Promise => { + const validated = parseRadioCommand(command); + const payload = JSON.stringify(discovery.protocol === 2 ? {version: 2, requestId, command: validated} : validated); return new Promise((resolve, reject) => { const req = request({ host: discovery.host, // Local control tokens must never be forwarded by an environment proxy. agent: false, + signal: options.signal, port: discovery.port, path: '/command', method: 'POST', headers: { authorization: `Bearer ${discovery.token}`, + 'x-radiocli-client': options.source ?? 'agent', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } }, res => { let text = ''; - res.on('data', value => { text += String(value); }); + res.setEncoding('utf8'); + res.on('data', value => { text += String(value); if (Buffer.byteLength(text) > 8 * 1024 * 1024) res.destroy(new Error('RadioCLI response too large.')); }); + res.once('error', reject); res.on('end', () => { - if (res.statusCode && res.statusCode < 300) resolve(JSON.parse(text) as RadioSessionResult); - else reject(new Error(parseError(text))); + try { + if (res.statusCode && res.statusCode < 300) { + const parsed = JSON.parse(text) as RadioSessionResult; + if (typeof parsed.ok !== 'boolean' || typeof parsed.message !== 'string' || !parsed.status?.playback || !Array.isArray(parsed.status.queue)) throw new Error('Invalid RadioCLI control response.'); + resolve(parsed); + } else reject(new Error(parseError(text))); + } catch (error) { reject(error); } }); }); req.once('error', reject); - req.setTimeout(15_000, () => req.destroy(new Error('RadioCLI control request timed out.'))); + const timeout = command.type === 'status' ? 5000 : ['stop', 'alarm-preempt'].includes(command.type) ? 15_000 : 120_000; + req.setTimeout(timeout, () => req.destroy(new Error(`RadioCLI control request timed out. Request ID: ${requestId}. Retry with the same ID to retrieve its outcome.`))); req.end(payload); }); }; try { await call({type: 'status'}); + options.signal?.throwIfAborted(); return {call, status: async () => (await call({type: 'status'})).status}; } catch { + options.signal?.throwIfAborted(); // A slow command does not prove that a playback host is dead. Retain both // ownership records while its PID is alive so a second host cannot start. if (!processAlive(discovery.pid)) { @@ -150,21 +311,25 @@ export async function connectRadioSession(filePath = radioSessionPath()): Promis } } -export async function ensureRadioSession(start: () => Promise, timeoutMs = 15_000): Promise { - const existing = await connectRadioSession(); +export async function ensureRadioSession(start: () => Promise, timeoutMs = 15_000, options: SessionClientOptions = {}): Promise { + options.signal?.throwIfAborted(); + const existing = await connectRadioSession(undefined, options); + options.signal?.throwIfAborted(); if (existing) return existing; const lockPath = `${radioSessionPath()}.launch`; let ownsLaunch = false; try { acquireOwner(lockPath); ownsLaunch = true; - const appeared = await connectRadioSession(); + const appeared = await connectRadioSession(undefined, options); + options.signal?.throwIfAborted(); if (appeared) return appeared; await start(); - return await waitForSession(timeoutMs); + options.signal?.throwIfAborted(); + return await waitForSession(timeoutMs, options); } catch (error) { if (ownsLaunch || !(error instanceof Error) || !error.message.includes('already active')) throw error; - return waitForSession(timeoutMs); + return waitForSession(timeoutMs, options); } finally { if (ownsLaunch) removeOwner(lockPath, process.pid); } @@ -174,16 +339,40 @@ function radioSessionPath(): string { return join(runtimeDirectory(), 'agent-session.json'); } -async function waitForSession(timeoutMs: number): Promise { +/** Optional terminal recovery may fall back to inline playback only when no + * live or unknown owner could already be using this library's audio session. */ +export function radioSessionMayBeActive(): boolean { + for (const path of [`${radioSessionPath()}.owner`, `${radioSessionPath()}.launch`, radioSessionPath()]) { + try { + const text = readFileSync(path, 'utf8'); + const pid = path.endsWith('.json') ? (JSON.parse(text) as Discovery).pid : Number(text.trim()); + if (!Number.isSafeInteger(pid) || pid <= 0 || processAlive(pid)) return true; + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return true; } + } + return false; +} + +async function waitForSession(timeoutMs: number, options: SessionClientOptions): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const client = await connectRadioSession(); + options.signal?.throwIfAborted(); + const client = await connectRadioSession(undefined, options); + options.signal?.throwIfAborted(); if (client) return client; - await new Promise(resolve => setTimeout(resolve, 100)); + await controlPollDelay(options.signal); } throw new Error('RadioCLI was launched but its control session did not become ready.'); } +function controlPollDelay(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const abort = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); reject(signal?.reason); }; + const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, 100); + signal?.addEventListener('abort', abort, {once: true}); + }); +} + function runtimeDirectory(): string { return platformPaths().runtime; } @@ -235,9 +424,10 @@ function processAlive(pid: number): boolean { function readBody(req: import('node:http').IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; + req.setEncoding('utf8'); req.on('data', value => { body += String(value); - if (body.length > 1_000_000) req.destroy(new Error('Request too large.')); + if (Buffer.byteLength(body) > 4 * 1024 * 1024) req.destroy(new Error('Request too large.')); }); req.on('end', () => resolve(body)); req.on('error', reject); diff --git a/src/agent/tui-recovery.native.test.ts b/src/agent/tui-recovery.native.test.ts new file mode 100644 index 0000000..7a47f43 --- /dev/null +++ b/src/agent/tui-recovery.native.test.ts @@ -0,0 +1,188 @@ +import {spawn, type ChildProcess} from 'node:child_process'; +import {existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {detectPlaybackBackends} from '../player/backend-install.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import {connectRadioSession} from './session.js'; +import {TuiPlayer, TuiRadioEngine, TuiSession} from './tui-session.js'; +import {connectActiveAlarms, startActiveAlarmSession} from '../alarms/active-session.js'; + +const hasMpv = detectPlaybackBackends().includes('mpv'); +if (!hasMpv && process.env.RADIOCLI_REQUIRE_TUI_RECOVERY === '1') throw new Error('The native TUI recovery check requires mpv.'); +let root: string | undefined; +let view: ChildProcess | undefined; +let session: TuiSession | undefined; + +afterEach(async () => { + if (view && view.exitCode === null && view.signalCode === null) { view.kill('SIGKILL'); await new Promise(resolve => view!.once('close', resolve)); } + if (session) await session.close().catch(() => undefined); + if (root) { + const path = join(root, 'runtime', 'agent-session.json'); + const client = await connectRadioSession(path, {source: 'cli'}); + if (client) await client.call({type: 'stop'}).catch(() => undefined); + if (existsSync(path)) { + const {pid} = JSON.parse(readFileSync(path, 'utf8')) as {pid: number}; + try { process.kill(pid, 'SIGTERM'); } catch { /* Already stopped. */ } + } + await vi.waitFor(() => expect(existsSync(path)).toBe(false), {timeout: 10_000}); + rmSync(root, {recursive: true, force: true}); + } + vi.unstubAllEnvs(); + root = undefined; view = undefined; session = undefined; +}); + +describe.skipIf(!hasMpv)('native TUI crash recovery (mpv, null audio)', () => { + it.each(['playing', 'paused'] as const)('survives a killed %s view and reconnects without restarting the player', async state => { + root = mkdtempSync(join(tmpdir(), 'radiocli tui recovery 音楽-')); + vi.stubEnv('RADIOCLI_HOME', root); + vi.stubEnv('RADIOCLI_MPV_AUDIO_OUTPUT', 'null'); + vi.stubEnv('RADIOCLI_DESKTOP_MEDIA', 'off'); + vi.stubEnv('RADIOCLI_OFFLINE', '1'); + const wav = join(root, 'station.wav'); + const rate = 8000; + const audio = Buffer.alloc(44 + rate * 2 * 180); + audio.write('RIFF'); audio.writeUInt32LE(audio.length - 8, 4); audio.write('WAVEfmt ', 8); + audio.writeUInt32LE(16, 16); audio.writeUInt16LE(1, 20); audio.writeUInt16LE(1, 22); + audio.writeUInt32LE(rate, 24); audio.writeUInt32LE(rate * 2, 28); audio.writeUInt16LE(2, 32); audio.writeUInt16LE(16, 34); + audio.write('data', 36); audio.writeUInt32LE(audio.length - 44, 40); writeFileSync(wav, audio); + const first = {id: 'recovery-first', provider: 'playlist' as const, name: 'Recovery First', tags: [], streamUrl: wav}; + const second = {...first, id: 'recovery-second', name: 'Recovery Second'}; + const store = new JsonLibraryStore(); + store.updateSettings({preferredBackend: 'mpv', volume: 20, automaticUpdateChecks: false, shareDirectoryVotes: false, desktopMediaControls: 'off'}); + const built = process.env.RADIOCLI_TUI_RECOVERY_BUILT === '1'; + const module = new URL(built ? '../../dist/agent/tui-session.js' : './tui-session.ts', import.meta.url).href; + const script = `import {TuiSession} from ${JSON.stringify(module)}; + const session = await TuiSession.connect(); + await session.call({type:'play',station:${JSON.stringify(first)},queue:${JSON.stringify([first, second])}}); + ${state === 'paused' ? "await session.call({type:'pause'});" : ''} + console.log('view-ready'); setInterval(()=>{},1000);`; + view = spawn(process.execPath, [...(built ? [] : ['--import', import.meta.resolve('tsx')]), '--input-type=module', '-e', script], {env: {...process.env}, stdio: ['ignore', 'pipe', 'pipe']}); + let output = ''; let errors = ''; + view.stdout!.on('data', chunk => { output += String(chunk); }); + view.stderr!.on('data', chunk => { errors += String(chunk); }); + await vi.waitFor(() => { + if (view?.exitCode !== null) throw new Error(`View exited: ${errors}`); + expect(output).toContain('view-ready'); + }, {timeout: 20_000}); + const path = join(root, 'runtime', 'agent-session.json'); + const client = (await connectRadioSession(path, {source: 'cli'}))!; + const before = await client.status(); + const {elapsedSeconds: beforeElapsed, ...beforePlayback} = before.playback; + const owner = readFileSync(path, 'utf8'); + expect(before).toMatchObject({owner: 'tui', playback: {state, ready: true}, station: first, queue: [first, second]}); + const killed = new Promise(resolve => view!.once('close', resolve)); + view.kill('SIGKILL'); await killed; + const detached = await client.status(); + expect(detached).toMatchObject({owner: 'headless', playback: beforePlayback}); + expect(detached.playback.elapsedSeconds ?? 0).toBeGreaterThanOrEqual(beforeElapsed ?? 0); + + session = await TuiSession.connect(); + const player = new TuiPlayer(session, () => store.settings()); + const engine = new TuiRadioEngine(session); + expect(engine.snapshot()).toMatchObject({station: first, queue: [first, second], playback: beforePlayback}); + expect(readFileSync(path, 'utf8')).toBe(owner); + expect((await player.setVolume(35)).ok).toBe(true); + expect(player.getState().volume).toBe(35); + expect((await player.setMuted(true)).ok).toBe(true); + expect(player.getState().muted).toBe(true); + expect(player.getState().startedAt).toBe(before.playback.startedAt); + if (state === 'playing') expect((await player.pause()).ok).toBe(true); + expect((await player.resume()).ok).toBe(true); + expect((await session.call({type: 'next'})).status.station).toEqual(second); + expect((await session.call({type: 'alarm-preempt'})).status.playback.ready).toBe(false); + expect((await engine.tune(first)).id).toBe(first.id); + expect(new JsonLibraryStore().settings().agentControl?.enabled).toBe(false); + // Restart only MCP, with the playback owner and the recovered view alive. + // Abruptly losing the MCP client must not stop or orphan their player. + store.updateSettings({agentControl: {...store.settings().agentControl!, enabled: true}}); + const playing = await client.status(); + const {elapsedSeconds: initialElapsed, ...continuousPlayback} = playing.playback; + let lastElapsed = initialElapsed ?? 0; + for (let restart = 0; restart < 2; restart++) { + expect(await mcpStatus(built)).toMatchObject({connected: true, session: {station: first, playback: continuousPlayback}}); + const playback = (await client.status()).playback; + expect(playback).toMatchObject(continuousPlayback); + expect(playback.elapsedSeconds ?? 0).toBeGreaterThanOrEqual(lastElapsed); + lastElapsed = playback.elapsedSeconds ?? 0; + expect(readFileSync(path, 'utf8')).toBe(owner); + } + + const alarmStatus = {alarmId: 'recovery-alarm', scheduledAt: new Date().toISOString(), stationName: second.name, + station: second, startedAt: new Date().toISOString(), state: 'playing' as const}; + const dismiss = vi.fn(); + const keepPlaying = vi.fn(); + const alarm = await startActiveAlarmSession(alarmStatus, {onDismiss: dismiss, onSnooze: vi.fn(), onKeepPlaying: keepPlaying}); + try { + expect((await client.call({type: 'alarm-preempt'})).status.playback.ready).toBe(false); + expect(await mcpStatus(built)).toMatchObject({alarmActive: true, session: {playback: {state: 'stopped'}}}); + await engine.close(); + const hostPid = (JSON.parse(owner) as {pid: number}).pid; + await vi.waitFor(() => { + expect(existsSync(path)).toBe(false); + expect(existsSync(`${path}.owner`)).toBe(false); + expect(processAlive(hostPid)).toBe(false); + }, {timeout: 10_000}); + const active = await connectActiveAlarms(); + expect(active).toHaveLength(1); + expect(await active[0]!.status()).toEqual(alarmStatus); + await active[0]!.keepPlaying(); + expect(keepPlaying).toHaveBeenCalledOnce(); + expect(dismiss).not.toHaveBeenCalled(); + + // The next launch starts a new owner without a manual process kill. + session = await TuiSession.connect(); + expect((JSON.parse(readFileSync(path, 'utf8')) as {pid: number}).pid).not.toBe(hostPid); + expect(session.status.playback.ready).toBe(false); + expect(await active[0]!.status()).toEqual(alarmStatus); + await session.close(); + await vi.waitFor(() => expect(existsSync(path)).toBe(false), {timeout: 10_000}); + } finally { await alarm.close(); } + }, 40_000); +}); + +function processAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; } +} + +async function mcpStatus(built: boolean): Promise { + const cli = new URL(built ? '../../dist/cli.js' : '../cli.tsx', import.meta.url); + const {fileURLToPath} = await import('node:url'); + const child = spawn(process.execPath, [...(built ? [] : ['--import', import.meta.resolve('tsx')]), fileURLToPath(cli), 'mcp', 'serve'], + {env: {...process.env}, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true}); + let output = ''; let errors = ''; + const replies = new Map}}>(); + child.stderr.on('data', chunk => { errors += String(chunk); }); + child.stdout.on('data', chunk => { + output += String(chunk); + let newline: number; + while ((newline = output.indexOf('\n')) >= 0) { + const line = output.slice(0, newline).trim(); output = output.slice(newline + 1); + if (line) { const reply = JSON.parse(line) as {id: number; result: {isError?: boolean; content?: Array<{text: string}>}}; replies.set(reply.id, reply); } + } + }); + const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`); + const wait = async (id: number) => { + await vi.waitFor(() => { + if (child.exitCode !== null) throw new Error(`MCP exited: ${errors}`); + expect(replies.has(id)).toBe(true); + }, {timeout: 10_000}); + return replies.get(id)!.result; + }; + try { + send({jsonrpc: '2.0', id: 1, method: 'initialize', params: {protocolVersion: '2025-11-25', capabilities: {}, clientInfo: {name: 'tui-recovery', version: '1.0.0'}}}); + await wait(1); + send({jsonrpc: '2.0', method: 'notifications/initialized'}); + send({jsonrpc: '2.0', id: 2, method: 'tools/call', params: {name: 'radio_status', arguments: {}}}); + const result = await wait(2); + expect(result?.isError).not.toBe(true); + return JSON.parse(result!.content![0]!.text) as unknown; + } finally { + if (child.exitCode === null && child.signalCode === null) { + const closed = new Promise(resolve => child.once('close', resolve)); + child.kill('SIGKILL'); await closed; + } + } +} diff --git a/src/agent/tui-session.test.ts b/src/agent/tui-session.test.ts new file mode 100644 index 0000000..632c299 --- /dev/null +++ b/src/agent/tui-session.test.ts @@ -0,0 +1,38 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as session from './session.js'; +import * as storage from '../platform/storage.js'; +import * as loopback from '../platform/loopback.js'; +import {openTuiSession, TuiSession} from './tui-session.js'; + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); + +describe('optional terminal recovery', () => { + it('explains how to restart an older host without stopping its playback automatically', async () => { + const call = vi.fn().mockRejectedValue(new Error('Invalid radio command: type: Invalid discriminator value.')); + vi.spyOn(session, 'ensureRadioSession').mockResolvedValue({call, status: vi.fn()}); + await expect(TuiSession.connect()).rejects.toThrow('running player predates TUI recovery'); + expect(call).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({type: 'tui-attach'})); + }); + + it('keeps inline playback available when the private runtime cannot be written', async () => { + vi.spyOn(session, 'radioSessionMayBeActive').mockReturnValue(false); + vi.spyOn(storage, 'storageReadiness').mockReturnValue({status: 'unavailable', message: 'read-only'}); + const launch = vi.spyOn(session, 'ensureRadioSession'); + expect(await openTuiSession()).toEqual({warning: expect.stringContaining('runtime directory is not writable')}); + expect(launch).not.toHaveBeenCalled(); + }); + + it('keeps inline playback available when loopback is blocked', async () => { + vi.spyOn(session, 'radioSessionMayBeActive').mockReturnValue(false); + vi.spyOn(storage, 'storageReadiness').mockReturnValue({status: 'available', message: 'ok'}); + vi.spyOn(loopback, 'listenLoopback').mockRejectedValue(Object.assign(new Error('denied'), {code: 'EPERM'})); + expect(await openTuiSession()).toEqual({warning: expect.stringContaining('blocks local loopback')}); + }); + + it('does not start inline audio alongside a live or unknown playback owner', async () => { + vi.stubEnv('RADIOCLI_TUI_PLAYBACK', 'inline'); + vi.spyOn(session, 'radioSessionMayBeActive').mockReturnValue(true); + vi.spyOn(session, 'ensureRadioSession').mockRejectedValue(new Error('owner unavailable')); + await expect(openTuiSession()).rejects.toThrow('owner unavailable'); + }); +}); diff --git a/src/agent/tui-session.ts b/src/agent/tui-session.ts new file mode 100644 index 0000000..9fa5918 --- /dev/null +++ b/src/agent/tui-session.ts @@ -0,0 +1,185 @@ +import {randomUUID} from 'node:crypto'; +import {fileURLToPath} from 'node:url'; +import {createServer} from 'node:net'; +import {join} from 'node:path'; +import type {AirPlayDevice, AppSettings, IcyNowPlaying, PlaybackState, Station} from '../types.js'; +import {PlayerController, type PlaybackControlResult} from '../player/player-controller.js'; +import type {RadioEngine, RadioEngineState} from '../player/radio-engine.js'; +import {launchHeadlessHost} from './launcher.js'; +import {platformPaths} from '../platform/paths.js'; +import {storageReadiness} from '../platform/storage.js'; +import {listenLoopback} from '../platform/loopback.js'; +import {ensureRadioSession, radioSessionMayBeActive, type RadioSessionClient, type RadioSessionCommand, type RadioSessionResult, type RadioSessionStatus} from './session.js'; + +export async function openTuiSession(): Promise<{session?: TuiSession; warning?: string}> { + if (!radioSessionMayBeActive()) { + if (process.env.RADIOCLI_TUI_PLAYBACK === 'inline') return {warning: 'Playback runs in this terminal. Crash recovery is disabled by RADIOCLI_TUI_PLAYBACK=inline.'}; + if (storageReadiness(join(platformPaths().runtime, 'agent-session.json')).status === 'unavailable') { + return {warning: 'Playback recovery is unavailable: the runtime directory is not writable. Set RADIOCLI_HOME to a private writable directory.'}; + } + const probe = createServer(); + try { await listenLoopback(probe); } + catch { return {warning: 'Playback recovery is unavailable: this environment blocks local loopback control. Playback runs in this terminal.'}; } + finally { await new Promise(resolve => probe.close(() => resolve())); } + } + return {session: await TuiSession.connect()}; +} + +/** A terminal is a view of the existing playback owner. Its death must never + * destroy the owner's IPC connection, queue, timers, or listening history. */ +export class TuiSession { + private readonly listeners = new Set<() => void>(); + private timer: NodeJS.Timeout | undefined; + private revision = 0; + private closing: Promise | undefined; + private closed = false; + + private constructor(private readonly client: RadioSessionClient, readonly clientId: string, public status: RadioSessionStatus) {} + + static async connect(): Promise { + const cli = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../cli.tsx' : '../cli.js', import.meta.url)); + const client = await ensureRadioSession(() => launchHeadlessHost(process.execPath, cli), 15_000, {source: 'cli'}); + const clientId = randomUUID(); + let result: RadioSessionResult; + try { result = await client.call({type: 'tui-attach', pid: process.pid, clientId}); } + catch (error) { + if (error instanceof Error && /Invalid radio command|Unsupported radio command/i.test(error.message)) { + throw new Error('The running player predates TUI recovery. Run radiocli stop, then reopen radiocli to use the updated player.'); + } + throw error; + } + if (!result.ok) throw new Error(result.message); + if (!result.status.tui) throw new Error('The playback host does not support terminal recovery. Stop the older session and reopen RadioCLI.'); + const session = new TuiSession(client, clientId, result.status); + session.poll(); + return session; + } + + subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } + + async call(command: RadioSessionCommand): Promise { + if (this.closed) throw new Error('RadioCLI terminal session is closed.'); + const result = await this.client.call(command); + this.revision++; + this.status = result.status; + this.emit(); + return result; + } + + report(error: unknown): void { + this.status = {...this.status, persistenceWarning: error instanceof Error ? error.message : String(error)}; + this.emit(); + } + + disconnect(): void { + clearTimeout(this.timer); + this.closed = true; + this.listeners.clear(); + } + + close(): Promise { + return this.closing ??= (async () => { + clearTimeout(this.timer); + try { + const result = await this.call({type: 'tui-detach', clientId: this.clientId}); + if (!result.ok) throw new Error(result.message); + } finally { this.disconnect(); } + })(); + } + + private poll(): void { + this.timer = setTimeout(() => { + const revision = this.revision; + void this.client.status().then(status => { + if (!this.closed && revision === this.revision && JSON.stringify(status) !== JSON.stringify(this.status)) { this.status = status; this.emit(); } + }).catch(() => { + if (!this.closed && revision === this.revision) { + this.status = {...this.status, playback: {...this.status.playback, state: 'error', ready: false, + message: 'Playback host disconnected. Reopen RadioCLI to reconnect.'}}; + this.emit(); + } + }).finally(() => { if (!this.closed && !this.closing) this.poll(); }); + }, 250); + this.timer.unref(); + } + + private emit(): void { for (const listener of this.listeners) listener(); } +} + +/** Keep synchronous presentation queries local, while every audio mutation + * reaches the same owner used by CLI, agent, alarm, and desktop controls. */ +export class TuiPlayer extends PlayerController { + constructor(private readonly session: TuiSession, settings: () => AppSettings) { super(settings); } + override getState(): PlaybackState { return {...this.session.status.playback}; } + override onChange(listener: (state: PlaybackState) => void): () => void { + listener(this.getState()); + return this.session.subscribe(() => listener(this.getState())); + } + override onMetadata(listener: (metadata: IcyNowPlaying) => void): () => void { + let previous: string | undefined; + return this.session.subscribe(() => { + const metadata = this.session.status.nowPlaying; + const key = JSON.stringify(metadata); + if (metadata && key !== previous) { previous = key; listener(metadata); } + }); + } + override diagnostics() { return {...this.session.status.tui!.diagnostics}; } + override desktopMediaCompatible(): boolean { return this.session.status.tui!.desktopMediaCompatible; } + override detectedBackends(): string[] { return [...this.session.status.tui!.diagnostics.availableBackends]; } + override refreshDetectedBackends(): string[] { + void this.session.call({type: 'tui-refresh'}).catch(error => this.session.report(error)); + return this.detectedBackends(); + } + override detectedAirPlayDevices(): AirPlayDevice[] { return [...this.session.status.tui!.devices]; } + override airPlaySelectionRequired(): boolean { return this.session.status.tui!.selectionRequired; } + override async refreshAirPlayDevices(): Promise { + const result = await this.session.call({type: 'airplay-list'}); + if (!result.ok) throw new Error(result.message); + return result.data ?? []; + } + override async selectAirPlayDevice(deviceId: string | null): Promise { return (await this.session.call({type: 'tui-output', deviceId})).ok; } + override async submitAirPlayPasscode(code: string): Promise { return this.control({type: 'airplay-passcode', code}); } + override async pause(): Promise { return this.control({type: 'pause'}); } + override async resume(): Promise { return this.control({type: 'resume'}); } + override async togglePause(): Promise { return this.control({type: 'tui-toggle-pause'}); } + override async setVolume(volume: number, options: {allowStopped?: boolean} = {}): Promise { + return this.control({type: 'set-volume', volume: Math.max(0, Math.min(100, Math.round(volume))), allowStopped: options.allowStopped}); + } + override async adjustVolume(delta: number): Promise { return this.control({type: 'tui-adjust-volume', delta}); } + override async setMuted(muted: boolean, options: {allowStopped?: boolean} = {}): Promise { return this.control({type: 'set-muted', muted, allowStopped: options.allowStopped}); } + override async toggleMute(): Promise { return this.control({type: 'tui-toggle-muted'}); } + override async stop(): Promise { await this.session.call({type: 'stop', retainSelection: true}); } + override async play(): Promise { throw new Error('Terminal playback must use the shared radio engine.'); } + private async control(command: RadioSessionCommand): Promise { + const result = await this.session.call(command); + return {ok: result.ok, message: result.message}; + } +} + +export class TuiRadioEngine implements Pick { + constructor(private readonly session: TuiSession) {} + snapshot(): RadioEngineState { + const status = this.session.status; + return {station: status.station, tuningStation: status.tui?.tuningStation ?? null, queue: [...status.queue], + playback: {...status.playback}, nowPlaying: status.nowPlaying ?? null, warning: status.persistenceWarning, message: status.tui?.message}; + } + onChange(listener: (state: RadioEngineState) => void): () => void { listener(this.snapshot()); return this.session.subscribe(() => listener(this.snapshot())); } + clearWarning(): void { this.session.status.persistenceWarning = undefined; } + cancel(): void { void this.session.call({type: 'tui-cancel'}).catch(error => this.session.report(error)); } + async tune(station: Station, options: {queue?: Station[]; startPaused?: boolean; signal?: AbortSignal} = {}): Promise { + options.signal?.throwIfAborted(); + const abort = () => this.cancel(); + options.signal?.addEventListener('abort', abort, {once: true}); + try { + const result = await this.session.call({type: 'play', station, queue: options.queue, startPaused: options.startPaused}); + options.signal?.throwIfAborted(); + if (!result.ok || !result.status.station) throw new Error(result.message); + return result.status.station; + } finally { options.signal?.removeEventListener('abort', abort); } + } + async stop(options: {retainSelection?: boolean} = {}): Promise { await this.session.call({type: 'stop', ...options}); } + async pause(): Promise { const result = await this.session.call({type: 'pause'}); return {ok: result.ok, message: result.message}; } + async resume(): Promise { const result = await this.session.call({type: 'resume'}); return {ok: result.ok, message: result.message}; } + select(station: Station, queue: Station[]): void { void this.session.call({type: 'tui-select', station, queue}).catch(error => this.session.report(error)); } + close(): Promise { return this.session.close(); } +} diff --git a/src/alarms/cli.ts b/src/alarms/cli.ts index 72de535..3d89a66 100644 --- a/src/alarms/cli.ts +++ b/src/alarms/cli.ts @@ -1,6 +1,8 @@ import {dirname,join} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {Alarm,AlarmCreateInput,AlarmSchedule,AppSettings,IsoWeekday,Station} from '../types.js'; +import {LibraryRepository} from '../storage/repository.js'; +import {matchesStationId} from '../storage/station-identity.js'; import {JsonLibraryStore,stationKey} from '../storage/store.js'; import {ProviderManager} from '../providers/provider-manager.js'; import {PlayerController} from '../player/player-controller.js'; @@ -27,7 +29,7 @@ export async function runAlarmCommand(args:string[],dependencies:AlarmCommandDep if(sub==='add'){const parsed=parseOptions(rest);if(parsed.positionals.length)throw new Error('Alarm add does not accept positional arguments.');const station=resolveStation(store,required(parsed,'station'));const input=createInput(parsed,station);if(parsed.values.has('fallback'))input.playback.fallbackStation=resolveStation(store,String(parsed.values.get('fallback')));const alarm=store.addAlarm(input);await syncSaved(scheduler,alarm);if(alarm.reliability.keepAwakeUntilAlarm)await reconcileAlarmGuard(alarm);console.log(`alarm=${alarm.id}`);return;} if(sub==='edit'){const parsed=parseOptions(rest);if(parsed.positionals.length!==1)throw new Error('Usage: radiocli alarm edit [options]');const id=parsed.positionals[0];const alarm=requireAlarm(store,id);const updated=store.updateAlarm(alarm.id,updatesFromOptions(parsed,store,alarm));await syncSaved(scheduler,updated);if(updated.reliability.keepAwakeUntilAlarm||parsed.values.has('no-guard'))await reconcileAlarmGuard(updated);console.log(`updated=${updated.id}`);return;} if(sub==='enable'||sub==='disable'){const alarm=store.toggleAlarm(requiredPos(rest,0),sub==='enable');await syncSaved(scheduler,alarm);await reconcileAlarmGuard(alarm);console.log(`${sub}d=${alarm.id}`);return;} - if(sub==='remove'){const id=requiredPos(rest,0);requireAlarm(store,id);await scheduler.remove(id);if(!store.removeAlarm(id))throw new Error(`Alarm not found: ${id}`);console.log(`removed=${id}`);return;} + if(sub==='remove'){const id=requiredPos(rest,0);requireAlarm(store,id);store.toggleAlarm(id,false);await scheduler.remove(id);if(!store.removeAlarm(id))throw new Error(`Alarm not found: ${id}`);console.log(`removed=${id}`);return;} if(sub==='sync'){const results=await scheduler.syncAll(store.listAlarms());for(const item of results)console.log(`${item.id}=${item.error?`degraded ${item.error}`:item.occurrence?.toISOString()??'disabled'}`);if(results.some(item=>item.error))process.exitCode=1;return;} if(sub==='doctor'){const guards=await new AlarmGuardService(new AlarmPowerGuardStore()).status();const active=await statusesOf(await connectActiveAlarms());const powerCapability=createPowerInhibitor().status();const schedulerStatus=await scheduler.runtimeStatus(store.listAlarms());const report={scheduler:schedulerStatus,power:{...powerCapability,active:guards.active||runnerPowerActive(active,schedulerStatus.entries)},guards,active};if(rest.includes('--json'))console.log(JSON.stringify(report,null,2));else{console.log(`scheduler=${report.scheduler.capabilities.supported?'ready':'unsupported'} ${report.scheduler.capabilities.message}`);console.log(`scheduler_degraded=${report.scheduler.entries.filter(item=>!item.healthy).length}`);console.log(`power_guard=${report.power.supported?'ready':'unsupported'} ${report.power.message}`);console.log(`guards=${guards.guards.length}`);console.log(`active=${active.length}`);}return;} if(sub==='status'){const active=await selectedActiveStatuses(rest);const guards=await new AlarmGuardService(new AlarmPowerGuardStore()).status();const powerCapability=createPowerInhibitor().status();const schedulerStatus=await scheduler.runtimeStatus(store.listAlarms());console.log(JSON.stringify({active,scheduler:schedulerStatus,guards,power:{...powerCapability,active:guards.active||runnerPowerActive(active,schedulerStatus.entries)}},null,2));return;} @@ -36,13 +38,13 @@ export async function runAlarmCommand(args:string[],dependencies:AlarmCommandDep if(sub==='keep-playing'){const active=await requireActive(rest);await active.keepPlaying();console.log('keep_playing=yes');return;} if(sub==='guard'){const action=rest[0]??'status';const guards=new AlarmGuardService(new AlarmPowerGuardStore());if(action==='start'){const alarm=requireAlarm(store,rest[1]);const result=await guards.start(alarm);console.log(`guard=${result.occurrenceAt}`);}else if(action==='stop')console.log(`stopped=${await guards.stop(rest[1])?'yes':'no'}`);else if(action==='status')console.log(JSON.stringify(await guards.status(),null,2));else throw new Error('Usage: radiocli alarm guard start |stop [id]|status');return;} if(sub==='test'){const alarm=requireAlarm(store,rest[0]);await testAlarm(alarm,store);console.log('test=complete');return;} - if(sub==='internal-run'){const id=requiredPos(rest,0);const at=requiredPos(rest,1);await runAlarm(id,at,createRuntimeDeps(store,scheduler,id));return;} - if(sub==='internal-launchd'){const id=requiredPos(rest,0);const at=requiredPos(rest,1);if(!shouldRunLaunchdOccurrence(at,new Date()))return;await runAlarm(id,at,createRuntimeDeps(store,scheduler,id));return;} + if(sub==='internal-run'){const id=requiredPos(rest,0);const at=requiredPos(rest,1);const repository=new LibraryRepository(store,{worker:!dependencies.store});try{await runAlarm(id,at,createRuntimeDeps(repository,scheduler,id));}finally{await repository.close();}return;} + if(sub==='internal-launchd'){const id=requiredPos(rest,0);const at=requiredPos(rest,1);if(!shouldRunLaunchdOccurrence(at,new Date()))return;const repository=new LibraryRepository(store,{worker:!dependencies.store});try{await runAlarm(id,at,createRuntimeDeps(repository,scheduler,id));}finally{await repository.close();}return;} if(sub==='internal-guard-run'){await runAlarmGuard(requiredPos(rest,0),requiredPos(rest,1),createPowerInhibitor(),new AlarmPowerGuardStore(),()=>new Date(),defaultRunnerUtilities.wait,rest[2],rest[3]);return;} throw new Error(alarmUsage()); } -function createRuntimeDeps(store:JsonLibraryStore,scheduler:SchedulerService,alarmId:string){const providers=new ProviderManager();const alarm=store.getAlarm(alarmId);const detected=detectPlaybackBackends();const settings=alarm?alarmRuntimeSettings(store.snapshot().settings,alarm,detected):{...store.snapshot().settings,preferredBackend:'auto' as const,preferredAirPlayDevice:undefined};const player=new PlayerController(()=>settings);player.refreshDetectedBackends();const cliPath=join(dirname(fileURLToPath(import.meta.url)),'..','cli.js');return{now:()=>new Date(),store,providers,player,scheduler,inhibitor:createPowerInhibitor(),systemVolume:createSystemVolumeController(),preemptInteractivePlayback:async()=>{const client=await connectRadioSession();if(!client)return;const result=await client.call({type:'alarm-preempt'});if(!result.ok)throw new Error(result.message);},acquireLock:acquireOccurrenceLock,createSession:defaultRunnerUtilities.createSession,openControls:()=>openAlarmControls({nodePath:process.execPath,cliPath,hasLiveTui}),wait:defaultRunnerUtilities.wait,subscribeSignals:defaultRunnerUtilities.subscribeSignals,health:scheduler.health};} +function createRuntimeDeps(store:JsonLibraryStore|LibraryRepository,scheduler:SchedulerService,alarmId:string){const providers=new ProviderManager();const alarm=store.getAlarm(alarmId);const detected=detectPlaybackBackends();const settings=alarm?alarmRuntimeSettings(store.snapshot().settings,alarm,detected):{...store.snapshot().settings,preferredBackend:'auto' as const,preferredAirPlayDevice:undefined};const player=new PlayerController(()=>settings);player.refreshDetectedBackends();const cliPath=join(dirname(fileURLToPath(import.meta.url)),'..','cli.js');return{now:()=>new Date(),store,providers,player,scheduler,inhibitor:createPowerInhibitor(),systemVolume:createSystemVolumeController(),preemptInteractivePlayback:async()=>{const client=await connectRadioSession();if(!client)return;const result=await client.call({type:'alarm-preempt'});if(!result.ok)throw new Error(result.message);},acquireLock:acquireOccurrenceLock,createSession:defaultRunnerUtilities.createSession,openControls:()=>openAlarmControls({nodePath:process.execPath,cliPath,hasLiveTui}),wait:defaultRunnerUtilities.wait,subscribeSignals:defaultRunnerUtilities.subscribeSignals,health:scheduler.health};} export function alarmRuntimeSettings(settings:AppSettings,alarm:Alarm,backends:readonly string[]):AppSettings{const mpvFade=alarm.playback.fadeSeconds>0&&backends.includes('mpv');return{...settings,volume:mpvFade?0:alarm.playback.volume,preferredBackend:mpvFade?'mpv':'auto',preferredAirPlayDevice:undefined};} async function testAlarm(alarm:Alarm,store:JsonLibraryStore){ const scheduledAt=new Date(Math.floor(Date.now()/60_000)*60_000);const deadline=Date.now()+10_000; @@ -63,7 +65,7 @@ function updatesFromOptions(options:Options,store:JsonLibraryStore,alarm:Alarm): function scheduleFrom(options:Options):AlarmSchedule{if(options.values.has('once')){const input=String(options.values.get('once'));const instant=new Date(input);if(!/(?:Z|[+-]\d{2}:\d{2})$/.test(input)||!Number.isFinite(instant.getTime())||instant.getUTCSeconds()!==0||instant.getUTCMilliseconds()!==0)throw new Error('--once requires an absolute ISO-8601 minute with an offset or Z and zero seconds.');return{type:'once',at:instant.toISOString()};}const time=canonicalizeAlarmTime(required(options,'time'));const days=parseDays(String(options.values.get('days')??'daily'));const timezone=canonicalizeTimeZone(String(options.values.get('timezone')??Intl.DateTimeFormat().resolvedOptions().timeZone));return{type:'recurring',time,weekdays:days,timezone};} function scheduleEdit(options:Options,current:AlarmSchedule):AlarmSchedule{if(options.values.has('once'))return scheduleFrom(options);if(current.type!=='recurring'&&!options.values.has('time'))throw new Error('Changing days or timezone on a one-time alarm also requires --time.');return{type:'recurring',time:canonicalizeAlarmTime(String(options.values.get('time')??(current.type==='recurring'?current.time:''))),weekdays:options.values.has('days')?parseDays(String(options.values.get('days'))):(current.type==='recurring'?current.weekdays:[1,2,3,4,5,6,7]),timezone:canonicalizeTimeZone(String(options.values.get('timezone')??(current.type==='recurring'?current.timezone:Intl.DateTimeFormat().resolvedOptions().timeZone)))};} function parseDays(value:string):IsoWeekday[]{if(value==='daily')return[1,2,3,4,5,6,7];if(value==='weekdays')return[1,2,3,4,5];if(value==='weekends')return[6,7];return canonicalizeIsoWeekdays(value.split(',').map(Number));} -function resolveStation(store:JsonLibraryStore,key:string):Station{const state=store.snapshot();const stations=[...state.favorites,...state.imported,...state.recent.map(item=>item.station),...state.alarms.flatMap(alarm=>[alarm.station,...(alarm.playback.fallbackStation?[alarm.playback.fallbackStation]:[])])];const station=stations.find(item=>stationKey(item)===key);if(!station)throw new Error(`Station not found: ${key}. Add it to favorites/imports/recents first.`);return station;} +function resolveStation(store:JsonLibraryStore,key:string):Station{const state=store.snapshot();const stations=[...state.favorites,...state.imported,...state.recent.map(item=>item.station),...state.alarms.flatMap(alarm=>[alarm.station,...(alarm.playback.fallbackStation?[alarm.playback.fallbackStation]:[])])];const matches=new Map(stations.filter(item=>matchesStationId(item,key)).map(item=>[stationKey(item),item]));if(matches.size>1)throw new Error('This legacy station ID is ambiguous. List stations and use the current ID.');const station=[...matches.values()][0];if(!station)throw new Error(`Station not found: ${key}. Add it to favorites/imports/recents first.`);return station;} function parseOptions(args:string[]):Options{const allowed=new Set(['station','once','time','days','timezone','label','volume','fade','stop-after','fallback','grace','disabled','wake','no-wake','guard','no-guard','clear-fallback']);const values=new Map();const positionals:string[]=[];for(let index=0;index{throw new Error(`Missing --${key}.`);})();return value;} function requiredPos(values:string[],index:number){const value=values[index];if(!value)throw new Error(alarmUsage());return value;} diff --git a/src/alarms/occurrence-cache.test.ts b/src/alarms/occurrence-cache.test.ts new file mode 100644 index 0000000..a6dd973 --- /dev/null +++ b/src/alarms/occurrence-cache.test.ts @@ -0,0 +1,27 @@ +import {describe, expect, it} from 'vitest'; +import type {Alarm} from '../types.js'; +import {AlarmOccurrenceCache} from './occurrence-cache.js'; + +const alarm = {id: 'morning', enabled: true, schedule: {type: 'recurring', time: '01:30', + weekdays: [1,2,3,4,5,6,7], timezone: 'America/Los_Angeles'}} as Alarm; + +describe('alarm occurrence cache', () => { + it('expires at a DST fold occurrence, invalidates clock reversal, and isolates returned Dates', () => { + const cache = new AlarmOccurrenceCache(); + const before = new Date('2026-11-01T08:00:00Z'); + const first = cache.next(alarm, before)!; + expect(first.toISOString()).toBe('2026-11-01T08:30:00.000Z'); + first.setFullYear(1900); + expect(cache.next(alarm, new Date('2026-11-01T08:01:00Z'))?.toISOString()).toBe('2026-11-01T08:30:00.000Z'); + expect(cache.next(alarm, new Date('2026-11-01T08:30:00Z'))?.toISOString()).toBe('2026-11-02T09:30:00.000Z'); + expect(cache.next(alarm, before)?.toISOString()).toBe('2026-11-01T08:30:00.000Z'); + }); + it('invalidates schedule, enabled, and snooze changes', () => { + const cache = new AlarmOccurrenceCache(); + const now = new Date('2026-10-03T08:00:00Z'); + expect(cache.next(alarm, now)).not.toBeNull(); + expect(cache.next({...alarm, enabled: false}, now)).toBeNull(); + expect(cache.next({...alarm, nextOverride: {at: '2026-10-03T08:15:00Z', createdAt: now.toISOString(), reason: 'snooze'}}, now)?.toISOString()).toBe('2026-10-03T08:15:00.000Z'); + expect(cache.next({...alarm, schedule: {...alarm.schedule, type: 'once', at: '2026-10-03T09:00:00Z'}}, now)?.toISOString()).toBe('2026-10-03T09:00:00.000Z'); + }); +}); diff --git a/src/alarms/occurrence-cache.ts b/src/alarms/occurrence-cache.ts new file mode 100644 index 0000000..021ddfe --- /dev/null +++ b/src/alarms/occurrence-cache.ts @@ -0,0 +1,25 @@ +import type {Alarm} from '../types.js'; +import {nextOccurrenceForAlarm} from './schedule.js'; + +/** Reuse civil-time work until its result expires; clock reversals invalidate it. */ +export class AlarmOccurrenceCache { + private readonly entries = new Map(); + + constructor(private readonly limit = 512) {} + + next(alarm: Alarm, now: Date): Date | null { + const time = now.getTime(); + const fingerprint = JSON.stringify([alarm.enabled, alarm.schedule, alarm.nextOverride?.at]); + const cached = this.entries.get(alarm.id); + if (cached && cached.fingerprint === fingerprint && time >= cached.computedAt && (cached.at === null || cached.at > time)) { + this.entries.delete(alarm.id); + this.entries.set(alarm.id, cached); + return cached.at === null ? null : new Date(cached.at); + } + const occurrence = nextOccurrenceForAlarm(alarm, now); + this.entries.delete(alarm.id); + this.entries.set(alarm.id, {fingerprint, computedAt: time, at: occurrence?.getTime() ?? null}); + while (this.entries.size > this.limit) this.entries.delete(this.entries.keys().next().value!); + return occurrence; + } +} diff --git a/src/alarms/runner.test.ts b/src/alarms/runner.test.ts index 53cc257..73e24b0 100644 --- a/src/alarms/runner.test.ts +++ b/src/alarms/runner.test.ts @@ -1,5 +1,5 @@ import {describe,expect,it,vi} from 'vitest'; -import {mkdtempSync,readdirSync,rmSync,utimesSync} from 'node:fs'; +import {existsSync,mkdirSync,mkdtempSync,readdirSync,rmSync,utimesSync,writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import type {Alarm,Station} from '../types.js'; @@ -15,13 +15,37 @@ const scheduledAt='2030-01-01T08:00:00Z'; function deps(overrides:Partial={}):AlarmRunnerDeps{ const current={...alarm}; - return {now:()=>new Date('2030-01-01T08:01:00Z'),store:{getAlarm:vi.fn(()=>current),recordAlarmOutcome:vi.fn(),toggleAlarm:vi.fn(),snoozeAlarm:vi.fn(),addRecent:vi.fn(),startListeningSession:vi.fn(),checkpointActiveListeningSession:vi.fn(),finishActiveListeningSession:vi.fn()},providers:{resolve:vi.fn(async(s:Station)=>({url:`https://${s.id}`}))},player:{play:vi.fn(async()=>{}),stop:vi.fn(async()=>{}),setVolume:vi.fn(async()=>({ok:true})),getState:vi.fn(()=>({backend:'mpv'}))},scheduler:{sync:vi.fn(async()=>null)},inhibitor:{status:vi.fn(()=>({supported:true,active:false,message:'ok'})),acquire:vi.fn(async()=>({release:vi.fn(async()=>{})}))},acquireLock:vi.fn(()=>()=>{}),createSession:vi.fn(async(_s,h)=>{queueMicrotask(()=>h.onDismiss());return{update:vi.fn(),close:vi.fn(async()=>{})};}),wait:vi.fn(async()=>{}),...overrides}; + return {now:()=>new Date('2030-01-01T08:01:00Z'),store:{getAlarm:vi.fn(()=>current),recordAlarmOutcome:vi.fn(),toggleAlarm:vi.fn(),snoozeAlarm:vi.fn(),addRecent:vi.fn(),startListeningSession:vi.fn(),checkpointActiveListeningSession:vi.fn(),finishActiveListeningSession:vi.fn()},providers:{resolve:vi.fn(async(s:Station)=>({url:`https://${s.id}`}))},player:{play:vi.fn(async()=>{}),stop:vi.fn(async()=>{}),setVolume:vi.fn(async()=>({ok:true})),getState:vi.fn(()=>({backend:'mpv'}))},scheduler:{sync:vi.fn(async()=>null)},inhibitor:{status:vi.fn(()=>({supported:true,active:false,message:'ok'})),acquire:vi.fn(async()=>({release:vi.fn(async()=>{})}))},acquireLock:vi.fn(()=>()=>{}),createSession:vi.fn(async(_s,h)=>{return{update:vi.fn(status=>{if(status.state==='playing')queueMicrotask(()=>h.onDismiss());}),close:vi.fn(async()=>{})};}),wait:vi.fn(async()=>{}),...overrides}; } describe('alarm runner',()=>{ + it('ends history at backend EOF and fails the occurrence instead of waiting out its deadline', async () => { + let change: ((state: import('../types.js').PlaybackState) => void) | undefined; + let current = new Date('2030-01-01T08:01:00Z'); const base = deps(); + const ended = new Date('2030-01-01T08:01:02Z'); + const d = deps({now: () => current, player: {...base.player, onChange: handler => { change = handler; return vi.fn(); }}, + createSession: vi.fn(async () => ({update: vi.fn(), close: vi.fn(async () => {})})), + wait: vi.fn(() => { current = ended; change!({backend: 'mpv', state: 'stopped', ready: false, volume: 40, muted: false, message: 'EOF'}); return new Promise(() => {}); })}); + expect(await runAlarm('a', scheduledAt, d)).toMatchObject({status: 'failed', message: 'EOF'}); + expect(d.store.finishActiveListeningSession).toHaveBeenCalledWith(ended); + }); + + it('releases a late sleep inhibitor after manual dismissal during acquisition', async () => { + let finish!: (lease: InhibitorLease) => void; + let handlers!: Parameters[1]; const release = vi.fn(async () => {}); + const d = deps({createSession: vi.fn(async (_status, value) => { handlers = value; return {update: vi.fn(), close: vi.fn(async () => {})}; }), + inhibitor: {status: vi.fn(() => ({supported: true, active: false, message: 'ok'})), acquire: vi.fn(() => { + queueMicrotask(() => handlers.onDismiss()); return new Promise(resolve => { finish = resolve; }); + })}}); + expect((await runAlarm('a', scheduledAt, d)).status).toBe('dismissed'); + finish({release}); await Promise.resolve(); await Promise.resolve(); + expect(release).toHaveBeenCalledOnce(); + expect(d.store.startListeningSession).not.toHaveBeenCalled(); + }); + it('plays headlessly, inhibits sleep, records activity, and cleans up',async()=>{ const d=deps();const result=await runAlarm('a',scheduledAt,d); - expect(d.player.play).toHaveBeenCalledWith(station,'https://main'); + expect(d.player.play).toHaveBeenCalledWith(station,'https://main',{signal:expect.any(AbortSignal)}); expect(d.inhibitor.acquire).toHaveBeenCalled(); expect(d.store.addRecent).toHaveBeenCalledWith(station); expect(d.store.recordAlarmOutcome).toHaveBeenCalledWith('a',expect.objectContaining({status:'dismissed'}),{clearNextOverride:true}); @@ -212,5 +236,72 @@ describe('alarm runner',()=>{ it('releases an inhibitor that finishes acquiring after cancellation',async()=>{let finish:(lease:InhibitorLease)=>void=()=>{};let signal=()=>{};const release=vi.fn(async()=>{});const acquire=vi.fn(()=>{setTimeout(signal,0);return new Promise(resolve=>{finish=resolve;});});const d=deps({inhibitor:{status:vi.fn(()=>({supported:true,active:false,message:'ok'})),acquire},createSession:vi.fn(async()=>({update:vi.fn(),close:vi.fn(async()=>{})})),subscribeSignals:handler=>{signal=handler;return vi.fn();}});const result=await runAlarm('a',scheduledAt,d);expect(result.status).toBe('dismissed');finish({release});await Promise.resolve();await Promise.resolve();expect(release).toHaveBeenCalled();}); + it.each([ + ['wake/a', 'wake?a'], ['wake', 'WAKE'], ['é', 'e\u0301'], + ['x'.repeat(80) + 'a', 'x'.repeat(80) + 'b'], ['CON', 'con'], + ['\ud800', '\ud801'], ['wake\0up', 'wake_up'], + ['abcde123-1234-1234-1234-123456789abc', 'ABCDE123-1234-1234-1234-123456789ABC'] + ])('keeps distinct imported alarm IDs independent: %s and %s', (firstId, secondId) => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-occurrence-ids-')); + try { + const first = acquireOccurrenceLock(firstId, scheduledAt, root); + expect(first).not.toBeNull(); + const second = acquireOccurrenceLock(secondId, scheduledAt, root); + expect(second).not.toBeNull(); + expect(acquireOccurrenceLock(firstId, scheduledAt, root)).toBeNull(); + expect(acquireOccurrenceLock(secondId, scheduledAt, root)).toBeNull(); + first!(); + second!(); + expect(acquireOccurrenceLock(firstId, scheduledAt, root)).toBeNull(); + expect(acquireOccurrenceLock(secondId, scheduledAt, root)).toBeNull(); + const names = readdirSync(join(root, 'locks')).map(name => name.normalize('NFD').toLowerCase()); + expect(new Set(names).size).toBe(2); + expect(names.every(name => Buffer.byteLength(name) < 200)).toBe(true); + } finally { rmSync(root, {recursive: true, force: true}); } + }); + + it.each(['abcde123-1234-1234-1234-123456789abc', 'wake/a'])('honors pre-upgrade running and completed claims for %s', id => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-old-occurrence-')); + try { + const oldPath = join(root, 'locks', `${id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)}-${Buffer.from(scheduledAt).toString('base64url')}`); + mkdirSync(oldPath, {recursive: true}); + writeFileSync(join(oldPath, 'running'), String(process.pid)); + expect(acquireOccurrenceLock(id, scheduledAt, root)).toBeNull(); + rmSync(join(oldPath, 'running')); + writeFileSync(join(oldPath, 'completed'), new Date().toISOString()); + expect(acquireOccurrenceLock(id, scheduledAt, root)).toBeNull(); + expect(readdirSync(join(root, 'locks'))).toHaveLength(1); + } finally { rmSync(root, {recursive: true, force: true}); } + }); + + it('retains the existing UUID lock path and allows an abandoned custom-ID claim to recover', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-old-abandoned-')); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('gone'), {code: 'ESRCH'}); }); + try { + const occurrence = Buffer.from(scheduledAt).toString('base64url'); + const id = 'abcde123-1234-1234-1234-123456789abc'; + const uuid = acquireOccurrenceLock(id, scheduledAt, root)!; + expect(existsSync(join(root, 'locks', `${id}-${occurrence}`, 'running'))).toBe(true); + uuid(); + const oldPath = join(root, 'locks', `wake_a-${occurrence}`); + mkdirSync(oldPath); writeFileSync(join(oldPath, 'running'), '123456'); + const recovered = acquireOccurrenceLock('wake/a', scheduledAt, root); + expect(recovered).not.toBeNull(); recovered!(); + } finally { kill.mockRestore(); rmSync(root, {recursive: true, force: true}); } + }); + + it('does not steal legacy claims whose owner cannot be inspected', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-unknown-owner-')); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('denied'), {code: 'EPERM'}); }); + try { + const oldPath = join(root, 'locks', `wake_a-${Buffer.from(scheduledAt).toString('base64url')}`); + mkdirSync(oldPath, {recursive: true}); + writeFileSync(join(oldPath, 'running'), '123456'); + expect(acquireOccurrenceLock('wake/a', scheduledAt, root)).toBeNull(); + rmSync(join(oldPath, 'running')); + expect(acquireOccurrenceLock('wake/a', scheduledAt, root)).toBeNull(); + } finally { kill.mockRestore(); rmSync(root, {recursive: true, force: true}); } + }); + it('prunes old completed idempotency markers',()=>{const root=mkdtempSync(join(tmpdir(),'radio-lock-'));try{const release=acquireOccurrenceLock('a',scheduledAt,root)!;release();const locks=join(root,'locks');const name=readdirSync(locks)[0]!;const completed=join(locks,name,'completed');utimesSync(completed,new Date(0),new Date(0));expect(pruneCompletedOccurrenceLocks(root,1000,Date.now())).toBe(1);}finally{rmSync(root,{recursive:true,force:true});}}); }); diff --git a/src/alarms/runner.ts b/src/alarms/runner.ts index 247f405..b048651 100644 --- a/src/alarms/runner.ts +++ b/src/alarms/runner.ts @@ -1,6 +1,7 @@ import {existsSync,mkdirSync,readFileSync,readdirSync,rmSync,statSync,writeFileSync} from 'node:fs'; import {platformPaths} from '../platform/paths.js'; import {dirname, join} from 'node:path'; +import {createHash} from 'node:crypto'; import type {Alarm,AlarmRunRecord,PlaybackState,ResolvedStream,Station} from '../types.js'; import type {PlaybackControlResult} from '../player/player-controller.js'; import type {SchedulerService} from './scheduler.js'; @@ -21,8 +22,8 @@ type RunnerStore={ checkpointActiveListeningSession(at?:Date):unknown; finishActiveListeningSession(at?:Date):unknown; }; -type RunnerPlayer={play(station:Station,url:string):Promise;stop():Promise;setVolume(volume:number):Promise;getState():Partial}; -type RunnerProvider={resolve(station:Station):Promise}; +type RunnerPlayer={play(station:Station,url:string,options?:{signal?:AbortSignal}):Promise;stop():Promise;setVolume(volume:number):Promise;getState():Partial;onChange?(listener:(state:PlaybackState)=>void):()=>void}; +type RunnerProvider={resolve(station:Station,options?:{signal?:AbortSignal}):Promise}; type RunnerScheduler=Pick & Partial>; export type AlarmRunnerDeps={ now():Date; store:RunnerStore; providers:RunnerProvider; player:RunnerPlayer; scheduler:RunnerScheduler; inhibitor:PowerInhibitor; @@ -49,10 +50,19 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR 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 tuneAbort=new AbortController(); + let signalReceived=false;let resolveEarlySignal:()=>void=()=>{};let onPlaybackSignal:()=>void=()=>{};const earlySignal=new Promise(resolve=>{resolveEarlySignal=resolve;});const unsubscribeSignals=deps.subscribeSignals?.(()=>{signalReceived=true;tuneAbort.abort();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=()=>{}; - const actionPromise=new Promise<'dismissed'|'snoozed'|'handoff'|'signal'>(resolve=>{resolveAction=resolve;}); + let action:'dismissed'|'snoozed'|'handoff'|'timeout'|'signal'|'backend-ended'|undefined;let keepPlaying=false;let resolveAction:(value:'dismissed'|'snoozed'|'handoff'|'signal'|'backend-ended')=>void=()=>{}; + const actionPromise=new Promise<'dismissed'|'snoozed'|'handoff'|'signal'|'backend-ended'>(resolve=>{resolveAction=resolve;}); + let backendMessage:string|undefined; + let playbackStarted=false;let backendEndedAt:Date|undefined; + const unsubscribePlayer=deps.player.onChange?.(state=>{ + if((!listening&&!playbackStarted)||action||!['error','stopped','idle'].includes(state.state))return; + backendMessage=state.message??'Alarm stream ended before its scheduled stop.'; + backendEndedAt=deps.now(); + action='backend-ended';resolveAction('backend-ended');resolveEarlySignal(); + }); try{ 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};} @@ -62,25 +72,29 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR 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'}; const creatingSession=deps.createSession(claimingStatus, { - 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();}, + onDismiss:()=>{tuneAbort.abort();action='dismissed';resolveAction('dismissed');resolveEarlySignal();}, + onSnooze:async minutes=>{await deps.store.snoozeAlarm(alarmId,new Date(deps.now().getTime()+minutes*60_000));preserveNextOverride=true;tuneAbort.abort();action='snoozed';resolveAction('snoozed');resolveEarlySignal();}, onKeepPlaying:()=>{keepPlaying=true;session?.update({keepPlaying:true});}, 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; + });void creatingSession.then(created=>{if(signalReceived||action)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; let interactivePreempted=false;let outputPrepared=false; const candidates=[alarm.station,alarm.station,alarm.playback.fallbackStation].filter((item):item is Station=>Boolean(item)); for(const [candidateIndex,candidate] of candidates.entries()){ - try{const resolved=await Promise.race([deps.providers.resolve(candidate).then(stream=>({stream})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in resolved){outcome=finish('dismissed','Alarm interrupted before playback started.');return{status:'dismissed',message:outcome.message};}if(!interactivePreempted){interactivePreempted=true;try{await deps.preemptInteractivePlayback?.();}catch(error){deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:true,message:`Alarm continued after interactive playback could not be stopped cleanly: ${errorMessage(error)}`});}}if(!outputPrepared){outputPrepared=true;if(deps.systemVolume)try{systemVolumeLease=await deps.systemVolume.acquireMinimum(alarm.playback.volume);deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:true,active:true,message:systemVolumeLease.message});}catch(error){deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Alarm will use player volume, but system output could not be raised: ${errorMessage(error)}`});}}const playPromise=deps.player.play(candidate,resolved.stream.url);const tuned=await Promise.race([playPromise.then(()=>({played:true as const})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in tuned||signalReceived){void playPromise.then(()=>deps.player.stop()).catch(()=>undefined);outcome=finish('dismissed','Alarm interrupted while playback was starting.');return{status:'dismissed',message:outcome.message};}resolvedStation=candidate;lastError=undefined;break;}catch(error){lastError=error;} + try{const resolved=await Promise.race([deps.providers.resolve(candidate,{signal:tuneAbort.signal}).then(stream=>({stream})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in resolved){outcome=finish('dismissed','Alarm interrupted before playback started.');return{status:'dismissed',message:outcome.message};}if(!interactivePreempted){interactivePreempted=true;try{await deps.preemptInteractivePlayback?.();}catch(error){deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:true,message:`Alarm continued after interactive playback could not be stopped cleanly: ${errorMessage(error)}`});}}tuneAbort.signal.throwIfAborted();if(!outputPrepared){outputPrepared=true;if(deps.systemVolume)try{systemVolumeLease=await deps.systemVolume.acquireMinimum(alarm.playback.volume);deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:true,active:true,message:systemVolumeLease.message});}catch(error){deps.health?.record({alarmId,occurrenceAt:scheduledAt.toISOString(),component:'runner',healthy:false,active:false,message:`Alarm will use player volume, but system output could not be raised: ${errorMessage(error)}`});}}tuneAbort.signal.throwIfAborted();const playPromise=deps.player.play(candidate,resolved.stream.url,{signal:tuneAbort.signal});const tuned=await Promise.race([playPromise.then(()=>({played:true as const})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in tuned||signalReceived){void playPromise.then(()=>deps.player.stop()).catch(()=>undefined);outcome=finish('dismissed','Alarm interrupted while playback was starting.');return{status:'dismissed',message:outcome.message};}resolvedStation=candidate;lastError=undefined;break;}catch(error){lastError=error;} if(candidateIndex({waited:true as const})),earlySignal.then(()=>({signal:true as const}))]);if('signal'in backoff){outcome=finish('dismissed','Alarm interrupted during station retry backoff.');return{status:'dismissed',message:outcome.message};}} } if(lastError)throw lastError; + playbackStarted=true; + const initialPlayback=deps.player.getState(); + if(initialPlayback.state&&['error','stopped','idle'].includes(initialPlayback.state))throw new Error(initialPlayback.message??'Alarm stream ended before playback could be published.'); 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)}`});} + try{const acquiring=deps.inhibitor.acquire('RadioCLI alarm playback');void acquiring.then(acquired=>{if(signalReceived||action)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(backendEndedAt?'failed':'dismissed',backendEndedAt?backendMessage:'Alarm interrupted while sleep protection was starting.');return{status:outcome.status,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)}`});} + if(backendEndedAt)throw new Error(backendMessage??'Alarm stream ended while sleep protection was starting.'); 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)}`);} + try{await deps.store.addRecent(resolvedStation);}catch(error){recordRunnerWarning(`Recent listening history could not be saved: ${errorMessage(error)}`);} + try{await 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).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)}`);}); @@ -91,22 +105,25 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR else {const volume=await deps.player.setVolume(alarm.playback.volume);if(!volume.ok)deps.health?.record({alarmId,component:'runner',healthy:true,message:volume.message??'The selected backend does not support runtime volume; it started with the configured alarm volume.'});} if(!action){const elapsed=Math.max(0,deps.now().getTime()-startedAt.getTime());const timeout=deps.wait(Math.max(0,alarm.playback.stopAfterMinutes*60_000-elapsed)).then(()=>keepPlaying?new Promise(()=>{}):'timeout' as const);action=await Promise.race([actionPromise,timeout]);} } - outcome=finish(action==='dismissed'||action==='snoozed'||action==='signal'?'dismissed':'played',action==='snoozed'?'Snoozed.':action==='handoff'?'Handed off to interactive playback.':action==='signal'?'Interrupted.':undefined); + outcome=finish(action==='backend-ended'?'failed':action==='dismissed'||action==='snoozed'||action==='signal'?'dismissed':'played',action==='backend-ended'?backendMessage:action==='snoozed'?'Snoozed.':action==='handoff'?'Handed off to interactive playback.':action==='signal'?'Interrupted.':undefined); return{status:outcome.status,message:outcome.message}; - }catch(error){outcome=finish('failed',errorMessage(error));return{status:'failed',message:outcome.message};} + }catch(error){outcome=finish(tuneAbort.signal.aborted&&action!=='backend-ended'?'dismissed':'failed',errorMessage(error));return{status:outcome.status,message:outcome.message};} finally{ listening=false; + unsubscribePlayer?.(); + tuneAbort.abort(); 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)}`);} - try{deps.store.finishActiveListeningSession(deps.now());}catch(error){recordRunnerWarning(`Listening history could not be finished: ${errorMessage(error)}`);} + const endedAt=backendEndedAt??deps.now(); + try{await deps.store.checkpointActiveListeningSession(endedAt);}catch(error){recordRunnerWarning(`Listening history could not be checkpointed: ${errorMessage(error)}`);} + try{await deps.store.finishActiveListeningSession(endedAt);}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(error){recordRunnerWarning(`Alarm outcome or completion state could not be saved: ${errorMessage(error)}`);}} + if(alarm&&outcome){try{await 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)await 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{}} @@ -114,15 +131,37 @@ export async function runAlarm(alarmId:string,scheduledAtText:string,deps:AlarmR } 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); + const occurrence = Buffer.from(scheduledAt).toString('base64url'); + // Keep existing claims for application-generated UUIDs. Other accepted IDs + // need a bounded, case/normalization-safe identity on every filesystem. The + // dot prefix cannot be produced by the old lossy sanitizer. + const id = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(alarmId) + ? alarmId : `.id-${createHash('sha256').update(JSON.stringify(alarmId)).digest('hex')}`; + const path = join(root, 'locks', `${id}-${occurrence}`); + const legacyPath = join(root, 'locks', `${alarmId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)}-${occurrence}`); + mkdirSync(dirname(path),{recursive:true,mode:0o700});pruneCompletedOccurrenceLocks(root); + if (path !== legacyPath && hasLegacyOccurrenceClaim(legacyPath, alarmId)) return null; 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}); + try { + writeFileSync(join(path,'running'),String(process.pid),{mode:0o600}); + writeFileSync(join(path,'alarm-id'),JSON.stringify(alarmId),{mode:0o600}); + } catch (error) { try { rmSync(path, {recursive: true, force: true}); } catch {} throw error; } 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});}; } +function hasLegacyOccurrenceClaim(path: string, alarmId: string): boolean { + if (!existsSync(path)) return false; + try { + try { if (JSON.parse(readFileSync(join(path, 'alarm-id'), 'utf8')) !== alarmId) return false; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + if (existsSync(join(path, 'completed'))) return true; + const pid = Number(readFileSync(join(path, 'running'), 'utf8')); + return !Number.isInteger(pid) || pid < 1 || processAlive(pid); + } catch { return existsSync(path); /* Unknown legacy ownership fails closed. */ } +} 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{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;}} +function processAlive(pid:number){try{process.kill(pid,0);return true;}catch(error){return(error as NodeJS.ErrnoException).code!=='ESRCH';}} function nextExpectedOccurrence(alarm:Alarm,scheduledAt:Date){if(alarm.nextOverride)return new Date(alarm.nextOverride.at);if(alarm.schedule.type==='once')return new Date(alarm.schedule.at);return nextOccurrenceForAlarmAt(alarm,new Date(scheduledAt.getTime()-1500));} function nextOccurrenceForAlarmAt(alarm:Alarm,now:Date){return nextOccurrenceForAlarm(alarm,now);} diff --git a/src/alarms/schedule.ts b/src/alarms/schedule.ts index 7b8f04c..a60be98 100644 --- a/src/alarms/schedule.ts +++ b/src/alarms/schedule.ts @@ -4,6 +4,8 @@ export type ScheduledOccurrenceAssessment = 'pending' | 'due' | 'missed'; export const NATIVE_DISPATCH_TOLERANCE_MS = 30_000; const formatterCache = new Map(); +const timezoneCache = new Map(); +const civilOccurrenceCache = new Map(); export function isValidTimeZone(timezone: string): boolean { try { @@ -16,12 +18,17 @@ export function isValidTimeZone(timezone: string): boolean { export function canonicalizeTimeZone(timezone: string): string { const value = timezone.trim(); + const cached = timezoneCache.get(value); + if (cached) { timezoneCache.delete(value); timezoneCache.set(value, cached); return cached; } 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; + const canonical = new Intl.DateTimeFormat('en-US', {timeZone: value}).resolvedOptions().timeZone; + timezoneCache.set(value, canonical); + while (timezoneCache.size > 256) timezoneCache.delete(timezoneCache.keys().next().value!); + return canonical; } catch { throw new Error(`Invalid IANA timezone: ${timezone}`); } @@ -118,6 +125,7 @@ function zonedParts(instant: Date, timezone: string): CivilParts { hourCycle: 'h23' }); formatterCache.set(timezone, formatter); + while (formatterCache.size > 128) formatterCache.delete(formatterCache.keys().next().value!); } const values = Object.fromEntries( formatter.formatToParts(instant) @@ -134,6 +142,19 @@ function zonedParts(instant: Date, timezone: string): CivilParts { } function resolveCivilOccurrence(target: CivilParts, timezone: string): Date | null { + const key = `${timezone}:${target.year}:${target.month}:${target.day}:${target.hour}:${target.minute}`; + if (civilOccurrenceCache.has(key)) { + const cached = civilOccurrenceCache.get(key)!; + civilOccurrenceCache.delete(key); civilOccurrenceCache.set(key, cached); + return cached === null ? null : new Date(cached); + } + const result = scanCivilOccurrence(target, timezone); + civilOccurrenceCache.set(key, result?.getTime() ?? null); + while (civilOccurrenceCache.size > 2048) civilOccurrenceCache.delete(civilOccurrenceCache.keys().next().value!); + return result; +} + +function scanCivilOccurrence(target: CivilParts, timezone: string): Date | null { const estimate = Date.UTC(target.year, target.month - 1, target.day, target.hour, target.minute); const start = estimate - 18 * 60 * 60_000; const end = estimate + 18 * 60 * 60_000; diff --git a/src/alarms/scheduler.test.ts b/src/alarms/scheduler.test.ts index fce30c2..0d26095 100644 --- a/src/alarms/scheduler.test.ts +++ b/src/alarms/scheduler.test.ts @@ -7,6 +7,7 @@ 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'; +import type {OmarchyProbeOptions} from '../platform/omarchy.js'; const alarm: Alarm = { id: 'wake<&"', label: 'Morning <&>', enabled: true, @@ -57,6 +58,14 @@ 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: ''}; } +function omarchyInstallation(name='omarchy',version='4.0.4-1'):OmarchyProbeOptions{ + return{ + resolve:command=>command.startsWith('/')?command:`/usr/bin/${command}`, + isDirectory:path=>path==='/usr/share/omarchy',readFile:()=>null, + run:(_command,args)=>args[0]==='-Q'&&args[1]===name?`${name} ${version}`:null + }; +} + 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: ''})); @@ -99,6 +108,64 @@ describe('native alarm schedulers', () => { expect(writes.map(item => item[1]).join('\n')).toContain('RADIOCLI_ALARM_TERMINAL=linux:unsupported'); }); + // This executes a Linux bootstrap. Windows restores USERPROFILE in child + // processes even when a POSIX launch correctly omits that unused selector. + it.skipIf(process.platform === 'win32').each([true,false])('launches packaged Quattro alarms with current user-manager desktop variables (present: %s)',async connected=>{ + const root=mkdtempSync(join(tmpdir(),'radiocli-quattro-job-')); + try{ + const cliPath=join(root,"literal ' $() ; & % cli.cjs"); + const keys=[...pathEnvironmentKeys,'DISPLAY','WAYLAND_DISPLAY','DBUS_SESSION_BUS_ADDRESS','RADIOCLI_DESKTOP_MEDIA','RADIOCLI_OFFLINE','RADIOCLI_ALARM_TERMINAL']; + writeFileSync(cliPath,`process.stdout.write(JSON.stringify({args:process.argv.slice(2),env:Object.fromEntries(${JSON.stringify(keys)}.filter(key=>process.env[key]!==undefined).map(key=>[key,process.env[key]]))}))`); + const writes:Array<[string,string]>=[]; + const configured={HOME:'/home/registered',RADIOCLI_HOME:"/data/literal ' $() & %",XDG_DATA_HOME:'/saved/data',XDG_CACHE_HOME:'/saved/cache',RADIOCLI_DESKTOP_MEDIA:'off',RADIOCLI_OFFLINE:'1'}; + const stale={DISPLAY:':0',WAYLAND_DISPLAY:'wayland-stale',DBUS_SESSION_BUS_ADDRESS:'unix:path=/stale/bus',XDG_RUNTIME_DIR:'/stale/run'}; + const adapter=createSchedulerAdapter({platform:'linux',home:root,nodePath:process.execPath,cliPath,env:{...configured,...stale,PATH:'/usr/bin'},omarchy:omarchyInstallation(),commandExists:()=>true,writeFile:(path,text)=>writes.push([path,text]),run:async()=>({code:0,stdout:'',stderr:''})}); + await adapter.install({...alarm,id:"literal ' $() ; & % id"},new Date('2030-02-03T14:05:00.000Z')); + const body=writes.find(([path])=>path.endsWith('.service'))![1]; + const snapshot=schedulerEnvironment(body,'linux'); + expect(snapshot).toEqual({...configured,RADIOCLI_ALARM_TERMINAL:'linux:omarchy-launch-tui'}); + expect(schedulerPayload(body,'linux').environment).toEqual(snapshot); + for(const value of Object.values(stale))expect(body).not.toContain(value); + const live=connected?{DISPLAY:':9',WAYLAND_DISPLAY:'wayland-23',DBUS_SESSION_BUS_ADDRESS:'unix:path=/live/bus',XDG_RUNTIME_DIR:'/live/run'}:{}; + const inherited={...Object.fromEntries(pathEnvironmentKeys.map(key=>[key,`/wrong/${key}`])),RADIOCLI_DESKTOP_MEDIA:'on',RADIOCLI_OFFLINE:'0',...live}; + if(!connected)delete inherited.XDG_RUNTIME_DIR; + const invocation=schedulerInvocation(body,'linux'); + const result=JSON.parse(execFileSync(invocation.command,invocation.args,{env:{...inherited,...snapshot},encoding:'utf8'})); + expect(result).toEqual({args:['alarm','internal-run',"literal ' $() ; & % id",'2030-02-03T14:05:00.000Z'],env:{...snapshot,...live}}); + }finally{rmSync(root,{recursive:true,force:true});} + }); + + it.each([ + {name:'omarchy',version:'3.9.0-1'}, + {name:'omarchy-dev',version:'4.0.4-1'}, + {name:'omarchy',version:'5.0.0-1'}, + {name:'missing',version:'4.0.4-1'} + ])('keeps saved desktop snapshots for non-Quattro installation $name $version',async({name,version})=>{ + const writes:Array<[string,string]>=[]; + const desktop={DISPLAY:':0',WAYLAND_DISPLAY:'wayland-old',DBUS_SESSION_BUS_ADDRESS:'unix:path=/old/bus',XDG_RUNTIME_DIR:'/old/run'}; + const adapter=createSchedulerAdapter({platform:'linux',home:'/home/test',nodePath:'/node',cliPath:'/cli.js',env:{...desktop,PATH:'/usr/bin'},omarchy:omarchyInstallation(name,version),commandExists:()=>true,writeFile:(path,text)=>writes.push([path,text]),run:async()=>({code:0,stdout:'',stderr:''})}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00.000Z')); + const body=writes.find(([path])=>path.endsWith('.service'))![1]; + expect(schedulerEnvironment(body,'linux')).toMatchObject(desktop); + expect(schedulerPayload(body,'linux').environment).toMatchObject(desktop); + }); + + it.each([undefined,'linux:unsupported','linux:/custom/terminal'])('does not persist missing registration-time desktop capability over a later Quattro session (override: %s)',async terminal=>{ + const writes:Array<[string,string]>=[]; + const adapter=createSchedulerAdapter({platform:'linux',home:'/home/test',nodePath:'/node',cliPath:'/cli.js',env:{PATH:'/usr/bin',RADIOCLI_ALARM_TERMINAL:terminal},omarchy:omarchyInstallation(),commandExists:()=>true,writeFile:(path,text)=>writes.push([path,text]),run:async()=>({code:0,stdout:'',stderr:''})}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00.000Z')); + const body=writes.find(([path])=>path.endsWith('.service'))![1]; + expect(schedulerEnvironment(body,'linux')).toEqual(terminal?{RADIOCLI_ALARM_TERMINAL:terminal}:{}); + }); + + it('preserves an explicit unsupported terminal selector even when the Quattro launcher is available',async()=>{ + const writes:Array<[string,string]>=[]; + const adapter=createSchedulerAdapter({platform:'linux',home:'/home/test',nodePath:'/node',cliPath:'/cli.js',env:{PATH:'/usr/bin',WAYLAND_DISPLAY:'wayland-live',DBUS_SESSION_BUS_ADDRESS:'unix:path=/run/live/bus',RADIOCLI_ALARM_TERMINAL:'linux:unsupported'},omarchy:omarchyInstallation(),commandExists:()=>true,writeFile:(path,text)=>writes.push([path,text]),run:async()=>({code:0,stdout:'',stderr:''})}); + await adapter.install(alarm,new Date('2030-02-03T14:05:00.000Z')); + const body=writes.find(([path])=>path.endsWith('.service'))![1]; + expect(schedulerPayload(body,'linux').environment.RADIOCLI_ALARM_TERMINAL).toBe('linux:unsupported'); + }); + it('exposes truthful unsupported Linux capability', () => { const adapter = createSchedulerAdapter({platform: 'linux', home: '/h', nodePath: 'node', cliPath: 'cli.js', writeFile: vi.fn(), removeFile: vi.fn(), run: vi.fn(), commandExists: () => false, env: {}}); expect(adapter.capabilities().supported).toBe(false); diff --git a/src/alarms/scheduler.ts b/src/alarms/scheduler.ts index 8d75fd7..03a0258 100644 --- a/src/alarms/scheduler.ts +++ b/src/alarms/scheduler.ts @@ -14,6 +14,7 @@ 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'; +import {identifyOmarchy,type OmarchyProbeOptions} from '../platform/omarchy.js'; export type SchedulerCapabilities = { name?: string; @@ -44,6 +45,7 @@ export type SchedulerDeps = { removeFile?: (path: string) => void; run?: (command: string, args: string[]) => Promise; commandExists?: (command: string) => boolean; + omarchy?: OmarchyProbeOptions; }; export class SchedulerService { @@ -107,7 +109,14 @@ 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 = {platform, home, nodePath, cliPath, env, terminal: detectGraphicalTerminal(platform, env), write, removeFile, run}; + const profile=policy.scheduler==='systemd'?identifyOmarchy({...deps.omarchy,platform,env,home}):undefined; + const inheritDesktop=profile?.kind==='quattro'&&profile.source==='package'&&profile.packageName==='omarchy'; + const detectedTerminal=detectGraphicalTerminal(platform, env,undefined,deps.omarchy); + // A job registered over SSH may next run inside UWSM's live desktop. Do not + // turn the registration-time absence of a display into a permanent selector. + const configuredTerminal=env.RADIOCLI_ALARM_TERMINAL?.trim(); + const terminal=inheritDesktop&&detectedTerminal.endsWith(':unsupported')?configuredTerminal||undefined:detectedTerminal; + const common = {platform, home, nodePath, cliPath, env, inheritDesktop, terminal, write, removeFile, run}; if (policy.scheduler === 'launchd') return launchdAdapter(common); if (policy.scheduler === 'task-scheduler') return windowsAdapter(common); if (policy.scheduler === 'systemd') { @@ -117,9 +126,9 @@ export function createSchedulerAdapter(deps: SchedulerDeps = {}): SchedulerAdapt return unsupportedAdapter(`Alarm scheduling is not supported on ${host.id==='unknown'?host.platform:host.id}.`); } -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}; +type Common = {platform: NodeJS.Platform; home: string; nodePath: string; cliPath: string; env: NodeJS.ProcessEnv; inheritDesktop:boolean; terminal:string|undefined; 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, {platform: common.platform, includeDesktop: true, terminal: common.terminal}); + return launchEnvironment(common.env, {platform: common.platform, includeDesktop: true, inheritDesktop: common.inheritDesktop, 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()]; @@ -168,7 +177,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(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 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),{inheritDesktop:common.inheritDesktop})); 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'); diff --git a/src/alarms/system-volume-linux.ts b/src/alarms/system-volume-linux.ts new file mode 100644 index 0000000..f148519 --- /dev/null +++ b/src/alarms/system-volume-linux.ts @@ -0,0 +1,147 @@ +import type {OutputIdentity,OutputSnapshot} from './system-volume-ownership.js'; + +type Result={code:number;stdout:string;stderr:string}; +type Run=(command:string,args:string[])=>Promise; +export type LinuxVolumeBackend={ + select?():Promise; + read(output?:OutputIdentity):Promise; + write(value:OutputSnapshot,execute?:Run,output?:OutputIdentity):Promise; +}; + +/** Never persist or mutate a default-sink alias: it can change during an alarm. */ +export function linuxVolumeBackend(run:Run,resolve:(command:string)=>string|null):LinuxVolumeBackend|undefined{ + const wpctl=resolve('wpctl'); + if(wpctl){ + const identity=async(id:string):Promise>=>{ + const [status,detail]=await Promise.all([ + checked(run(wpctl,['status']),'identify the PipeWire server'), + checked(run(wpctl,['inspect',id]),'identify the PipeWire output') + ]); + const server=/^PipeWire .+cookie:\s*(\d{1,20})\]/m.exec(status)?.[1]; + const node=/^id (\d{1,20}), type PipeWire:Interface:Node\s*$/m.exec(detail)?.[1]; + const property=(name:string)=>new RegExp(`^\\s*\\*?\\s*${name.replaceAll('.','\\.')} = "([^"\\r\\n]+)"\\s*$`,'m').exec(detail)?.[1]; + const serial=property('object.serial');const name=property('node.name'); + if(!server||!node||!serial||!/^\d{1,20}$/.test(serial)||!validName(name)||property('media.class')!=='Audio/Sink')throw new Error('PipeWire returned an unreadable output identity; no default-output mutation is safe.'); + return{kind:'pipewire',server,id:node,serial,name}; + }; + const verify=async(output:OutputIdentity|undefined)=>{ + if(!output||output.kind==='pulse')throw changedOutput(); + const id=output.kind==='pipewire'?output.id:output.nodeId; + const current=await identity(id); + if(current.server!==output.server||current.id!==id||current.serial!==output.serial||current.name!==output.name)throw changedOutput(); + return id; + }; + return{ + select:()=>identity('@DEFAULT_AUDIO_SINK@'), + async read(output){ + const id=await verify(output); + const text=await checked(run(wpctl,['get-volume',id]),'read PipeWire output volume'); + const match=/Volume:\s*(\d+(?:\.\d+)?)(?:\s|$)/i.exec(text); + if(!match)throw new Error('wpctl returned an unreadable output-volume state.'); + await verify(output); + return{volume:clamp(Number(match[1])*100),muted:/\[MUTED\]/i.test(text)}; + }, + async write(value,execute=run,output){ + const id=await verify(output); + await checked(execute(wpctl,['set-volume',id,`${clamp(value.volume)}%`]),'set PipeWire output volume'); + // A hot-unplug between the two commands must not unmute a reused ID. + await verify(output); + await checked(execute(wpctl,['set-mute',id,value.muted?'1':'0']),'set PipeWire mute state'); + } + }; + } + + const pactl=resolve('pactl'); + if(pactl){ + // The C-locale textual interface also supports PulseAudio versions predating + // pactl's JSON output. Its public server cookie changes across restarts. + const info=async()=>{ + const text=await checked(run(pactl,['info']),'identify the PulseAudio server'); + const server=/^Cookie:\s*([\da-f]+(?::[\da-f]+)*)\s*$/im.exec(text)?.[1]?.toLowerCase(); + const name=/^Default Sink:\s*(.+)\s*$/m.exec(text)?.[1]?.trim(); + if(!validName(server))throw new Error('PulseAudio returned an unreadable server identity; no default-output mutation is safe.'); + const pipewire=/^Server Name: PulseAudio \(on PipeWire [\w.+-]+\)\s*$/m.test(text); + const cookie=/^([\da-f]{1,4}):([\da-f]{1,4})$/.exec(server); + if(pipewire&&!cookie)throw new Error('PulseAudio returned an unreadable PipeWire server cookie.'); + // PipeWire's Pulse server exposes the same core cookie as wpctl, rendered + // as two hexadecimal words. Classic PulseAudio stays in a separate domain. + return{pipewire,server:pipewire?Number.parseInt(`${cookie![1]!.padStart(4,'0')}${cookie![2]!.padStart(4,'0')}`,16).toString():server,name:validName(name)?name:undefined}; + }; + const sinks=async(server:Awaited>):Promise>>=>{ + const text=await checked(run(pactl,server.pipewire?['list','sinks']:['list','short','sinks']),'identify PulseAudio outputs'); + const outputs:Array>=server.pipewire?parsePipeWireSinks(text,server.server):text.split(/\r?\n/).filter(Boolean).map(line=>{ + const [id,name]=line.split('\t'); + if(!id||!/^\d{1,20}$/.test(id)||!validName(name))throw new Error('PulseAudio returned an unreadable output identity.'); + return{kind:'pulse',server:server.server,id,name}; + }); + if(new Set(outputs.map(item=>item.id)).size!==outputs.length||new Set(outputs.map(item=>item.name)).size!==outputs.length)throw new Error('PulseAudio returned ambiguous output identities.'); + return outputs; + }; + const verify=async(output:OutputIdentity|undefined)=>{ + if(!output)throw changedOutput(); + const server=await info();const outputs=await sinks(server); + if(server.server!==output.server)throw changedOutput(); + const current=outputs.find(item=>{ + if(output.kind==='pulse')return item.kind==='pulse'&&item.id===output.id&&item.name===output.name; + if(item.kind!=='pulse-pipewire'||item.serial!==output.serial||item.name!==output.name)return false; + return output.kind==='pipewire'?item.nodeId===output.id:item.nodeId===output.nodeId&&item.id===output.id&&item.sinkName===output.sinkName; + }); + if(!current)throw changedOutput(); + return current.id; + }; + return{ + async select(){ + const server=await info();const outputs=await sinks(server); + const output=outputs.find(item=>(item.kind==='pulse-pipewire'?item.sinkName:item.name)===server.name); + if(!output)throw new Error('The default PulseAudio output is unavailable.'); + return output; + }, + async read(output){ + const id=await verify(output); + const [volume,mute]=await Promise.all([ + checked(run(pactl,['get-sink-volume',id]),'read PulseAudio output volume'), + checked(run(pactl,['get-sink-mute',id]),'read PulseAudio mute state') + ]); + const match=/(\d+)%/.exec(volume);const muted=/^Mute:\s*(yes|no)\s*$/im.exec(mute); + if(!match||!muted)throw new Error('pactl returned an unreadable output-volume state.'); + await verify(output); + return{volume:clamp(Number(match[1])),muted:muted[1]!.toLowerCase()==='yes'}; + }, + async write(value,execute=run,output){ + const id=await verify(output); + await checked(execute(pactl,['set-sink-volume',id,`${clamp(value.volume)}%`]),'set PulseAudio output volume'); + await verify(output); + await checked(execute(pactl,['set-sink-mute',id,value.muted?'1':'0']),'set PulseAudio mute state'); + } + }; + } + + const amixer=resolve('amixer'); + if(amixer)return{ + async read(){ + const text=await checked(run(amixer,['get','Master']),'read ALSA output volume'); + const last=[...text.matchAll(/\[(\d+)%\]/g)].at(-1); + if(!last)throw new Error('amixer returned an unreadable output-volume state.'); + return{volume:clamp(Number(last[1])),muted:/\[off\]/i.test(text)}; + }, + 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 parsePipeWireSinks(text:string,server:string):Array>{ + if(!text.trim())return[]; + return text.split(/(?=^Sink #)/m).filter(block=>block.trim()).map(block=>{ + const id=/^Sink #(\d{1,20})\s*$/m.exec(block)?.[1]; + const sinkName=/^\s+Name:\s*(.+)\s*$/m.exec(block)?.[1]?.trim(); + const property=(name:string)=>new RegExp(`^\\s+${name.replaceAll('.','\\.')} = "([^"\\r\\n]+)"\\s*$`,'m').exec(block)?.[1]; + const nodeId=property('object.id');const serial=property('object.serial');const name=property('node.name'); + if(!id||!nodeId||!/^\d{1,20}$/.test(nodeId)||!serial||!/^\d{1,20}$/.test(serial)||!validName(name)||!validName(sinkName))throw new Error('PulseAudio returned an unreadable PipeWire output identity.'); + return{kind:'pulse-pipewire',server,id,nodeId,serial,name,sinkName}; + }); +} + +function changedOutput():Error{return new Error('The original alarm output disconnected or its audio-server identity changed. Its saved volume was retained; no replacement output was changed.');} +function validName(value:unknown):value is string{return typeof value==='string'&&value.length>0&&value.length<=1024&&!/[\u0000-\u001f\u007f]/.test(value);} +function clamp(value:number){return Math.max(0,Math.min(100,Math.round(Number.isFinite(value)?value:0)));} +async function checked(promise:Promise,label:string):Promise{const result=await promise;if(result.code!==0)throw new Error(`${label} failed: ${(result.stderr||result.stdout||`exit ${result.code}`).trim()}`);if(Buffer.byteLength(result.stdout)>64*1024)throw new Error(`${label} returned too much data.`);return result.stdout;} diff --git a/src/alarms/system-volume-native.test.ts b/src/alarms/system-volume-native.test.ts index 5c1b2ad..1a07181 100644 --- a/src/alarms/system-volume-native.test.ts +++ b/src/alarms/system-volume-native.test.ts @@ -23,7 +23,7 @@ describe('native system-output command lifetime',()=>{ 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();} + if(state.volume===70){fs.writeFileSync(helperFile+'.tmp',JSON.stringify({pid:process.pid}));fs.renameSync(helperFile+'.tmp',helperFile);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)); } `; @@ -40,6 +40,7 @@ describe('native system-output command lifetime',()=>{ 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; + await until(()=>{try{return JSON.parse(fs.readFileSync(join(directory,'native-write.json'),'utf8')).helperPid===helperPid;}catch{return false;}}); 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[])=>{ diff --git a/src/alarms/system-volume-ownership.ts b/src/alarms/system-volume-ownership.ts index 0dcd194..6a488a9 100644 --- a/src/alarms/system-volume-ownership.ts +++ b/src/alarms/system-volume-ownership.ts @@ -5,6 +5,8 @@ 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'}>}; +export type OutputIdentity={kind:'pipewire';server:string;id:string;serial:string;name:string}|{kind:'pulse';server:string;id:string;name:string}|{kind:'pulse-pipewire';server:string;id:string;serial:string;nodeId:string;name:string;sinkName:string}; +type StoredVolumeState=SystemVolumeState|{version:2;outputs:Array<{identity:OutputIdentity;lease:SystemVolumeState}>}; /** The output device belongs to the OS account, not a RADIOCLI_HOME/XDG profile. */ export function defaultSystemVolumeDirectory():string{ @@ -15,7 +17,7 @@ export function defaultSystemVolumeDirectory():string{ /** 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){} + constructor(private readonly directory:string,private readonly timeoutMs=30_000,private readonly output?:OutputIdentity){} async transaction(work:()=>Promise):Promise{ const release=await this.lock(); @@ -24,23 +26,67 @@ export class SystemVolumeOwnership{ } read():SystemVolumeState|undefined{ + const value=this.readDocument(); + if(!value)return undefined; + if(value.version===1){ + if(!this.output)return value; + this.checkLegacy(value);return undefined; + } + if(!this.output)throw new Error('Saved output-volume ownership belongs to an identified Linux audio device; its recovery state was left intact.'); + return value.outputs.find(item=>sameOutput(item.identity,this.output!))?.lease; + } + + /** Enumerate other recovery records while holding the shared root lock. */ + otherOutputs():OutputIdentity[]{ + const value=this.readDocument(); + return this.output&&value?.version===2?value.outputs.map(item=>item.identity).filter(identity=>!sameOutput(identity,this.output!)):[]; + } + + private readDocument():StoredVolumeState|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(); + const value=JSON.parse(text) as StoredVolumeState; + if(value?.version===2){ + if(!Array.isArray(value.outputs)||value.outputs.length>128||!value.outputs.every(item=>item&&validOutput(item.identity)&&validLease(item.lease))||new Set(value.outputs.map(item=>outputKey(item.identity))).size!==value.outputs.length)throw new Error(); + }else if(!validLease(value))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{ + if(!this.output){this.writeDocument(value);return;} + const existing=this.readDocument(); + if(existing?.version===1)this.checkLegacy(existing); + const outputs=existing?.version===2?existing.outputs.filter(item=>!sameOutput(item.identity,this.output!)):[]; + if(outputs.length>=128)throw new Error('Too many unresolved output-volume recovery records; saved output states were retained.'); + this.writeDocument({version:2,outputs:[...outputs,{identity:this.output,lease:value}]}); + } + + private writeDocument(value:StoredVolumeState):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;}} + clear():void{ + if(this.output){ + const value=this.readDocument(); + if(value?.version===1)this.checkLegacy(value); + if(value?.version===2){ + const outputs=value.outputs.filter(item=>!sameOutput(item.identity,this.output!)); + if(outputs.length){this.writeDocument({version:2,outputs});return;} + } + } + try{unlinkSync(join(this.directory,'state.json'));}catch(error){if(codeOf(error)!=='ENOENT')throw error;} + } + + private checkLegacy(value:SystemVolumeState):void{ + // Older Linux jobs recorded only the default sink's volume. That baseline + // cannot safely be assigned to whichever device happens to be default now. + if(value.changed||value.participants.some(item=>item.phase==='active'&&volumeProcessAlive(item.pid)))throw new Error('An earlier alarm saved output-volume ownership without a device identity. Finish its active alarms or repair the saved state.json after checking the original output; the saved state was retained and no output was changed.'); + } /** A null PID is intentional: a crash before PID persistence is ambiguous. */ beginNativeWrite():void{this.saveNativeWrite(null);} @@ -125,5 +171,13 @@ function removeEmptyLock(directory:string):void{ 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 validLease(value:unknown):value is SystemVolumeState{if(!value||typeof value!=='object')return false;const state=value as SystemVolumeState;return state.version===1&&validId(state.generation)&&typeof state.changed==='boolean'&&(state.baseline===null?!state.changed:validSnapshot(state.baseline))&&Array.isArray(state.participants)&&state.participants.every(item=>item&&validId(item.id)&&validPid(item.pid)&&(item.phase==='pending'||item.phase==='active'))&&new Set(state.participants.map(item=>item.id)).size===state.participants.length;} +function validOutput(value:unknown):value is OutputIdentity{if(!value||typeof value!=='object')return false;const output=value as OutputIdentity;return ['pipewire','pulse','pulse-pipewire'].includes(output.kind)&&validLabel(output.server)&&validLabel(output.name)&&decimalId(output.id)&&(output.kind==='pulse'||decimalId(output.serial))&&(output.kind!=='pulse-pipewire'||decimalId(output.nodeId)&&validLabel(output.sinkName));} +function validLabel(value:unknown):value is string{return typeof value==='string'&&value.length>0&&value.length<=1024&&!/[\u0000-\u001f\u007f]/.test(value);} +function decimalId(value:unknown):value is string{return typeof value==='string'&&/^\d{1,20}$/.test(value);} +// The PipeWire core, object serial and name identify a sink across both CLIs. +// Backend-specific IDs remain in the record for independent mutation checks. +function outputKey(value:OutputIdentity):string{return JSON.stringify(value.kind==='pulse'?['pulse',value.server,value.id,value.name]:['pipewire',value.server,value.serial,value.name]);} +function sameOutput(first:OutputIdentity,second:OutputIdentity):boolean{return outputKey(first)===outputKey(second);} 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.native.test.ts b/src/alarms/system-volume.native.test.ts new file mode 100644 index 0000000..3d498a3 --- /dev/null +++ b/src/alarms/system-volume.native.test.ts @@ -0,0 +1,117 @@ +import {execFile, spawn, type ChildProcess} from 'node:child_process'; +import {once} from 'node:events'; +import {existsSync, mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {promisify} from 'node:util'; +import {describe, expect, it} from 'vitest'; +import {createSystemVolumeController} from './system-volume.js'; + +const execute = promisify(execFile); +type PipeWireObject = {id: number; type: string; props?: Record; info?: {props?: Record}}; + +/** Explicit CI opt-in: a private PipeWire/Pulse server and synthetic sinks. + * No commands target the desktop's output or establish audible playback. */ +describe.skipIf(process.platform !== 'linux' || process.env.RADIOCLI_NATIVE_PIPEWIRE_TEST !== '1')('native output-volume ownership', () => { + it.each(['wpctl', 'pactl'] as const)('restores independent outputs and refuses replacement devices with %s', async backend => { + await withAudio(async (directory, env) => { + const command = async (name: string, args: string[]) => (await execute(name, args, {env, timeout: 3000, maxBuffer: 1024 * 1024})).stdout; + const wp = (...args: Array) => command('wpctl', args.map(String)); + for (const name of ['radiocli-speakers', 'radiocli-headphones']) { + await command('pw-cli', ['create-node', 'adapter', `{ factory.name = support.null-audio-sink node.name = ${name} media.class = Audio/Sink object.linger = true audio.position = [ FL FR ] }`]); + } + let speakers = 0; let headphones = 0; + await until(async () => { + const objects = JSON.parse(await command('pw-dump', [])) as PipeWireObject[]; + speakers = objects.find(item => item.info?.props?.['node.name'] === 'radiocli-speakers')?.id ?? 0; + headphones = objects.find(item => item.info?.props?.['node.name'] === 'radiocli-headphones')?.id ?? 0; + return speakers > 0 && headphones > 0; + }); + await wp('set-default', speakers); + await wp('set-volume', speakers, '20%'); await wp('set-mute', speakers, '1'); + await wp('set-volume', headphones, '85%'); await wp('set-mute', headphones, '0'); + await until(async () => (await command('pactl', ['get-default-sink'])).trim() === 'radiocli-speakers'); + const run = async (name: string, args: string[]) => { + try { return {code: 0, stdout: await command(name, args), stderr: ''}; } + catch (error) { const failure = error as Error & {stdout?: string; stderr?: string}; return {code: 1, stdout: failure.stdout ?? '', stderr: failure.stderr ?? failure.message}; } + }; + const controller = createSystemVolumeController('linux', run, name => name === backend ? `/usr/bin/${backend}` : null, {directory: join(directory, 'ownership')}); + const peerBackend = backend === 'wpctl' ? 'pactl' : 'wpctl'; + const peer = createSystemVolumeController('linux', run, name => name === peerBackend ? `/usr/bin/${peerBackend}` : null, {directory: join(directory, 'ownership')}); + const overlapFirst = await controller.acquireMinimum(60); + const overlapSecond = await peer.acquireMinimum(80); + await overlapFirst.release(); + expect((await wp('get-volume', speakers)).trim()).toBe('Volume: 0.80'); + await overlapSecond.release(); + expect((await wp('get-volume', speakers)).trim()).toBe('Volume: 0.20 [MUTED]'); + + const first = await controller.acquireMinimum(70); + expect((await wp('get-volume', speakers)).trim()).toBe('Volume: 0.70'); + await wp('set-default', headphones); + await until(async () => (await command('pactl', ['get-default-sink'])).trim() === 'radiocli-headphones'); + const second = await controller.acquireMinimum(95); + expect((await wp('get-volume', headphones)).trim()).toBe('Volume: 0.95'); + await first.release(); + expect((await wp('get-volume', speakers)).trim()).toBe('Volume: 0.20 [MUTED]'); + expect((await wp('get-volume', headphones)).trim()).toBe('Volume: 0.95'); + await second.release(); + expect((await wp('get-volume', headphones)).trim()).toBe('Volume: 0.85'); + expect((await wp('get-volume', speakers)).trim()).toBe('Volume: 0.20 [MUTED]'); + + await wp('set-default', speakers); + await until(async () => (await command('pactl', ['get-default-sink'])).trim() === 'radiocli-speakers'); + const disconnected = await controller.acquireMinimum(70); + await command('pw-cli', ['destroy', String(speakers)]); + await command('pw-cli', ['create-node', 'adapter', '{ factory.name = support.null-audio-sink node.name = radiocli-speakers media.class = Audio/Sink object.linger = true audio.position = [ FL FR ] }']); + let replacement = 0; + await until(async () => { + const objects = JSON.parse(await command('pw-dump', [])) as PipeWireObject[]; + replacement = objects.find(item => item.info?.props?.['node.name'] === 'radiocli-speakers')?.id ?? 0; + return replacement > 0; + }); + await wp('set-volume', replacement, '37%'); await wp('set-mute', replacement, '0'); + await expect(disconnected.release()).rejects.toThrow(); + expect((await wp('get-volume', replacement)).trim()).toBe('Volume: 0.37'); + expect(existsSync(join(directory, 'ownership', 'state.json'))).toBe(true); + }); + }, 30_000); +}); + +async function withAudio(check: (directory: string, env: NodeJS.ProcessEnv) => Promise): Promise { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-native-audio-')); + const env = {...process.env, LC_ALL: 'C', XDG_RUNTIME_DIR: directory, XDG_STATE_HOME: directory, PIPEWIRE_RUNTIME_DIR: directory, PIPEWIRE_REMOTE: 'pipewire-0', PULSE_SERVER: `unix:${directory}/pulse/native`}; + const children: Array<{child: ChildProcess; closed: Promise}> = []; + const start = (name: string) => { + const child = spawn(name, [], {env, stdio: 'ignore'}); + const closed = once(child, 'close'); void closed.catch(() => undefined); + children.push({child, closed}); + }; + try { + start('pipewire'); + await until(async () => existsSync(join(directory, 'pipewire-0'))); + start('wireplumber'); start('pipewire-pulse'); + await until(async () => { + const output = await execute('pw-dump', [], {env, timeout: 1000, maxBuffer: 1024 * 1024}); + const objects = JSON.parse(output.stdout) as PipeWireObject[]; + return existsSync(join(directory, 'pulse', 'native')) && objects.some(item => item.type === 'PipeWire:Interface:Metadata' && item.props?.['metadata.name'] === 'default'); + }); + await check(directory, env); + } finally { + for (const {child, closed} of children.reverse()) { + child.kill('SIGTERM'); + const force = setTimeout(() => child.kill('SIGKILL'), 1000); + try { await closed; } catch { /* A failed spawn is reported by readiness. */ } + finally { clearTimeout(force); } + } + rmSync(directory, {force: true, recursive: true}); + } +} + +async function until(predicate: () => Promise): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { if (await predicate()) return; } catch { /* Services are still starting. */ } + await new Promise(resolve => setTimeout(resolve, 30)); + } + throw new Error('The private audio fixture did not become ready.'); +} diff --git a/src/alarms/system-volume.test.ts b/src/alarms/system-volume.test.ts index 7c83ece..6739705 100644 --- a/src/alarms/system-volume.test.ts +++ b/src/alarms/system-volume.test.ts @@ -3,6 +3,7 @@ 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 {randomUUID} from 'node:crypto'; import {createSystemVolumeController as createController} from './system-volume.js'; vi.mock('node:fs',async importOriginal=>({...await importOriginal()})); @@ -24,6 +25,33 @@ function fakeOutput(volume=20,muted=true){ return{run,writes,state:()=>({...state})}; } +function linuxOutput(command:'wpctl'|'pactl'='wpctl',pipewirePulse=false){ + const outputs=new Map([ + ['42',{name:'speakers',serial:'142',volume:20,muted:true}], + ['43',{name:'headphones',serial:'143',volume:85,muted:false}] + ]); + let selected='42';let server='123456'; + const writes:Array<{id:string;action:string;value:string}>=[]; + const run=vi.fn(async(executable:string,args:string[])=>{ + const ok=(stdout='')=>({code:0,stdout,stderr:''}); + if(args[0]==='status')return ok(`PipeWire 'pipewire-0' [1.4.7, test@host, cookie:${server}]`); + if(args[0]==='info'){const cookie=Number(server).toString(16).padStart(8,'0');return ok(`Server String: /run/user/1000/pulse/native\nServer Name: ${pipewirePulse?'PulseAudio (on PipeWire 1.4.9)':'pulseaudio'}\nDefault Sink: ${outputs.get(selected)?.name}\nCookie: ${pipewirePulse?`${cookie.slice(0,4)}:${cookie.slice(4)}`:server}`);} + if(args[0]==='list')return ok([...outputs].map(([id,value])=>args[1]==='short'?`${id}\t${value.name}\tPipeWire\tfloat32le 2ch 48000Hz\tIDLE`:`Sink #${value.serial}\n\tName: ${value.name}\n\tProperties:\n\t\tobject.id = "${id}"\n\t\tobject.serial = "${value.serial}"\n\t\tnode.name = "${value.name}"\n`).join('\n')); + const id=args[1]==='@DEFAULT_AUDIO_SINK@'?selected:pipewirePulse&&executable.endsWith('/pactl')?[...outputs].find(([,value])=>value.serial===args[1])?.[0]??'missing':args[1]!;const output=outputs.get(id); + if(!output)return{code:3,stdout:'',stderr:'Object not found'}; + if(args[0]==='inspect')return ok(`id ${id}, type PipeWire:Interface:Node\n * object.serial = "${output.serial}"\n * node.name = "${output.name}"\n * media.class = "Audio/Sink"\n`); + if(args[0]==='get-volume')return ok(`Volume: ${(output.volume/100).toFixed(2)}${output.muted?' [MUTED]':''}`); + if(args[0]==='get-sink-volume')return ok(`Volume: front-left: 1000 / ${output.volume}% / -10 dB`); + if(args[0]==='get-sink-mute')return ok(`Mute: ${output.muted?'yes':'no'}`); + if(args[0]==='set-volume'||args[0]==='set-sink-volume')output.volume=Number.parseInt(args[2]!); + else if(args[0]==='set-mute'||args[0]==='set-sink-mute')output.muted=args[2]==='1'; + else throw new Error(`Unexpected fake Linux mixer command: ${args.join(' ')}`); + writes.push({id,action:args[0]!,value:args[2]!});return ok(); + }); + const controller=(execute:Parameters[1]=run,backend=command)=>createSystemVolumeController('linux',execute,name=>name===backend?`/usr/bin/${backend}`:null); + return{outputs,writes,run,controller,select:(id:string)=>{selected=id;},restart:()=>{server='654321';}}; +} + 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(); @@ -150,21 +178,29 @@ describe('alarm system output volume',()=>{ 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();}); - it('uses the available PipeWire output on Linux',async()=>{const run=vi.fn(async(_command:string,args:string[])=>({code:0,stdout:args[0]==='get-volume'?'Volume: 0.15 [MUTED]':'',stderr:''}));const controller=createSystemVolumeController('linux',run,command=>command==='wpctl'?'/usr/bin/wpctl':null);const lease=await controller.acquireMinimum(60);expect(run).toHaveBeenCalledWith('/usr/bin/wpctl',['set-volume','@DEFAULT_AUDIO_SINK@','60%']);expect(run).toHaveBeenCalledWith('/usr/bin/wpctl',['set-mute','@DEFAULT_AUDIO_SINK@','0']);await lease.release();expect(run).toHaveBeenCalledWith('/usr/bin/wpctl',['set-mute','@DEFAULT_AUDIO_SINK@','1']);}); + it('uses the identified PipeWire output on Linux',async()=>{ + const output=linuxOutput();const lease=await output.controller().acquireMinimum(60); + expect(output.run).toHaveBeenCalledWith('/usr/bin/wpctl',['set-volume','42','60%']); + expect(output.run).toHaveBeenCalledWith('/usr/bin/wpctl',['set-mute','42','0']); + await lease.release();expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); + }); 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']]); + const output=linuxOutput(); + const run=async(command:string,args:string[])=>{const result=await output.run(command,args);return args[0]==='set-mute'&&args[2]==='0'?{...result,code:1,stderr:'mute denied'}:result;}; + await expect(output.controller(run).acquireMinimum(60)).rejects.toThrow(/mute denied/); + expect(output.writes).toEqual([{id:'42',action:'set-volume',value:'60%'},{id:'42',action:'set-mute',value:'0'},{id:'42',action:'set-volume',value:'20%'},{id:'42',action:'set-mute',value:'1'}]); + expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); }); 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); + const output=linuxOutput(); + const run=async(command:string,args:string[])=>{const result=await output.run(command,args);return args[0]==='set-mute'||args[2]==='20%'?{...result,code:1,stderr:args[2]==='20%'?'restore denied':'mute denied'}:result;}; + await expect(output.controller(run).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()=>{ @@ -176,5 +212,180 @@ describe('alarm system output volume',()=>{ 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(['wpctl','pactl'] as const)('keeps %s leases independent across default switches and same-output overlaps',async command=>{ + const output=linuxOutput(command); + const speakers=await output.controller().acquireMinimum(70); + output.select('43');const headphones=await output.controller().acquireMinimum(95); + output.select('42');const speakersAgain=await output.controller().acquireMinimum(80); + output.select('43');await speakers.release(); + expect(output.outputs.get('42')).toMatchObject({volume:80,muted:false}); + expect(output.outputs.get('43')).toMatchObject({volume:95,muted:false}); + await speakersAgain.release();expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); + expect(output.outputs.get('43')).toMatchObject({volume:95,muted:false}); + await headphones.release();expect(output.outputs.get('43')).toMatchObject({volume:85,muted:false}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + expect(output.writes.every(item=>/^\d+$/.test(item.id))).toBe(true); + }); + + it.each([ + ['wpctl','pactl',false],['wpctl','pactl',true], + ['pactl','wpctl',false],['pactl','wpctl',true] + ] as const)('shares one PipeWire baseline across %s and %s (reverse release: %s)',async(firstBackend,secondBackend,reverse)=>{ + const output=linuxOutput('wpctl',true); + const first=await output.controller(output.run,firstBackend).acquireMinimum(70); + const second=await output.controller(output.run,secondBackend).acquireMinimum(95); + const saved=JSON.parse(readFileSync(join(directory,'state.json'),'utf8')); + expect(saved.outputs).toHaveLength(1);expect(saved.outputs[0].lease.participants).toHaveLength(2); + await (reverse?second:first).release();expect(output.outputs.get('42')).toMatchObject({volume:95,muted:false}); + await (reverse?first:second).release();expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it.each([['wpctl','pactl'],['pactl','wpctl']] as const)('recovers a crashed %s owner through %s on the same and another PipeWire output',async(firstBackend,secondBackend)=>{ + const output=linuxOutput('wpctl',true);const path=join(directory,'state.json'); + vi.spyOn(process,'kill').mockImplementation(pid=>{if(pid===99999999)throw Object.assign(new Error('gone'),{code:'ESRCH'});return true;}); + for(const next of ['42','43']){ + output.select('42');await output.controller(output.run,firstBackend).acquireMinimum(70); + const saved=JSON.parse(readFileSync(path,'utf8'));saved.outputs[0].lease.participants[0].pid=99999999;writeFileSync(path,JSON.stringify(saved)); + output.select(next);const recovered=await output.controller(output.run,secondBackend).acquireMinimum(95); + if(next==='43')expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); + await recovered.release();expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); + expect(output.outputs.get('43')).toMatchObject({volume:85,muted:false}); + expect(existsSync(path)).toBe(false); + } + }); + + it('keeps classic PulseAudio separate even when a sink name and numeric cookie resemble PipeWire',async()=>{ + const pipewire=linuxOutput();const pulse=linuxOutput('pactl'); + pipewire.outputs.get('42')!.serial='42'; + const first=await pipewire.controller().acquireMinimum(70);const second=await pulse.controller().acquireMinimum(95); + expect(JSON.parse(readFileSync(join(directory,'state.json'),'utf8')).outputs).toHaveLength(2); + await first.release();expect(pipewire.outputs.get('42')).toMatchObject({volume:20,muted:true}); + expect(pulse.outputs.get('42')).toMatchObject({volume:95,muted:false});await second.release(); + expect(pulse.outputs.get('42')).toMatchObject({volume:20,muted:true}); + }); + + it.each(['object.id','object.serial','node.name'])('refuses unverifiable Pulse-on-PipeWire %s metadata without mixer writes',async property=>{ + const output=linuxOutput('pactl',true); + const run=async(command:string,args:string[])=>{const result=await output.run(command,args);return args[0]==='list'?{...result,stdout:result.stdout.split('\n').filter(line=>!line.includes(property)).join('\n')}:result;}; + await expect(output.controller(run).acquireMinimum(70)).rejects.toThrow(/unreadable PipeWire output identity/); + expect(output.writes).toEqual([]); + }); + + it.each(['wpctl','pactl'] as const)('preserves only the handed-off %s output while restoring another output',async command=>{ + const output=linuxOutput(command);const speakers=await output.controller().acquireMinimum(70); + output.select('43');const headphones=await output.controller().acquireMinimum(95); + await speakers.release({preserve:true});await headphones.release(); + expect(output.outputs.get('42')).toMatchObject({volume:70,muted:false}); + expect(output.outputs.get('43')).toMatchObject({volume:85,muted:false}); + }); + + it('restores a retained PulseAudio sink even if the server has no current default',async()=>{ + const output=linuxOutput('pactl');const lease=await output.controller().acquireMinimum(70); + output.select('unavailable');await lease.release(); + expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); + }); + + it.each(['wpctl','pactl'] as const)('leaves another %s lease intact after a partial acquisition and rollback failure',async command=>{ + const output=linuxOutput(command);const speakers=await output.controller().acquireMinimum(70); + output.select('43'); + const run=async(executable:string,args:string[])=>{const result=await output.run(executable,args);return args[0]?.startsWith('set-')?{...result,code:1,stderr:'partial write'}:result;}; + await expect(output.controller(run).acquireMinimum(95)).rejects.toThrow(/restoring.*failed/); + await speakers.release();expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); + const recovered=await output.controller().acquireMinimum(96);await recovered.release(); + expect(output.outputs.get('43')).toMatchObject({volume:85,muted:false}); + expect(existsSync(join(directory,'state.json'))).toBe(false); + }); + + it.each(['wpctl','pactl'] as const)('recovers a dead %s owner on its original output without lowering a different active output',async command=>{ + const output=linuxOutput(command);await output.controller().acquireMinimum(70); + const path=join(directory,'state.json');const saved=JSON.parse(readFileSync(path,'utf8')); + saved.outputs[0].lease.participants[0].pid=99999999;writeFileSync(path,JSON.stringify(saved)); + vi.spyOn(process,'kill').mockImplementation(pid=>{if(pid===99999999)throw Object.assign(new Error('gone'),{code:'ESRCH'});return true;}); + output.select('43');const headphones=await output.controller().acquireMinimum(95); + expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); + output.select('42');const recovered=await output.controller().acquireMinimum(80); + expect(output.outputs.get('43')).toMatchObject({volume:95,muted:false}); + await recovered.release();expect(output.outputs.get('42')).toMatchObject({volume:20,muted:true}); + await headphones.release();expect(output.outputs.get('43')).toMatchObject({volume:85,muted:false}); + }); + + it.each(['wpctl','pactl'] as const)('retains failed %s recovery when the original output disappears and allows a separate output lease',async command=>{ + const output=linuxOutput(command);const speakers=await output.controller().acquireMinimum(70); + output.outputs.delete('42');output.select('43');const before=output.writes.length; + await expect(speakers.release()).rejects.toThrow(/not found|identity changed/i); + expect(output.writes).toHaveLength(before); + const retained=JSON.parse(readFileSync(join(directory,'state.json'),'utf8')).outputs[0]; + expect(retained.lease.baseline).toEqual({volume:20,muted:true}); + const headphones=await output.controller().acquireMinimum(95);expect(headphones.message).toContain('recovery record remains unresolved and retained');await headphones.release(); + expect(output.outputs.get('43')).toMatchObject({volume:85,muted:false}); + expect(JSON.parse(readFileSync(join(directory,'state.json'),'utf8')).outputs).toEqual([retained]); + }); + + it.each(['wpctl','pactl'] as const)('does not restore a %s baseline onto a reused device index or restarted server',async command=>{ + const output=linuxOutput(command);const lease=await output.controller().acquireMinimum(70); + const original={...output.outputs.get('42')!};const before=output.writes.length; + output.outputs.set('42',{...original,name:'replacement',serial:'999',volume:90,muted:false}); + await expect(lease.release()).rejects.toThrow(/identity changed/);expect(output.writes).toHaveLength(before); + output.outputs.set('42',original);output.restart(); + await expect(lease.release()).rejects.toThrow(/identity changed/);expect(output.writes).toHaveLength(before); + expect(existsSync(join(directory,'state.json'))).toBe(true); + }); + + it('checks PipeWire object serial even when a reused ID has the same node name',async()=>{ + const output=linuxOutput();const lease=await output.controller().acquireMinimum(70); + output.outputs.get('42')!.serial='999';const before=output.writes.length; + await expect(lease.release()).rejects.toThrow(/identity changed/);expect(output.writes).toHaveLength(before); + }); + + it.each(['wpctl','pactl'] as const)('guards the second %s write when output identity changes after setting volume',async command=>{ + const output=linuxOutput(command); + const run=async(executable:string,args:string[])=>{ + const result=await output.run(executable,args); + if(args[0]==='set-volume'||args[0]==='set-sink-volume')output.outputs.set('42',{name:'replacement',serial:'999',volume:90,muted:true}); + return result; + }; + await expect(output.controller(run).acquireMinimum(70)).rejects.toThrow(/identity changed.*restoring.*identity changed/); + expect(output.writes).toHaveLength(1);expect(output.outputs.get('42')).toMatchObject({volume:90,muted:true}); + expect(JSON.parse(readFileSync(join(directory,'state.json'),'utf8')).outputs[0].lease.baseline).toEqual({volume:20,muted:true}); + }); + + it.each(['wpctl','pactl'] as const)('preserves unidentified legacy %s recovery and the original native-helper barrier',async command=>{ + const output=linuxOutput(command);const state=JSON.stringify({version:1,generation:randomUUID(),baseline:{volume:20,muted:true},changed:true,participants:[]}); + writeFileSync(join(directory,'state.json'),state); + const journal=JSON.stringify({version:1,ownerPid:99999999,helperPid:null});writeFileSync(join(directory,'native-write.json'),journal); + await expect(output.controller().acquireMinimum(70)).rejects.toThrow(/helper could not be identified/); + expect(readFileSync(join(directory,'native-write.json'),'utf8')).toBe(journal); + rmSync(join(directory,'native-write.json')); + await expect(output.controller().acquireMinimum(70)).rejects.toThrow(/without a device identity/); + expect(readFileSync(join(directory,'state.json'),'utf8')).toBe(state);expect(output.writes).toEqual([]); + }); + + it('migrates a completed, unchanged legacy record without discarding a live old lease',async()=>{ + const output=linuxOutput();const path=join(directory,'state.json'); + const state={version:1,generation:randomUUID(),baseline:{volume:20,muted:true},changed:false,participants:[{id:randomUUID(),pid:process.pid,phase:'active'}]}; + writeFileSync(path,JSON.stringify(state));await expect(output.controller().acquireMinimum(70)).rejects.toThrow(/without a device identity/); + state.participants=[];writeFileSync(path,JSON.stringify(state));const lease=await output.controller().acquireMinimum(70); + expect(JSON.parse(readFileSync(path,'utf8')).version).toBe(2);await lease.release(); + }); + + it.each(['identity','duplicate','baseline','participant'])('retains corrupt multi-output %s state without mixer writes',async damage=>{ + const output=linuxOutput();await output.controller().acquireMinimum(70); + const path=join(directory,'state.json');const saved=JSON.parse(readFileSync(path,'utf8')); + if(damage==='identity')delete saved.outputs[0].identity.serial; + if(damage==='duplicate')saved.outputs.push(saved.outputs[0]); + if(damage==='baseline')saved.outputs[0].lease.baseline.volume=200; + if(damage==='participant')saved.outputs[0].lease.participants[0].pid=-1; + const text=JSON.stringify(saved);writeFileSync(path,text);const writes=output.writes.length; + await expect(output.controller().acquireMinimum(80)).rejects.toThrow(/ownership state is unreadable/); + expect(output.writes).toHaveLength(writes);expect(readFileSync(path,'utf8')).toBe(text); + }); + + it('keeps ALSA output controls and restoration unchanged',async()=>{ + const run=vi.fn(async(_command:string,args:string[])=>({code:0,stdout:args[0]==='get'?'Mono: Playback 20 [20%] [off]':'',stderr:''})); + const lease=await createSystemVolumeController('linux',run,name=>name==='amixer'?'/usr/bin/amixer':null).acquireMinimum(60);await lease.release(); + expect(run.mock.calls.map(call=>call[1])).toEqual([['get','Master'],['set','Master','60%','unmute'],['set','Master','20%','mute']]); + }); + 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 4c38292..38d7423 100644 --- a/src/alarms/system-volume.ts +++ b/src/alarms/system-volume.ts @@ -4,6 +4,7 @@ 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'; +import {linuxVolumeBackend,type LinuxVolumeBackend} from './system-volume-linux.js'; type Snapshot=OutputSnapshot; type Result={code:number;stdout:string;stderr:string}; @@ -13,13 +14,30 @@ export type SystemVolumeController={acquireMinimum(volume:number):Promisestring|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; + const backend=adapter==='macos'?macBackend(run):adapter==='unix-audio'?linuxVolumeBackend(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); + const output=await backend.select?.(); + const directory=options.directory??defaultSystemVolumeDirectory(); + const ownership=new SystemVolumeOwnership(directory,options.lockTimeoutMs,output); + const execute=run===runCommand?(command:string,args:string[])=>runCommand(command,args,ownership):run; + const write=(value:Snapshot)=>backend.write(value,execute,output); return ownership.transaction(async()=>{ let state=ownership.read(); + let pendingRecovery=0; + // Recover dead owners on their still-connected original outputs even if + // the default has moved. An unplugged output remains repairable without + // preventing a different output from ringing. + for(const identity of ownership.otherOutputs()){ + const priorOutput=new SystemVolumeOwnership(directory,options.lockTimeoutMs,identity); + const prior=priorOutput.read(); + if(!prior||prior.participants.some(activeParticipant))continue; + if(prior.changed&&prior.baseline){ + try{await backend.write(prior.baseline,execute,identity);} + catch{pendingRecovery+=1;continue;} + } + priorOutput.clear(); + } if(state){ state.participants=state.participants.filter(activeParticipant); if(state.participants.length===0){ @@ -29,7 +47,7 @@ export function createSystemVolumeController(platform:NodeJS.Platform=process.pl ownership.clear();state=undefined; } } - const target=clamp(volume);const before=await backend.read();const changed=before.muted||before.volume{ @@ -79,16 +97,9 @@ export function createSystemVolumeController(platform:NodeJS.Platform=process.pl function activeParticipant(item:SystemVolumeState['participants'][number]):boolean{return item.phase==='active'&&volumeProcessAlive(item.pid);} -type Backend={read():Promise;write(value:Snapshot,execute?:Run):Promise}; +type Backend=LinuxVolumeBackend; 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,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,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 @' @@ -107,10 +118,11 @@ function runCommand(command:string,args:string[],ownership?:SystemVolumeOwnershi 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'});} + try{child=spawn(command,args,{stdio:['ignore','pipe','pipe'],windowsHide:true,timeout:10_000,killSignal:'SIGKILL',env:{...process.env,LC_ALL:'C'}});} 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)); + const append=(value:unknown,output:'stdout'|'stderr')=>{const text=String(value);if(output==='stdout')stdout+=text;else stderr+=text;if(Buffer.byteLength(stdout)+Buffer.byteLength(stderr)>64*1024){stdout=stdout.slice(0,32768);stderr=stderr.slice(0,32768);failure='The output-volume command returned too much data.';try{child.kill('SIGKILL');}catch{}}}; + child.stdout.on('data',value=>append(value,'stdout'));child.stderr.on('data',value=>append(value,'stderr')); // 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;}); diff --git a/src/cli-lazy.test.ts b/src/cli-lazy.test.ts new file mode 100644 index 0000000..46d6c1b --- /dev/null +++ b/src/cli-lazy.test.ts @@ -0,0 +1,28 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; + +// These modules must remain unevaluated for the common read-only entry points. +vi.mock('react/jsx-runtime', () => { throw new Error('Unexpected React import'); }); +vi.mock('ink', () => { throw new Error('Unexpected Ink import'); }); +vi.mock('./agent/cli.js', () => { throw new Error('Unexpected agent/MCP import'); }); +vi.mock('./agent/headless-host.js', () => { throw new Error('Unexpected host import'); }); +vi.mock('./providers/provider-manager.js', () => { throw new Error('Unexpected provider import'); }); +vi.mock('./player/player-controller.js', () => { throw new Error('Unexpected player import'); }); +vi.mock('./storage/store.js', () => { throw new Error('Unexpected library import'); }); +vi.mock('./setup.js', () => { throw new Error('Unexpected setup import'); }); +vi.mock('./cli/doctor.js', () => { throw new Error('Unexpected doctor import'); }); +vi.mock('./alarms/cli.js', () => { throw new Error('Unexpected alarm import'); }); +vi.mock('./update-check.js', () => { throw new Error('Unexpected updater import'); }); + +afterEach(() => vi.restoreAllMocks()); + +describe('lightweight CLI entry points', () => { + it('loads help, version, command help and top-level completion without application subsystems', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const {runCommand} = await import('./cli.js'); + for (const args of [['help'], ['help', '--all'], ['version'], ['setup', '--help'], ['doctor', '--help'], ['mcp', '--help'], ['play', '--help'], ['search', '--help'], ['_complete', 'p']]) { + await runCommand(args); + } + expect(log).toHaveBeenCalledTimes(9); + await expect(runCommand(['doctro'])).rejects.toThrow('Did you mean radiocli doctor?'); + }); +}); diff --git a/src/cli-terminal.test.tsx b/src/cli-terminal.test.tsx index 4acebe7..63241df 100644 --- a/src/cli-terminal.test.tsx +++ b/src/cli-terminal.test.tsx @@ -2,10 +2,11 @@ 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()})); +const {render, configure, closePlayback, disconnectPlayback, openPlayback} = vi.hoisted(() => ({render: vi.fn(), configure: vi.fn(), closePlayback: vi.fn(), disconnectPlayback: vi.fn(), openPlayback: vi.fn()})); vi.mock('ink', () => ({render})); vi.mock('./ui/App.js', () => ({App: () => null})); vi.mock('./ui/terminal-renderer.js', () => ({configureTerminalRenderer: configure})); +vi.mock('./agent/tui-session.js', () => ({openTuiSession: openPlayback})); const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); @@ -18,6 +19,9 @@ beforeEach(() => { 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)); + closePlayback.mockResolvedValue(undefined); + openPlayback.mockResolvedValue({session: {close: closePlayback, disconnect: disconnectPlayback}}); + render.mockReturnValue({waitUntilExit: async () => undefined}); process.argv = [process.execPath, fileURLToPath(new URL('./cli.tsx', import.meta.url))]; }); @@ -44,6 +48,18 @@ describe('CLI terminal entry point', () => { debug: !stdoutTty, kittyKeyboard: expect.objectContaining({mode: stdoutTty ? 'auto' : 'disabled'}) })); + const stdout = render.mock.calls[0]?.[1].stdout; + if (stdoutTty) expect(stdout).not.toBe(process.stdout); + else expect(stdout).toBe(process.stdout); + expect(closePlayback).toHaveBeenCalledOnce(); + }); + + it.each([{TERM: 'dumb'}, {RADIOCLI_SCREEN_READER: '1'}, {INK_SCREEN_READER: 'true'}])('keeps plain or accessible output free of RTL cursor anchors with %j', async env => { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: true}); + Object.defineProperty(process.stdout, 'isTTY', {configurable: true, value: true}); + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value); + await import('./cli.js'); + expect(render.mock.calls[0]?.[1].stdout).toBe(process.stdout); }); it('prints actionable help and exits before rendering when terminal input is absent', async () => { @@ -56,5 +72,15 @@ describe('CLI terminal entry point', () => { expect(process.exitCode).toBe(1); expect(render).not.toHaveBeenCalled(); expect(configure).not.toHaveBeenCalled(); + expect(openPlayback).not.toHaveBeenCalled(); + }); + + it('leaves the playback owner running when the renderer fails', async () => { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: true}); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + render.mockReturnValue({waitUntilExit: async () => { throw new Error('render failed'); }}); + await expect(import('./cli.js')).rejects.toThrow('render failed'); + expect(disconnectPlayback).toHaveBeenCalledOnce(); + expect(closePlayback).not.toHaveBeenCalled(); }); }); diff --git a/src/cli.test.ts b/src/cli.test.ts index 9469a2f..2b0c8ac 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,4 +1,4 @@ -import {existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {pathToFileURL} from 'node:url'; @@ -11,6 +11,9 @@ 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'; +import * as nativeDiagnostics from './platform/diagnostics.js'; +import * as airPlayReadiness from './player/airplay-readiness.js'; +import * as commandDiagnostics from './player/command-diagnostics.js'; vi.mock('./player/backend-install.js', async importOriginal => { const actual = await importOriginal(); @@ -56,6 +59,15 @@ describe('CLI command dispatch', () => { it('prints help without creating user library state', async () => { await runCommand(['help']); + expect(logs.join('\n')).toContain('radiocli play [station]'); + expect(logs.join('\n')).toContain('radiocli help --all'); + expect(logs.join('\n')).not.toContain('radiocli agent'); + expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); + }); + + it.each([['help', '--all'], ['--help', '--all']])('shows the full command reference with %s %s without creating state', async (...args) => { + await runCommand(args); + expect(logs.join('\n')).toContain('radiocli doctor'); expect(logs.join('\n')).toContain('radiocli setup'); expect(logs.join('\n')).toContain('radiocli import '); @@ -63,6 +75,17 @@ describe('CLI command dispatch', () => { expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); }); + it('persists desktop media controls without changing the settings-screen layout', async () => { + await runCommand(['media-controls', 'off']); + expect(new JsonLibraryStore().snapshot().settings.desktopMediaControls).toBe('off'); + expect(logs.join('\n')).toContain('desktop_media_preference=off'); + expect(logs.join('\n')).toContain('RADIOCLI_DESKTOP_MEDIA=off'); + await runCommand(['media-controls', 'auto']); + expect(new JsonLibraryStore().snapshot().settings.desktopMediaControls).toBe('auto'); + await expect(runCommand(['media-controls', 'yes'])).rejects.toThrow('Usage:'); + await expect(runCommand(['media-controls', 'on', 'extra'])).rejects.toThrow('Usage:'); + }); + it('prints setup help without probing or changing the system', async () => { await runCommand(['setup', '--help']); @@ -78,6 +101,7 @@ describe('CLI command dispatch', () => { expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); }); + // Doctor permits a bounded 10-second PowerShell scheduler probe on Windows. it('prints doctor setup guidance without creating user library state', async () => { await runCommand(['doctor']); @@ -87,7 +111,7 @@ describe('CLI command dispatch', () => { expect(logs.join('\n')).toContain('mpv_path='); expect(logs.join('\n')).toContain('mpv_launch='); expect(existsSync(join(radioCliHome, 'radiocli.json'))).toBe(false); - }); + }, process.platform === 'win32' ? 15_000 : 5_000); it('prints a machine-readable, state-free doctor report', async () => { await runCommand(['doctor', '--json']); @@ -131,6 +155,87 @@ describe('CLI command dispatch', () => { expect(readFileSync(blockedHome, 'utf8')).toBe('preserve me'); }); + it('preserves every existing doctor JSON field and adds separate native readiness evidence', async () => { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + for (const key of ['radioCliVersion', 'nodeVersion', 'platform', 'architecture']) expect(report[key]).toEqual(expect.any(String)); + expect(report.host).toEqual(expect.objectContaining({id: expect.any(String), arch: expect.any(String), endianness: expect.any(String), release: expect.any(String), libc: expect.any(String), isWsl: expect.any(Boolean)})); + expect(report.host.armVersion === null || typeof report.host.armVersion === 'number').toBe(true); + expect(report.support.scope).toBe('current-installation'); + for (const key of ['playback', 'playbackControls', 'packageInstallation', 'storage', 'filePermissions', 'atomicWrites', 'externalUrl', 'clipboard', 'terminalReopening', 'backgroundScheduling', 'catchUp', 'wakeRequests', 'sleepInhibition', 'systemVolume', 'unicode', 'color', 'screenReader', 'airPlay']) { + expect(report.capabilities[key]).toEqual(expect.objectContaining({status: expect.stringMatching(/^(?:available|unavailable|unverified)$/), message: expect.any(String)})); + } + expect(report.terminal).toEqual(expect.objectContaining({interactive: expect.any(Boolean), unicode: expect.any(Boolean), colorLevel: expect.any(Number), screenReader: expect.any(Boolean)})); + expect(report.network).toEqual(expect.objectContaining({status: expect.any(String), message: expect.any(String)})); + expect(report.backends).toEqual(expect.any(Array)); + for (const key of ['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd']) expect(report.commands[key] === null || typeof report.commands[key] === 'string').toBe(true); + expect(report.mpv).toEqual(expect.objectContaining({discovery: expect.any(String), launchable: expect.any(Boolean)})); + expect(report.mpv.path === null || typeof report.mpv.path === 'string').toBe(true); + expect(report.airPlay).toEqual(expect.objectContaining({available: expect.any(Boolean), safe: expect.any(Boolean), package: expect.any(String), vulnerablePackages: expect.any(Array), warningPackages: expect.any(Array), + eligible: expect.any(Boolean), ready: expect.any(Boolean), discoveryExecutable: expect.any(Boolean), discoveryService: expect.any(String), senderLoaded: expect.any(Boolean), ffmpeg: expect.any(Boolean)})); + expect(report.airPlay.version === null || typeof report.airPlay.version === 'string').toBe(true); + expect(report.guidance).toEqual(expect.any(Array)); + expect(report.omarchy).toHaveProperty('kind'); + expect(report.desktopMedia).toEqual(expect.objectContaining({preference: expect.any(Object), policy: expect.any(Object), sessionBus: expect.any(Object), owner: expect.any(Object)})); + expect(report.terminalLauncher.launch).toBe('unverified'); + expect(report.audio).toEqual(expect.objectContaining({status: expect.any(String), wpctl: expect.any(Object), pactl: expect.any(Object)})); + logs.length = 0; + await runCommand(['doctor']); + for (const line of ['omarchy=', 'desktop_media_preference=', 'desktop_media_policy=', 'desktop_media_bus=', 'desktop_media_owner=', 'terminal_launcher=', 'audio_session=', 'airplay_readiness=']) expect(logs.join('\n')).toContain(line); + }); + + it.each(['{"settings":{"desktopMediaControls":"off"},"private":"https://secret.test/token"}', '{corrupt library'])('never repairs or rewrites existing library state during doctor', async contents => { + const path = join(radioCliHome, 'radiocli.json'); + writeFileSync(path, contents, {mode: 0o400}); + const before = statSync(path); + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.desktopMedia.preference).toMatchObject(contents.startsWith('{corrupt') ? {value: 'auto', status: 'unverified'} : {value: 'off', source: 'library', status: 'available'}); + expect(readFileSync(path, 'utf8')).toBe(contents); + expect(statSync(path).mtimeMs).toBe(before.mtimeMs); + expect(readdirSync(radioCliHome)).toEqual(['radiocli.json']); + expect(JSON.stringify(report)).not.toContain('secret.test'); + }); + + it('contains an optional AirPlay probe failure without losing other doctor fields', async () => { + const probe = vi.spyOn(airPlayReadiness, 'probeAirPlayReadiness').mockRejectedValue(new Error('https://secret.test/token /private/account')); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.airPlay).toMatchObject({ready: false, discoveryService: 'unverified'}); + expect(report.capabilities.airPlay.status).toBe('unavailable'); + expect(report.capabilities.storage.status).toBe('available'); + expect(report.support.scope).toBe('current-installation'); + expect(JSON.stringify(report)).not.toMatch(/secret\.test|\/private\/account/); + } finally { probe.mockRestore(); } + }); + + it('keeps native health probes out of ordinary help and version commands', async () => { + const probe = vi.spyOn(nativeDiagnostics, 'probeNativeIntegrations'); + try { + await runCommand(['help']); + await runCommand(['version']); + await runCommand(['setup', '--help']); + expect(probe).not.toHaveBeenCalled(); + } finally { probe.mockRestore(); } + }); + + it('keeps mpv version and error diagnostics useful without printing raw executable output', async () => { + const probe = vi.spyOn(commandDiagnostics, 'diagnoseCommand').mockReturnValue({path: null, discovery: 'configured-path', launchable: false, + version: 'mpv 0.40.0 private-build=/private/account https://secret.test/token', error: 'spawn unixexec:path=/private/account https://secret.test/token'}); + try { + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.mpv).toEqual({path: null, discovery: 'configured-path', launchable: false, version: 'mpv 0.40.0', error: 'The configured mpv executable could not be run. Check RADIOCLI_MPV_PATH.'}); + expect(logs.join('\n')).not.toMatch(/private-build|\/private\/account|secret\.test|unixexec/); + logs.length = 0; + await runCommand(['doctor']); + expect(logs.join('\n')).toContain('mpv_version=mpv 0.40.0'); + expect(logs.join('\n')).toContain('Check RADIOCLI_MPV_PATH.'); + expect(logs.join('\n')).not.toMatch(/private-build|\/private\/account|secret\.test|unixexec/); + } finally { probe.mockRestore(); } + }); + 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}); @@ -180,6 +285,23 @@ describe('CLI command dispatch', () => { } finally { spy.mockRestore(); } }); + it.skipIf(process.platform !== 'linux')('rejects executable or remote scheduler bus transports in default Doctor', async () => { + const previous = process.env.DBUS_SESSION_BUS_ADDRESS; + const create = vi.spyOn(schedulers, 'createSchedulerAdapter'); + try { + for (const address of ['unixexec:path=/probe-only', 'tcp:host=secret.test,port=1234', 'autolaunch:']) { + process.env.DBUS_SESSION_BUS_ADDRESS = address; + logs.length = 0; + await runCommand(['doctor', '--json']); + const report = JSON.parse(logs.join('\n')); + expect(report.capabilities.backgroundScheduling).toMatchObject({status: 'unavailable', message: expect.stringContaining('local Unix session-bus')}); + expect(report.capabilities.storage.status).toBe('available'); + expect(logs.join('\n')).not.toMatch(/probe-only|secret\.test|unixexec|autolaunch/); + } + expect(create).not.toHaveBeenCalled(); + } finally { create.mockRestore(); restoreEnv('DBUS_SESSION_BUS_ADDRESS', previous); } + }); + 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}); diff --git a/src/cli.tsx b/src/cli.tsx index 199e148..d4e5987 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,50 +1,32 @@ #!/usr/bin/env node -import {realpathSync} from 'node:fs'; +import {readFileSync, realpathSync} from 'node:fs'; 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 {defaultLibraryPath, JsonLibraryStore} from './storage/store.js'; -import {parsePlaylistFile, writeM3u} from './playlists/playlist.js'; -import {importStreamUrl} from './streams/import-stream.js'; -import {detectPlaybackBackends, playbackBackendStatusLines} from './player/backend-install.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'; +import {completionCandidates, isListenerCommand, listenerCommands, listenerHelp} from './cli/arguments.js'; +import {sanitizeTerminalText} from './safety.js'; const runtime = {nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url)}; if (isDirectRun(process.argv[1], import.meta.url)) { const args = process.argv.slice(2); + process.stdout.on('error', error => { + if ((error as NodeJS.ErrnoException).code === 'EPIPE') process.exit(0); + else throw error; + }); if (args[0] === 'agent-ui') { const encoded = args[1]; if (!encoded) throw new Error('Missing RadioCLI agent startup request.'); await renderTui(encoded); } else if (args[0] === 'agent-host') { + const {runHeadlessAgentHost} = await import('./agent/headless-host.js'); await runHeadlessAgentHost(); } else if (args.length > 0) { await runCommand(args).catch(error => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; + const message = error instanceof Error ? error.message : String(error); + console.error(message.split('\n').map(line => sanitizeTerminalText(line) ?? '').join('\n')); + process.exitCode = error instanceof Error && error.name === 'AbortError' ? 130 : 1; }); } else { await renderTui(); @@ -52,30 +34,16 @@ if (isDirectRun(process.argv[1], import.meta.url)) { } 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']} - }); + const {renderTui: render} = await import('./cli/render-tui.js'); + await render(printHelp, encodedCommand); } export async function runCommand(args: string[]): Promise { const [command, ...rest] = args; if (!command || command === 'help' || command === '--help' || command === '-h') { - printHelp(); + if (command === 'help' && rest[0] && rest[0] !== '--all') { await runCommand([rest[0], '--help']); return; } + printHelp(rest.includes('--all')); return; } @@ -84,59 +52,97 @@ export async function runCommand(args: string[]): Promise { return; } + if (command === '_complete') { + let presets: string[] = []; + if (rest.length > 1 && ['play', 'favorite', 'unfavorite', 'preset'].includes(rest[0]!)) { + try { + // Completion is read-only, including when a library needs repair. + const {defaultLibraryPath} = await import('./storage/store.js'); + const library = JSON.parse(readFileSync(defaultLibraryPath(), 'utf8')) as {presets?: {name?: unknown}[]}; + if (Array.isArray(library.presets)) presets = library.presets.flatMap(preset => typeof preset?.name === 'string' && /^[a-z0-9][a-z0-9_-]{0,39}$/.test(preset.name) ? [preset.name] : []); + } catch { /* Completion must remain quiet when storage is unavailable. */ } + } + const candidates = completionCandidates(rest, presets); + if (candidates.length) console.log(candidates.join('\n')); + return; + } + + const optionArgs = rest.includes('--') ? rest.slice(0, rest.indexOf('--')) : rest; + const wantsHelp = optionArgs.includes('--help') || optionArgs.includes('-h'); + if (isListenerCommand(command)) { + if (wantsHelp) console.log(listenerHelp(command)); + else { + const {runListenerCommand} = await import('./cli/listener.js'); + await runListenerCommand(command, rest, runtime); + } + return; + } + if (command === 'agent' && wantsHelp) { + const {agentCommandUsage} = await import('./agent/cli.js'); + console.log(`${agentCommandUsage()}\nJSON controls for local agents. Everyday listening is available directly: radiocli play, now, favorites.`); + return; + } + const commandHelp = legacyCommandHelp()[command]; + if (wantsHelp && commandHelp) { console.log(commandHelp); return; } + if (command === 'alarm') { + const {runAlarmCommand} = await import('./alarms/cli.js'); await runAlarmCommand(rest); return; } if (command === 'mcp') { + const {runMcpCommand} = await import('./agent/cli.js'); await runMcpCommand(rest, runtime); return; } if (command === 'agent') { + const {runAgentCliCommand} = await import('./agent/cli.js'); await runAgentCliCommand(rest, runtime); return; } if (command === 'setup') { if (rest.includes('--help') || rest.includes('-h')) { - printSetupHelp(); + await printSetupHelp(); return; } + const {runSetup} = await import('./setup.js'); await runSetup({args: rest}); return; } + if (command === 'media-controls') { + if (rest.length > 1 || rest[0] !== undefined && !['auto', 'on', 'off'].includes(rest[0])) { + throw new Error('Usage: radiocli media-controls [auto|on|off]'); + } + const [{JsonLibraryStore}, {desktopMediaPolicy}] = await Promise.all([import('./storage/store.js'), import('./platform/desktop-media.js')]); + const store = new JsonLibraryStore(); + if (rest[0]) store.updateSettings({desktopMediaControls: rest[0] as 'auto' | 'on' | 'off'}); + const policy = desktopMediaPolicy(store.snapshot().settings); + console.log(`desktop_media_preference=${store.snapshot().settings.desktopMediaControls ?? 'auto'}`); + console.log(`desktop_media_effective=${policy.preference} ${policy.enabled ? 'enabled' : 'disabled'}`); + console.log(`desktop_media_note=${policy.reason}`); + if (rest[0]) console.log('Restart active RadioCLI sessions to apply this preference. RADIOCLI_DESKTOP_MEDIA=off overrides the saved preference.'); + return; + } + if (!isKnownCommand(command)) { - throw new Error(`Unknown command: ${command}\nRun radiocli help.`); + const suggestion = suggestCommand(command); + throw new Error(`Unknown command: ${command}${suggestion ? `\nDid you mean radiocli ${suggestion}?` : ''}\nRun radiocli help.`); } if (command === 'doctor') { - const backends = detectPlaybackBackends(); - const mpvDiagnostic = diagnoseCommand('mpv'); - const report = await doctorReport(backends, mpvDiagnostic); - if (rest.includes('--json')) { - 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}`); + const {runDoctor} = await import('./cli/doctor.js'); + await runDoctor(rest); return; } if (command === 'update') { const unknown = rest.filter(arg => arg !== '--install'); if (unknown.length > 0) throw new Error('Usage: radiocli update [--install]'); + const {checkForUpdate, installUpdate, updateCommandForInstall} = await import('./update-check.js'); const updateCheck = await checkForUpdate(); const updateCommand = updateCommandForInstall(); if (updateCheck.error) { @@ -151,9 +157,11 @@ export async function runCommand(args: string[]): Promise { const result = await installUpdate(updateCommand.command); if (!result.ok) throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`); console.log('updated=yes'); + const [{JsonLibraryStore}, {defaultAgentControlSettings}] = await Promise.all([import('./storage/store.js'), import('./types.js')]); const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings; if (agentControl.enabled) { try { + const {configureMcpIntegrations} = await import('./agent/mcp-install.js'); const repaired = await configureMcpIntegrations(true, runtime); const failed = repaired.filter(item => item.status === 'failed'); console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`); @@ -168,6 +176,9 @@ export async function runCommand(args: string[]): Promise { } if (command === 'check') { + const [{JsonLibraryStore}, {ProviderManager}, {PlayerController}, {playbackBackendStatusLines}] = await Promise.all([ + import('./storage/store.js'), import('./providers/provider-manager.js'), import('./player/player-controller.js'), import('./player/backend-install.js') + ]); const store = new JsonLibraryStore(); const providers = new ProviderManager(); const player = new PlayerController(() => store.snapshot().settings); @@ -175,45 +186,23 @@ export async function runCommand(args: string[]): Promise { const health = await providers.health(store.snapshot().settings); console.log(`store=${store.filePath}`); console.log(`backends=${backends.join(',') || 'none'}`); - printPlaybackBackendStatus(backends); + for (const line of playbackBackendStatusLines(backends)) console.log(line); for (const [provider, status] of Object.entries(health)) { console.log(`${provider}=${status}`); } return; } - if (command === 'countries') { - const providers = new ProviderManager(); - const countries = await providers.countries(30); - for (const country of countries) { - console.log(`${country.code}\t${country.stationCount}\t${country.name}`); - } - return; - } - - if (command === 'search') { - const query = rest.join(' ').trim(); - if (!query) { - throw new Error('Usage: radiocli search '); - } - - const store = new JsonLibraryStore(); - const providers = new ProviderManager(); - const stations = await providers.search(query, store.snapshot().settings, {limit: 20}); - for (const station of stations) { - console.log(`${station.provider}:${station.id}\t${station.name}\t${station.country ?? ''}\t${station.codec ?? ''}\t${station.bitrate ?? ''}`); - } - return; - } - if (command === 'import') { const target = rest[0]; if (!target) { throw new Error('Usage: radiocli import [station name]'); } + const {JsonLibraryStore} = await import('./storage/store.js'); const store = new JsonLibraryStore(); if (/^https?:\/\//i.test(target)) { + const {importStreamUrl} = await import('./streams/import-stream.js'); const result = await importStreamUrl(target, rest.slice(1).join(' ') || undefined); store.addImported([result.station]); console.log(`imported=${result.station.name}`); @@ -221,6 +210,7 @@ export async function runCommand(args: string[]): Promise { return; } + const {parsePlaylistFile} = await import('./playlists/playlist.js'); const stations = parsePlaylistFile(target); store.addImported(stations); console.log(`imported=${stations.length}`); @@ -228,6 +218,7 @@ export async function runCommand(args: string[]): Promise { } if (command === 'export') { + const [{JsonLibraryStore}, {writeM3u}] = await Promise.all([import('./storage/store.js'), import('./playlists/playlist.js')]); const file = rest[0] ?? 'radiocli-favorites.m3u'; const store = new JsonLibraryStore(); const state = store.snapshot(); @@ -238,25 +229,89 @@ export async function runCommand(args: string[]): Promise { } -export function printHelp(): void { +export function printHelp(all = false): void { console.log(`RadioCLI -Usage: - radiocli Start the TUI - radiocli version Print the installed version - radiocli check Show provider/backend health - radiocli doctor [--json] Show local playback setup guidance - radiocli setup Install and verify native playback tools - radiocli mcp Install, inspect, or run the MCP integration - radiocli agent Scriptable radio controls for local agents - radiocli update Show update availability and install command - radiocli update --install Install the latest release and repair enabled MCP entries - radiocli countries Print top countries - radiocli search Search public stations +Usage: radiocli [command] + Run radiocli without a command to open the TUI. + +Everyday listening: + radiocli play [station] Start or continue listening; names and presets work + radiocli pause Pause; use play to continue + radiocli next Tune the next station in the current queue + radiocli now See the current station, track, and volume + radiocli volume <0-100> Set listening volume + radiocli favorite Save the current station + radiocli favorites List saved stations; add --pick to choose one + radiocli stop Stop playback and close the background player +${all ? ` +More listening controls: + radiocli previous Tune the previous station in the queue + radiocli resume Resume paused playback (play also works) + radiocli mute | unmute Mute or restore sound + radiocli now --watch Watch station and track changes + radiocli status Alias for now + radiocli play --favorites | --recent | --popular + Choose a queue; add --random or --pick + +Discovery and library: + radiocli search Search stations; --country, --limit, --pick + radiocli countries List top countries + radiocli recent List recently played stations + radiocli unfavorite Remove a favorite (current station by default) + radiocli preset save Save a shortcut; play with play + radiocli preset remove Remove a shortcut + radiocli presets List shortcuts; also: preset list + radiocli stats Show listening time and streaks radiocli import Import a stream URL or local playlist - radiocli export [file] Export favorites/imports as .m3u + radiocli export [file] Export favorites/imports as .m3u radiocli alarm Manage alarms and scheduled radio -`); + +Setup and integrations: + radiocli setup Install and verify native playback tools + radiocli doctor [--json] Show local playback setup guidance + radiocli check Show provider/backend health + radiocli version Print the installed version + radiocli update Check for updates; --install to install + radiocli media-controls [auto|on|off] Configure Linux desktop media controls + radiocli mcp Install, inspect, or run the MCP integration + radiocli agent Scriptable radio controls for local agents +` : ''} +Examples: + radiocli play jazz Find a station and listen + radiocli play Continue the current or last station + radiocli play --favorites Listen from your favorites + +More help: + radiocli help --all Browse every command by category + radiocli help play Options and examples for one command + radiocli completion Print a Tab completion script +${all ? '\nListener commands support --json, --plain, --quiet, and --no-input.\nUse radiocli --help for options and examples.\n' : ''} +`.trimEnd()); +} + +function legacyCommandHelp(): Record { return { + check: 'Usage: radiocli check\nShow local store paths, playback backends, and provider health.', + doctor: 'Usage: radiocli doctor [--json] [--playback]\nCheck playback tools without changing settings. Add --playback to rehearse local WAV playback through null/dummy audio.', + update: 'Usage: radiocli update [--install]\nCheck for updates. Add --install to install and repair enabled MCP entries.', + import: 'Usage: radiocli import [station name]\nExample: radiocli import https://example.com/live.mp3 "Example FM"', + export: 'Usage: radiocli export [file]\nExport favorites and imports as M3U (default: radiocli-favorites.m3u).', + 'media-controls': 'Usage: radiocli media-controls [auto|on|off]\nInspect or configure Linux desktop media controls.', + mcp: 'Usage: radiocli mcp \nManage the optional local agent integration.' +}; } + +function suggestCommand(input: string): string | undefined { + const candidates = [...listenerCommands, ...Object.keys(legacyCommandHelp()), 'setup', 'alarm', 'agent', 'help', 'version']; + const distance = (word: string): number => { + let row = Array.from({length: word.length + 1}, (_, index) => index); + for (let i = 1; i <= input.length; i += 1) { + const next = [i]; + for (let j = 1; j <= word.length; j += 1) next[j] = Math.min(next[j - 1]! + 1, row[j]! + 1, row[j - 1]! + (input[i - 1] === word[j - 1] ? 0 : 1)); + row = next; + } + return row[word.length]!; + }; + return candidates.map(word => ({word, distance: distance(word)})).filter(item => item.distance <= (input.length > 3 ? 2 : 1)).sort((a, b) => a.distance - b.distance)[0]?.word; } export function isDirectRun(entryPath: string | undefined, moduleUrl: string): boolean { @@ -275,7 +330,8 @@ function isKnownCommand(command: string): boolean { return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'alarm', 'mcp', 'agent'].includes(command); } -function printSetupHelp(): void { +async function printSetupHelp(): Promise { + const {isAirPlayPlatformSupported} = await import('./player/airplay-policy.js'); console.log(`RadioCLI Setup Usage: @@ -288,99 +344,9 @@ Usage: radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default) 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, +${process.platform === 'linux' && isAirPlayPlatformSupported() ? ' radiocli setup --airplay Enable experimental Linux AirPlay and set up prerequisites\n radiocli setup --no-airplay Disable Linux AirPlay; keep shared services and packages\n' : ''} radiocli setup --package-manager Use brew, winget, scoop, choco, apt, dnf, pacman, apk, zypper, pkg (FreeBSD), pkg_add, pkgin, termux-pkg (Termux), or pkgman (Haiku) `); } - -function printPlaybackBackendStatus(backends: string[]): void { - for (const line of playbackBackendStatusLines(backends)) { - console.log(line); - } -} - -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), - airPlay: { - available: airPlay.available, - safe: airPlay.safe, - package: airPlay.packageName, - version: airPlay.version ?? null, - vulnerablePackages: airPlay.vulnerablePackages, - warningPackages: airPlay.warningPackages - }, - guidance: playbackBackendStatusLines(backends) - }; -} - -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}`); - console.log(`mpv_launch=${diagnostic.launchable ? 'ready' : 'failed'}`); - if (diagnostic.version) console.log(`mpv_version=${diagnostic.version}`); - if (diagnostic.error) console.log(`mpv_error=${diagnostic.error}`); - if (diagnostic.launchable && diagnostic.discovery !== 'path') { - console.log('mpv_hint=RadioCLI found mpv outside PATH and can use it directly; no PATH changes are required.'); - } else if (!diagnostic.path && process.platform === 'win32') { - console.log('mpv_hint=Install with winget, then rerun Doctor; RadioCLI also checks the standard MPV Player install directory.'); - } -} - -function redactDiagnostic(diagnostic: CommandDiagnostic): CommandDiagnostic { - return {...diagnostic, path: redactHome(diagnostic.path)}; -} - -function redactHome(path: string | null): string | null { - if (!path) return null; - const home = process.env.HOME ?? process.env.USERPROFILE; - return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path; -} diff --git a/src/cli/arguments.ts b/src/cli/arguments.ts new file mode 100644 index 0000000..8ad7152 --- /dev/null +++ b/src/cli/arguments.ts @@ -0,0 +1,192 @@ +export const listenerCommands = ['play', 'pause', 'resume', 'stop', 'next', 'previous', 'volume', 'mute', 'unmute', 'now', 'status', 'search', 'countries', 'favorites', 'recent', 'favorite', 'unfavorite', 'preset', 'presets', 'stats', 'completion'] as const; + +export type ListenerCommand = typeof listenerCommands[number]; +export type CliFlags = {json: boolean; plain: boolean; quiet: boolean; noInput: boolean; noColor: boolean}; +export type ParsedCommand = {command: ListenerCommand; words: string[]; flags: CliFlags; options: Map}; + +const commonFlags = ['--json', '--plain', '--quiet', '-q', '--no-input', '--no-color']; +const filterFlags = ['--country', '--language', '--codec', '--min-bitrate', '--limit']; +const commandOptions: Partial> = { + play: ['--favorites', '--recent', '--popular', '--pick', '--source', '--random', '--keep-playing', ...filterFlags], + search: ['--pick', ...filterFlags], + favorites: ['--pick', '--limit'], recent: ['--pick', '--limit'], + countries: ['--limit'], now: ['--watch'], status: ['--watch'] +}; +const booleans = new Set([...commonFlags, '--favorites', '--recent', '--popular', '--pick', '--watch', '--random', '--keep-playing']); + +export function isListenerCommand(value: string): value is ListenerCommand { + return (listenerCommands as readonly string[]).includes(value); +} + +export function parseListenerArgs(command: ListenerCommand, args: string[]): ParsedCommand { + const options = new Map(); + const words: string[] = []; + const allowed = new Set([...commonFlags, ...commandOptions[command] ?? []]); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (arg === '--') { words.push(...args.slice(index + 1)); break; } + if (!arg.startsWith('-')) { words.push(arg); continue; } + const equal = arg.indexOf('='); + const key = equal < 0 ? arg : arg.slice(0, equal); + if (!allowed.has(key)) throw new Error(`Unknown option: ${key}\nRun radiocli ${command} --help.`); + if (options.has(key)) throw new Error(`Option ${key} was supplied more than once.`); + if (booleans.has(key)) { + if (equal >= 0) throw new Error(`${key} does not take a value.`); + options.set(key, true); + } else { + const value = equal >= 0 ? arg.slice(equal + 1) : args[++index]; + if (!value || value.startsWith('--')) throw new Error(`${key} requires a value. Run radiocli ${command} --help.`); + options.set(key, value); + } + } + const flags = {json: options.has('--json'), plain: options.has('--plain'), quiet: options.has('--quiet') || options.has('-q'), noInput: options.has('--no-input'), noColor: options.has('--no-color')}; + if (flags.json && flags.plain) throw new Error('Choose either --json or --plain.'); + if ((options.has('--pick') || options.has('--watch')) && (flags.json || flags.plain || flags.quiet || flags.noInput)) { + throw new Error('--pick and --watch require interactive output. Omit --json, --plain, --quiet, and --no-input.'); + } + const sources = ['--favorites', '--recent', '--popular', '--source'].filter(option => options.has(option)); + if (sources.length > 1) throw new Error('Choose one station queue: --favorites, --recent, --popular, or --source.'); + if (sources[0] && sources[0] !== '--source') options.set('--source', sources[0] === '--favorites' ? 'favorite' : sources[0].slice(2)); + if (options.get('--source') === 'favorites') options.set('--source', 'favorite'); + return {command, words, options, flags}; +} + +export function integerOption(value: string | true | undefined, fallback: number, label: string, minimum: number, maximum: number): number { + if (value === undefined) return fallback; + if (typeof value !== 'string' || !/^\d+$/.test(value) || Number(value) < minimum || Number(value) > maximum) throw new Error(`${label} must be a whole number from ${minimum} through ${maximum}.`); + return Number(value); +} + +const help: Record = { + play: `play [station name|provider:id|preset] [options] + Start background playback, or control the open TUI. With no station, continue + the current station or play your most recent station when nothing is selected. + Paused playback resumes; playing audio continues uninterrupted. + Exact saved names and presets work offline. + Ambiguous names open a numbered chooser only in an interactive terminal. + + --favorites Play from your favorites + --recent Play from your recently played stations + --popular Play from the directory's popular stations + --random Pick randomly from the chosen queue or search + --pick Choose a station by number + --keep-playing Keep an already playing or paused station + --country CODE --language NAME --codec NAME --min-bitrate KBPS + --limit 1-100 Search result count (default 20) + + --source recent|favorites|popular|country also works; favorite is accepted + for compatibility. Explicit queue or filter options select a station anew. + + Examples: + radiocli play jazz + radiocli pause + radiocli play + radiocli play "Groove Salad" + radiocli play --favorites --random + radiocli play --country JP + radiocli play work`, + search: `search [options] + Find stations. Terminal tables mark favorites with *; piped output is TSV. + --pick Choose a result and start background playback + --country CODE Filter by two-letter country code + --language NAME Filter by language + --codec NAME Filter by audio codec + --min-bitrate KBPS Minimum bitrate (0-10000) + --limit 1-100 Maximum results (default 20) + + Examples: + radiocli search jazz --country US --pick + radiocli search jazz --json + radiocli search jazz --plain`, + now: `now [--watch] + Show station, track metadata, output, playback state, and volume. + --watch updates a single terminal line. Ctrl+C exits the view; audio continues. + + Examples: + radiocli now + radiocli now --watch + radiocli now --json`, + status: `status [--watch] + Alias for radiocli now. Use --json for the current playback snapshot.`, + favorites: `favorites [--limit 1-100] [--pick] + List favorite stations, or select one to play with --pick. + Save the active station with radiocli favorite.`, + recent: `recent [--limit 1-100] [--pick] + List recently played stations, newest first. Use --pick to tune one again.`, + favorite: `favorite [station name|provider:id|preset] + Save a station to favorites. With no argument, save the active station. + Example: radiocli favorite "Groove Salad"`, + unfavorite: `unfavorite [station name|provider:id|preset] + Remove a saved favorite. With no argument, use the active station.`, + preset: `preset [name] [station name|provider:id|preset] + save NAME [STATION] saves a shortcut; omit STATION to save the active station. + Saving an existing name replaces its station. Names are case-insensitive, + 1-40 letters, numbers, hyphens, or underscores, starting with a letter/number. + remove NAME removes only the shortcut. Presets are included in library backups. + + Examples: + radiocli preset save work + radiocli preset save jazz "Example FM" + radiocli play work + radiocli preset remove work`, + presets: `presets + List saved station shortcuts. Alias for radiocli preset list.`, + countries: `countries [--limit 1-100] + Show the most popular countries (default 30). + Example: radiocli play --country JP`, + volume: `volume <0-100> + Set playback volume, or save it for the next session when playback is closed. + Example: radiocli volume 40`, + pause: `pause + Pause the active station. Use radiocli play to continue.`, + resume: `resume + Resume paused playback. radiocli play also resumes, and can restart after stop.`, + stop: `stop + Stop ordinary playback and close its background player. Alarms are separate.`, + next: `next + Play the next station in the current search, favorites, or recent queue.`, + previous: `previous + Play the previous station in the current queue.`, + mute: `mute + Mute active playback. Use radiocli unmute to restore sound.`, + unmute: `unmute + Restore sound without changing the saved volume.`, + stats: `stats + Show listening time, sessions, streaks, and your most-listened station. + Example: radiocli stats --json`, + completion: `completion + Print a shell completion script. No shell configuration is changed. + + Bash: source <(radiocli completion bash) + Zsh: source <(radiocli completion zsh) # after compinit + Fish: radiocli completion fish | source + PowerShell: radiocli completion powershell | Out-String | Invoke-Expression + + Add the matching line to your shell profile to enable it in future sessions.` +}; + +export function listenerHelp(command: ListenerCommand): string { + return `RadioCLI · ${command}\n\nUsage: radiocli ${help[command]}\n\nOutput options:\n --json Structured JSON\n --plain Stable tab-separated text without decoration\n --quiet, -q Suppress successful output\n --no-input Never prompt; use an exact station ID for ambiguous matches\n --no-color Disable color\n --help, -h Show this help\n`; +} + +export function completionCandidates(words: string[], presets: string[]): string[] { + const current = words.at(-1) ?? ''; + const command = words[0] ?? ''; + const previous = words.at(-2); + let candidates: string[]; + if (words.length <= 1) candidates = [...listenerCommands, 'help', 'version', 'check', 'doctor', 'setup', 'mcp', 'agent', 'media-controls', 'update', 'import', 'export', 'alarm']; + else if (command === 'help') candidates = ['--all', ...listenerCommands, 'check', 'doctor', 'setup', 'mcp', 'agent', 'media-controls', 'update', 'import', 'export', 'alarm', 'version']; + else if (command === '--help' || command === '-h') candidates = ['--all']; + else if (previous === '--source') candidates = ['recent', 'favorites', 'favorite', 'popular', 'country']; + else if (previous === '--country') candidates = ['US', 'GB', 'DE', 'FR', 'JP', 'CA', 'AU', 'BR', 'IN']; + else if (previous === '--codec') candidates = ['MP3', 'AAC', 'OGG', 'FLAC']; + else if (previous === '--language') candidates = ['english', 'spanish', 'french', 'german', 'japanese']; + else if (previous === '--limit') candidates = ['10', '20', '50', '100']; + else if (previous === '--min-bitrate') candidates = ['64', '128', '192', '320']; + else if (command === 'completion') candidates = ['bash', 'zsh', 'fish', 'powershell']; + else if (command === 'preset' && words.length === 2) candidates = ['save', 'list', 'remove']; + else if (command === 'volume') candidates = ['0', '20', '40', '60', '80', '100']; + else if (isListenerCommand(command)) candidates = [...commonFlags, '--help', ...commandOptions[command] ?? [], ...(['play', 'favorite', 'unfavorite', 'preset'].includes(command) ? presets : [])]; + else candidates = ['--help']; + return [...new Set(candidates)].filter(candidate => candidate.startsWith(current)); +} diff --git a/src/cli/completion.ts b/src/cli/completion.ts new file mode 100644 index 0000000..f15f7f5 --- /dev/null +++ b/src/cli/completion.ts @@ -0,0 +1,40 @@ +export function completionScript(shell: string): string { + if (shell === 'bash') return `# RadioCLI Bash completion +_radiocli_complete() { + local candidate + COMPREPLY=() + while IFS= read -r candidate; do + COMPREPLY+=("$candidate") + done < <(radiocli _complete "\${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null) +} +complete -F _radiocli_complete radiocli +`; + if (shell === 'zsh') return `# RadioCLI Zsh completion (load after compinit) +_radiocli_complete() { + local -a candidates + candidates=("\${(@f)$(radiocli _complete "\${words[@]:1:$((CURRENT - 1))}" 2>/dev/null)}") + compadd -- "\${candidates[@]}" +} +compdef _radiocli_complete radiocli +`; + if (shell === 'fish') return `# RadioCLI Fish completion +function __radiocli_candidates + set -l words (commandline -opc) + set -e words[1] + set -l current (commandline -ct) + radiocli _complete $words "$current" 2>/dev/null +end +complete -c radiocli -f -a '(__radiocli_candidates)' +`; + if (shell === 'powershell') return `# RadioCLI PowerShell completion +Register-ArgumentCompleter -Native -CommandName radiocli -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $words = @($commandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.StartOffset -lt $cursorPosition } | ForEach-Object { $_.Extent.Text }) + if (!$wordToComplete) { $words += '' } + & radiocli _complete @words 2>$null | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } +} +`; + throw new Error('Choose a completion shell: bash, zsh, fish, or powershell. Run radiocli completion --help.'); +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 0000000..655ca29 --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,146 @@ +import {defaultLibraryPath} from '../storage/store.js'; +import {detectPlaybackBackends, playbackBackendStatusLines} from '../player/backend-install.js'; +import {resolveCommand} from '../platform/executables.js'; +import {diagnoseCommand, type CommandDiagnostic} from '../player/command-diagnostics.js'; +import {readSettingsWithoutMigration} from '../storage/read-settings.js'; +import {airPlaySenderHealth} from '../player/airplay-sender-health.js'; +import {appVersion} from '../version.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'; + +export async function runDoctor(rest: string[]): Promise { + const saved = (() => { try { return readSettingsWithoutMigration(defaultLibraryPath()); } catch { return undefined; } })(); + const backends = detectPlaybackBackends({settings: {linuxAirPlayEnabled: saved?.linuxAirPlayEnabled === true}}); + const mpvDiagnostic = diagnoseCommand('mpv'); + const base = await doctorReport(backends, mpvDiagnostic); + const playbackRehearsal = rest.includes('--playback') ? await (await import('../setup-playback.js')).verifyLocalPlayback(backends) : undefined; + const report = {...base, ...(playbackRehearsal ? {playbackRehearsal} : {})}; + if (rest.includes('--json')) { + console.log(JSON.stringify(report, null, 2)); + return; + } + console.log(`backends=${backends.join(',') || 'none'}`); + printMpvDiagnostic(report.mpv); + for (const line of playbackBackendStatusLines(backends)) console.log(line); + for (const [name, capability] of Object.entries(report.capabilities)) { + console.log(`capability_${name}=${capability.status} ${capability.message}`); + } + console.log(`omarchy=${report.omarchy.kind} status=${report.omarchy.status} version=${report.omarchy.version ?? 'unknown'} source=${report.omarchy.source ?? 'none'}`); + if (report.omarchy.session) console.log(`desktop_session=${report.omarchy.session.type} remote=${report.omarchy.session.remote ? 'yes' : 'no'}`); + console.log(`omarchy_launcher=${report.omarchy.terminal.prerequisitesReady ? 'ready' : 'unavailable'} missing=${report.omarchy.terminal.missing.join(',') || 'none'}`); + console.log(`desktop_media_preference=${report.desktopMedia.preference.value} source=${report.desktopMedia.preference.source} status=${report.desktopMedia.preference.status}`); + console.log(`desktop_media_policy=${report.desktopMedia.policy.status} eligible=${report.desktopMedia.policy.eligible ? 'yes' : 'no'} ${report.desktopMedia.policy.message}`); + console.log(`desktop_media_bus=${report.desktopMedia.sessionBus.status} ${report.desktopMedia.sessionBus.message}`); + console.log(`desktop_media_owner=${report.desktopMedia.owner.status} ${report.desktopMedia.owner.message}`); + console.log(`terminal_launcher=${report.terminalLauncher.selected ?? 'none'} prerequisites=${report.terminalLauncher.prerequisites} launch=${report.terminalLauncher.launch} ${report.terminalLauncher.message}`); + console.log(`audio_session=${report.audio.status} backend=${report.audio.backend ?? 'none'} ${report.audio.message}`); + if (playbackRehearsal) { + for (const rehearsal of playbackRehearsal) console.log(`decoder_${rehearsal.backend}=${rehearsal.status} ${rehearsal.message}`); + if (!playbackRehearsal.length) console.log('decoder=unverified No local playback backend is available for a rehearsal.'); + console.log('physical_output=unverified Null/dummy playback does not verify speakers, headphones, receivers, or a live network stream.'); + } + console.log(`airplay_readiness=${report.airPlay.state !== 'enabled' ? report.airPlay.state : report.airPlay.ready ? 'ready' : 'unavailable'} discovery=${report.airPlay.discoveryService} sender=${report.airPlay.senderLoaded ? 'loaded' : 'unavailable'} ${report.airPlay.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}`); +} + +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 {probeNativeIntegrations, probeDoctorScheduler} = await import('../platform/diagnostics.js'); + const native = await probeNativeIntegrations({host, libraryPath: defaultLibraryPath}); + const terminal = resolveTerminalCapabilities(process.env, {isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.()}); + const scheduler = await probeDoctorScheduler(host); + 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, + airPlay: native.airPlay + }); + 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, + omarchy: native.omarchy, + desktopMedia: native.desktopMedia, + terminalLauncher: native.terminalLauncher, + audio: native.audio, + network: networkDiagnostic(), + backends, + commands, + mpv: redactDiagnostic(mpvDiagnostic), + airPlay: { + available: airPlay.available, + safe: airPlay.safe, + package: airPlay.packageName, + version: airPlay.version ?? null, + vulnerablePackages: airPlay.vulnerablePackages, + warningPackages: airPlay.warningPackages, + ...native.airPlay + }, + guidance: playbackBackendStatusLines(backends) + }; +} + +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}`); + console.log(`mpv_launch=${diagnostic.launchable ? 'ready' : 'failed'}`); + if (diagnostic.version) console.log(`mpv_version=${diagnostic.version}`); + if (diagnostic.error) console.log(`mpv_error=${diagnostic.error}`); + if (diagnostic.launchable && diagnostic.discovery !== 'path') { + console.log('mpv_hint=RadioCLI found mpv outside PATH and can use it directly; no PATH changes are required.'); + } else if (!diagnostic.path && process.platform === 'win32') { + console.log('mpv_hint=Install with winget, then rerun Doctor; RadioCLI also checks the standard MPV Player install directory.'); + } +} + +function redactDiagnostic(diagnostic: CommandDiagnostic): CommandDiagnostic { + // Executable banners and spawn errors can include private build paths, URLs, + // or environment values. Keep a bounded version token and actionable status. + const version = diagnostic.version?.slice(0, 256).match(/^mpv\s+(v?\d+(?:\.\d+){1,3}(?:[a-z\d.+-]{0,80})?)(?:\s|$)/i)?.[1]; + const error = diagnostic.error === null ? null : diagnostic.discovery === 'missing' ? 'not found' + : diagnostic.discovery === 'configured-path' ? 'The configured mpv executable could not be run. Check RADIOCLI_MPV_PATH.' + : 'mpv could not be launched. Check the executable and its native runtime dependencies.'; + return {...diagnostic, path: redactHome(diagnostic.path), version: version ? `mpv ${version}` : null, error}; +} + +function redactHome(path: string | null): string | null { + if (!path) return null; + const home = process.env.HOME ?? process.env.USERPROFILE; + return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path; +} diff --git a/src/cli/interactive.test.ts b/src/cli/interactive.test.ts new file mode 100644 index 0000000..d4c74d7 --- /dev/null +++ b/src/cli/interactive.test.ts @@ -0,0 +1,93 @@ +import {EventEmitter} from 'node:events'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {createInterface} from 'node:readline'; +import {pickStation, watchNow} from './interactive.js'; +import {CliOutput} from './output.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import type {Station} from '../types.js'; +import type {RadioSessionStatus} from '../agent/session.js'; + +vi.mock('node:readline', () => ({createInterface: vi.fn()})); +const first: Station = {id: 'first', provider: 'playlist', name: 'First', tags: []}; +const second: Station = {...first, id: 'second', name: 'Second'}; +const flags = {json: false, plain: false, quiet: false, noInput: false, noColor: true}; +const settings = new JsonLibraryStore('/nonexistent-radiocli-interactive-fixture/library.json').snapshot().settings; +const stdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const stdoutTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); +const exitCode = process.exitCode; +let originalSignals: Set; + +beforeEach(() => { + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: true}); + Object.defineProperty(process.stdout, 'isTTY', {configurable: true, value: true}); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + originalSignals = new Set(process.listeners('SIGINT')); +}); +afterEach(() => { + if (stdinTTY) Object.defineProperty(process.stdin, 'isTTY', stdinTTY); else Reflect.deleteProperty(process.stdin, 'isTTY'); + if (stdoutTTY) Object.defineProperty(process.stdout, 'isTTY', stdoutTTY); else Reflect.deleteProperty(process.stdout, 'isTTY'); + process.exitCode = exitCode; + vi.restoreAllMocks(); vi.useRealTimers(); +}); + +describe('inline terminal interactions', () => { + it('aborts an in-flight status request immediately when the view is cancelled', async () => { + const read = vi.fn((signal?: AbortSignal) => new Promise(resolve => { + signal!.addEventListener('abort', () => resolve(null), {once: true}); + })); + const watching = watchNow(read, new CliOutput(flags, settings)); + const signal = process.listeners('SIGINT').find(listener => !originalSignals.has(listener)); + signal!('SIGINT'); + await watching; + expect(read.mock.calls[0]![0]?.aborted).toBe(true); + expect(process.listeners('SIGINT')).not.toContain(signal); + }); + + it('reprompts for invalid numbers and closes after selection', async () => { + const input = new EventEmitter() as EventEmitter & {question: ReturnType; close: ReturnType}; + let answer: (text: string) => void = () => undefined; + input.question = vi.fn((_prompt, callback) => { answer = callback; }); + input.close = vi.fn(() => input.emit('close')); + vi.mocked(createInterface).mockReturnValue(input as unknown as ReturnType); + const selection = pickStation([first, second], new CliOutput(flags, settings), []); + answer('3'); + expect(input.question).toHaveBeenCalledTimes(2); + answer('2'); + await expect(selection).resolves.toEqual(second); + expect(input.close).toHaveBeenCalledOnce(); + }); + + it.each(['SIGINT', 'close'])('cancels on %s without selecting a station', async event => { + const input = new EventEmitter() as EventEmitter & {question: ReturnType; close: ReturnType}; + input.question = vi.fn(); input.close = vi.fn(); + vi.mocked(createInterface).mockReturnValue(input as unknown as ReturnType); + const selection = pickStation([first], new CliOutput(flags, settings), []); + input.emit(event); + await expect(selection).rejects.toMatchObject({name: 'AbortError'}); + expect(input.close).toHaveBeenCalledOnce(); + }); + + it('updates one line only when playback changes and cleans up on Ctrl+C', async () => { + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const status: RadioSessionStatus = {owner: 'headless', station: first, queue: [first], playback: {backend: 'mpv', state: 'playing', volume: 40, muted: false, ready: true}}; + const read = vi.fn().mockResolvedValue(status); + const output = new CliOutput(flags, settings, {isTTY: true, columns: 100}, {TERM: 'xterm'}); + const watching = watchNow(read, output); + const signal = process.listeners('SIGINT').find(listener => !originalSignals.has(listener)); + try { + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2), {timeout: 1500}); + expect(write).toHaveBeenCalledTimes(1); + read.mockResolvedValue({...status, nowPlaying: {title: 'New song', updatedAt: '2030-01-01T00:00:00Z'}}); + await vi.waitFor(() => expect(write).toHaveBeenLastCalledWith(expect.stringContaining('New song')), {timeout: 1500}); + expect(signal).toBeDefined(); + } finally { + signal?.('SIGINT'); + await watching; + } + expect(write).toHaveBeenLastCalledWith('\n'); + expect(process.listeners('SIGINT')).not.toContain(signal); + expect(process.exitCode).toBe(130); + expect(write.mock.calls.map(call => String(call[0])).join('')).not.toContain('\u001b[?1049'); + }); +}); diff --git a/src/cli/interactive.ts b/src/cli/interactive.ts new file mode 100644 index 0000000..4643e40 --- /dev/null +++ b/src/cli/interactive.ts @@ -0,0 +1,70 @@ +import {createInterface} from 'node:readline'; +import {setTimeout as delay} from 'node:timers/promises'; +import type {Station} from '../types.js'; +import type {RadioSessionStatus} from '../agent/session.js'; +import {CliOutput} from './output.js'; + +export function requireInteractive(action: '--pick' | '--watch'): void { + if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error(`${action} needs an interactive terminal. Use --json or --plain for scripts, and radiocli play to select a station directly.`); +} + +/** A small line-mode chooser preserves scrollback and ordinary terminal editing. */ +export async function pickStation(stations: Station[], output: CliOutput, favorites: Station[]): Promise { + requireInteractive('--pick'); + if (!stations.length) throw new Error('No stations found. Try a broader search or remove a filter.'); + output.stations(stations, favorites, undefined, false); + const input = createInterface({input: process.stdin, output: process.stderr}); + return new Promise((resolve, reject) => { + let settled = false; + const cancel = () => { + if (settled) return; + settled = true; + reject(Object.assign(new Error('Selection cancelled.'), {name: 'AbortError'})); + input.close(); + }; + input.once('SIGINT', cancel); + input.once('close', cancel); + const ask = () => input.question(`Play station [1-${stations.length}], or q to cancel: `, answer => { + if (answer.trim().toLowerCase() === 'q' || !answer.trim()) { cancel(); return; } + const index = /^\d+$/.test(answer.trim()) ? Number(answer.trim()) - 1 : -1; + const station = stations[index]; + if (!station) { process.stderr.write(`Choose a number from 1 through ${stations.length}.\n`); ask(); return; } + settled = true; + input.close(); + resolve(station); + }); + ask(); + }); +} + +export async function watchNow(read: (signal?: AbortSignal) => Promise, output: CliOutput): Promise { + requireInteractive('--watch'); + const controller = new AbortController(); + const interrupt = () => { process.exitCode = 130; controller.abort(); }; + const terminate = () => { process.exitCode = 143; controller.abort(); }; + process.once('SIGINT', interrupt); + process.once('SIGTERM', terminate); + const rewrite = output.terminal.interactive && !output.terminal.screenReader; + let rendered = false; + let previous = ''; + try { + process.stderr.write('Watching playback. Ctrl+C exits this view; audio continues.\n'); + while (!controller.signal.aborted) { + const status = await read(controller.signal); + if (controller.signal.aborted) break; + const line = output.fit(output.nowLine(status), Math.max(1, (process.stdout.columns || 100) - 1)); + if (!rendered || line !== previous) { + process.stdout.write(rewrite ? `\r\u001b[2K${line}` : `${line}\n`); + rendered = true; + previous = line; + } + await delay(1000, undefined, {signal: controller.signal}); + } + } catch (error) { + if (!controller.signal.aborted) throw error; + } finally { + process.removeListener('SIGINT', interrupt); + process.removeListener('SIGTERM', terminate); + if (rendered && rewrite) process.stdout.write('\n'); + } +} diff --git a/src/cli/listener.test.ts b/src/cli/listener.test.ts new file mode 100644 index 0000000..782d324 --- /dev/null +++ b/src/cli/listener.test.ts @@ -0,0 +1,342 @@ +import {existsSync, mkdtempSync, readFileSync, 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 {runCommand} from '../cli.js'; +import {JsonLibraryStore, stationKey} from '../storage/store.js'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {AgentRadioService} from '../agent/service.js'; +import {launchHeadlessHost, launchRadioTui} from '../agent/launcher.js'; +import {startRadioSession, type RadioSessionCommand, type RadioSessionStatus} from '../agent/session.js'; +import {startActiveAlarmSession} from '../alarms/active-session.js'; +import type {Station} from '../types.js'; +import {listenerCommands} from './arguments.js'; + +vi.mock('../agent/launcher.js', () => ({launchHeadlessHost: vi.fn(), launchRadioTui: vi.fn()})); + +const first: Station = {id: 'first', provider: 'playlist', name: 'Evening Jazz', tags: ['jazz'], country: 'United States', countryCode: 'US', language: 'english', codec: 'MP3', bitrate: 128, streamUrl: 'https://example.com/first'}; +const second: Station = {...first, id: 'second', name: 'Midnight Jazz', bitrate: 256}; +const remote: Station = {...first, id: 'remote-id', provider: 'radio-browser', name: 'Remote Jazz'}; +const runtime = {nodePath: process.execPath, cliPath: '/radiocli.js'}; +let root: string; +let logs: string[]; +let errors: string[]; +let store: JsonLibraryStore; +let close: (() => Promise) | undefined; +let seen: {command: RadioSessionCommand; source?: string}[]; +let status: RadioSessionStatus; +const stdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const stdoutTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); +const initialExitCode = process.exitCode; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'radiocli-listener-')); + vi.stubEnv('RADIOCLI_HOME', root); + vi.stubEnv('RADIOCLI_DESKTOP_MEDIA', 'off'); + Object.defineProperty(process.stdin, 'isTTY', {configurable: true, value: false}); + Object.defineProperty(process.stdout, 'isTTY', {configurable: true, value: false}); + store = new JsonLibraryStore(); + logs = []; errors = []; seen = []; + status = {owner: 'headless', station: null, queue: [], playback: {backend: 'mpv', state: 'idle', ready: false, volume: 70, muted: false}}; + vi.spyOn(console, 'log').mockImplementation(value => { logs.push(String(value)); }); + vi.spyOn(process.stderr, 'write').mockImplementation(value => { errors.push(String(value)); return true; }); + vi.spyOn(ProviderManager.prototype, 'search').mockResolvedValue([remote]); + vi.spyOn(ProviderManager.prototype, 'popular').mockResolvedValue([first, second]); + vi.spyOn(ProviderManager.prototype, 'byCountry').mockResolvedValue([first, second]); + vi.spyOn(ProviderManager.prototype, 'byId').mockResolvedValue(remote); + vi.spyOn(ProviderManager.prototype, 'countries').mockResolvedValue([{name: 'United States', code: 'US', stationCount: 12}]); + vi.spyOn(ProviderManager.prototype, 'vote').mockResolvedValue(true); +}); + +afterEach(async () => { + await close?.(); close = undefined; + process.exitCode = initialExitCode; + if (stdinTTY) Object.defineProperty(process.stdin, 'isTTY', stdinTTY); else Reflect.deleteProperty(process.stdin, 'isTTY'); + if (stdoutTTY) Object.defineProperty(process.stdout, 'isTTY', stdoutTTY); else Reflect.deleteProperty(process.stdout, 'isTTY'); + vi.restoreAllMocks(); vi.clearAllMocks(); vi.unstubAllEnvs(); + rmSync(root, {recursive: true, force: true}); +}); + +async function session(ok = true): Promise { + const host = await startRadioSession(async (command, source) => { + seen.push({command, source}); + if (command.type === 'play') status = {...status, station: command.station, queue: command.queue ?? [], playback: {...status.playback, state: 'playing', ready: true}}; + if (command.type === 'pause' && ok) status = {...status, playback: {...status.playback, state: 'paused'}}; + if (command.type === 'resume' && ok) status = {...status, playback: {...status.playback, state: 'playing'}}; + if (command.type === 'set-volume') status = {...status, playback: {...status.playback, volume: command.volume}}; + return {ok: command.type === 'status' || ok, message: ok ? 'Playback updated.' : 'This output cannot pause.', status}; + }); + close = host.close; +} + +describe('listener command workflows', () => { + it.each(listenerCommands)('%s help needs no library, directory, player, or MCP setup', async command => { + await runCommand([command, '--help']); + expect(logs.join('\n')).toContain('Usage: radiocli'); + expect(existsSync(join(root, 'radiocli.json'))).toBe(false); + expect(ProviderManager.prototype.search).not.toHaveBeenCalled(); + expect(launchHeadlessHost).not.toHaveBeenCalled(); + }); + + it('passes search filters through and preserves the existing piped search columns', async () => { + await runCommand(['search', 'late night', 'jazz', '--country=us', '--codec', 'MP3', '--language', 'english', '--min-bitrate', '128', '--limit', '5']); + expect(ProviderManager.prototype.search).toHaveBeenCalledWith('late night jazz', expect.anything(), {limit: 5, countryCode: 'US', language: 'english', codec: 'MP3', minBitrate: 128}); + expect(logs).toEqual(['radio-browser:remote-id\tRemote Jazz\tUnited States\tMP3\t128']); + }); + + it('returns one JSON document with favorite markers and a stable empty array', async () => { + store.toggleFavorite(remote); + await runCommand(['search', 'jazz', '--json']); + expect(JSON.parse(logs.join('\n'))).toMatchObject([{id: 'radio-browser:remote-id', favorite: true, bitrate: 128}]); + logs = []; + vi.mocked(ProviderManager.prototype.search).mockResolvedValueOnce([]); + await runCommand(['search', 'missing', '--json']); + expect(logs).toEqual(['[]']); + expect(errors).toEqual([]); + }); + + it.each([ + ['search', 'jazz', '--limt', '3'], ['search', 'jazz', '--limit', '2.5'], + ['search', 'jazz', '--limit', '101'], ['search', 'jazz', '--country', 'USA'], + ['search', 'jazz', '--json', '--plain'], ['search', 'jazz', '--limit'], + ['search', 'jazz', '--pick'], ['now', '--watch'], ['pause', 'extra'], + ['play', '--favorites', '--recent'], ['play', '--popular', '--source', 'recent'], + ['play', 'jazz', '--favorites'], ['play', '--favorites=yes'] + ])('rejects invalid or noninteractive input before playback: %s', async (...args) => { + await expect(runCommand(args)).rejects.toThrow(); + expect(launchHeadlessHost).not.toHaveBeenCalled(); + expect(ProviderManager.prototype.search).not.toHaveBeenCalled(); + }); + + it('plays saved names through the existing session while agents remain disabled', async () => { + store.addImported([first, second]); + await session(); + await runCommand(['play', 'evening jazz', '--json']); + expect(seen.find(item => item.command.type === 'play')).toMatchObject({source: 'cli', command: {station: first, openNowPlaying: false}}); + expect(ProviderManager.prototype.search).not.toHaveBeenCalled(); + expect(launchRadioTui).not.toHaveBeenCalled(); + expect(new JsonLibraryStore().snapshot().settings.agentControl?.enabled).toBe(false); + await expect(new AgentRadioService(runtime).control({type: 'pause'})).rejects.toThrow('disabled'); + }); + + it.each(['custom-4bf0e7aa', 'playlist:custom-4bf0e7aa'])('plays a migrated 0.2.4 stream by its old ID: %s', async id => { + store.addImported([{...first, id: 'custom-4bf0e7aa', streamUrl: 'https://example.test/Aa'}]); + const migrated = new JsonLibraryStore().snapshot().imported[0]!; + expect(migrated.id).toMatch(/^custom-v2-/); + await session(); + await runCommand(['play', id, '--no-input']); + expect(status.station).toEqual(migrated); + expect(ProviderManager.prototype.search).not.toHaveBeenCalled(); + expect(ProviderManager.prototype.byId).not.toHaveBeenCalled(); + }); + + it('requires a current ID when two migrated streams share an old ID', async () => { + store.addImported(['Aa', 'BB'].map(path => ({...first, id: 'custom-4bf0e7aa', streamUrl: `https://example.test/${path}`}))); + await session(); + await expect(runCommand(['play', 'playlist:custom-4bf0e7aa', '--no-input'])).rejects.toThrow('Several stations match'); + expect(seen.some(item => item.command.type === 'play')).toBe(false); + expect(ProviderManager.prototype.byId).not.toHaveBeenCalled(); + }); + + it('starts a detached host when no player exists and returns a reusable session', async () => { + store.addImported([first]); + vi.mocked(launchHeadlessHost).mockImplementationOnce(async () => session()); + await runCommand(['play', stationKey(first), '--quiet']); + await runCommand(['volume', '40', '--quiet']); + expect(launchHeadlessHost).toHaveBeenCalledOnce(); + expect(launchRadioTui).not.toHaveBeenCalled(); + expect(status.playback.volume).toBe(40); + expect(logs).toEqual([]); + }); + + it('uses play to resume the current station while preserving its queue', async () => { + store.addRecent(remote); + await session(); + status = {...status, station: second, queue: [first, second], playback: {...status.playback, state: 'paused'}}; + await runCommand(['play', '--json']); + expect(seen.filter(item => item.command.type !== 'status')).toEqual([{source: 'cli', command: {type: 'resume'}}]); + expect(JSON.parse(logs.join('\n')).status).toMatchObject({station: second, queue: [first, second], playback: {state: 'playing'}}); + expect(ProviderManager.prototype.search).not.toHaveBeenCalled(); + expect(launchHeadlessHost).not.toHaveBeenCalled(); + }); + + it.each(['playing', 'loading', 'paused'] as const)('keeps an active %s station without retuning when no change is needed', async state => { + store.addRecent(remote); + await session(); + status = {...status, station: second, queue: [first, second], playback: {...status.playback, state}}; + await runCommand(['play', '--quiet', ...(state === 'paused' ? ['--keep-playing'] : [])]); + expect(seen.every(item => item.command.type === 'status')).toBe(true); + expect(status).toMatchObject({station: second, queue: [first, second], playback: {state}}); + expect(logs).toEqual([]); + }); + + it('starts the most recent station when play has no active session', async () => { + store.addRecent(first); store.addRecent(second); + vi.mocked(launchHeadlessHost).mockImplementationOnce(async () => session()); + await runCommand(['play']); + expect(status).toMatchObject({station: second, queue: [second, first], playback: {state: 'playing'}}); + expect(launchHeadlessHost).toHaveBeenCalledOnce(); + }); + + it.each(['stopped', 'error'] as const)('restarts the selected station and its queue after playback is %s', async state => { + store.addRecent(remote); + await session(); + status = {...status, station: second, queue: [first, second], playback: {...status.playback, state}}; + await runCommand(['play']); + expect(status).toMatchObject({station: second, queue: [first, second], playback: {state: 'playing'}}); + }); + + it('offers a direct listening command when there is nothing to continue', async () => { + await expect(runCommand(['play'])).rejects.toThrow('Start with radiocli play "station name".'); + expect(ProviderManager.prototype.search).not.toHaveBeenCalled(); + expect(launchHeadlessHost).not.toHaveBeenCalled(); + }); + + it.each([ + ['--favorites'], ['--recent'], ['--popular'], ['--source', 'favorites'], ['--source', 'favorite'] + ])('selects an explicit queue with %s even while another station is active', async (...options) => { + store.toggleFavorite(second); store.toggleFavorite(first); + store.addRecent(second); store.addRecent(first); + await session(); + status = {...status, station: remote, queue: [remote], playback: {...status.playback, state: 'paused'}}; + await runCommand(['play', ...options]); + expect(status).toMatchObject({station: first, queue: [first, second], playback: {state: 'playing'}}); + expect(seen.some(item => item.command.type === 'resume')).toBe(false); + }); + + it('combines the favorites shortcut with filters and random selection', async () => { + store.toggleFavorite(first); store.toggleFavorite(second); + await session(); + await runCommand(['play', '--favorites', '--country', 'US', '--min-bitrate', '192', '--random']); + expect(status).toMatchObject({station: second, queue: [second]}); + expect(ProviderManager.prototype.search).not.toHaveBeenCalled(); + expect(ProviderManager.prototype.byCountry).not.toHaveBeenCalled(); + }); + + it('honors a filter without resuming an unrelated active station', async () => { + store.addRecent(first); store.addRecent(second); + await session(); + status = {...status, station: remote, queue: [remote], playback: {...status.playback, state: 'paused'}}; + await runCommand(['play', '--min-bitrate', '192']); + expect(status).toMatchObject({station: second, queue: [second]}); + }); + + it('resolves a unique remote result and rejects ambiguous names in scripts', async () => { + await session(); + await runCommand(['play', 'remote', '--no-input']); + expect(status.station).toEqual(remote); + vi.mocked(ProviderManager.prototype.search).mockResolvedValue([first, second]); + const previousPlays = seen.filter(item => item.command.type === 'play').length; + await expect(runCommand(['play', 'jazz', '--no-input'])).rejects.toThrow(/Several stations match[\s\S]*playlist:first[\s\S]*playlist:second/); + expect(seen.filter(item => item.command.type === 'play')).toHaveLength(previousPlays); + }); + + it('preserves discovery order so next follows the selected station in its queue', async () => { + await session(); + vi.mocked(ProviderManager.prototype.search).mockResolvedValue([first, second, remote]); + await runCommand(['play', 'midnight jazz']); + expect(status.station).toEqual(second); + expect(status.queue).toEqual([first, second, remote]); + }); + + it('lists library stations and saves, plays, completes, and removes named presets', async () => { + store.addImported([first]); + await runCommand(['preset', 'save', 'Work', 'Evening Jazz', '--json']); + expect(new JsonLibraryStore().snapshot().presets).toEqual([{name: 'work', station: first}]); + logs = []; + await runCommand(['_complete', 'play', 'wo']); + expect(logs).toEqual(['work']); + await session(); + await runCommand(['play', 'work']); + expect(status.station).toEqual(first); + await runCommand(['preset', 'save', 'current']); + expect(new JsonLibraryStore().snapshot().presets?.some(item => item.name === 'current')).toBe(true); + await runCommand(['preset', 'remove', 'work']); + logs = []; + await runCommand(['presets', '--json']); + expect(JSON.parse(logs.join('\n'))).toEqual([{name: 'current', stationId: 'playlist:first', stationName: first.name}]); + }); + + it('keeps concurrent playback writes and backup round trips from erasing presets', () => { + const stalePlayer = new JsonLibraryStore(); + store.savePreset('work', first); + stalePlayer.addRecent(second); + const refreshed = new JsonLibraryStore(); + refreshed.savePreset('night', second); + store.removePreset('work'); + const library = new JsonLibraryStore(); + expect(library.snapshot().presets).toEqual([{name: 'night', station: second}]); + expect(library.snapshot().recent[0]?.station).toEqual(second); + const backup = library.exportBackup(join(root, 'backup.json')); + expect(readFileSync(backup, 'utf8')).toContain('"night"'); + library.savePreset('extra', first); + library.importBackup(backup); + expect(library.snapshot().presets).toEqual([{name: 'night', station: second}]); + }); + + it('favorites the current station through the player, and can save a station while stopped', async () => { + store.addImported([first]); + await runCommand(['favorite', 'evening jazz', '--quiet']); + expect(new JsonLibraryStore().snapshot().favorites).toEqual([first]); + await runCommand(['unfavorite', 'evening jazz', '--quiet']); + expect(new JsonLibraryStore().snapshot().favorites).toEqual([]); + await session(); + status = {...status, station: first}; + await runCommand(['favorite']); + expect(seen.at(-1)).toMatchObject({source: 'cli', command: {type: 'set-favorite', favorite: true, station: first}}); + }); + + it('reports metadata and output, and saves volume without starting playback', async () => { + await runCommand(['volume', '40', '--quiet']); + expect(new JsonLibraryStore().snapshot().settings.volume).toBe(40); + expect(launchHeadlessHost).not.toHaveBeenCalled(); + await session(); + status = {...status, station: first, nowPlaying: {title: 'Artist - Track', updatedAt: '2030-01-01T00:00:00Z'}, playback: {...status.playback, state: 'paused', muted: true, airPlayDeviceName: 'Office'}}; + await runCommand(['now', '--json']); + expect(JSON.parse(logs.join('\n'))).toMatchObject({station: first, nowPlaying: {title: 'Artist - Track'}, playback: {state: 'paused', airPlayDeviceName: 'Office'}}); + }); + + it('preserves backend failures and returns a failing exit code in JSON mode', async () => { + await session(false); + await expect(runCommand(['pause'])).rejects.toThrow('cannot pause'); + await runCommand(['pause', '--json']); + expect(JSON.parse(logs.join('\n')).ok).toBe(false); + expect(process.exitCode).toBe(1); + }); + + it.each([false, true])('does not start or resume ordinary playback over an active alarm (paused: %s)', async paused => { + store.addImported([first]); + if (paused) { + await session(); + status = {...status, station: first, queue: [first], playback: {...status.playback, state: 'paused'}}; + } + const alarm = await startActiveAlarmSession({alarmId: 'alarm', scheduledAt: '2030-01-01T08:00:00Z', stationName: first.name, station: first, startedAt: '2030-01-01T08:00:01Z', state: 'playing'}, {onDismiss: vi.fn(), onSnooze: vi.fn(), onKeepPlaying: vi.fn(), onHandoff: vi.fn()}); + try { + await expect(runCommand(paused ? ['play'] : ['play', 'Evening Jazz'])).rejects.toThrow('alarm is currently active'); + expect(launchHeadlessHost).not.toHaveBeenCalled(); + expect(seen.every(item => item.command.type === 'status')).toBe(true); + } finally { await alarm.close(); } + }); + + it('suggests a misspelled command without executing it', async () => { + await expect(runCommand(['paly'])).rejects.toThrow('Did you mean radiocli play?'); + expect(launchHeadlessHost).not.toHaveBeenCalled(); + }); + + it('does not repair or rewrite a damaged library during shell completion', async () => { + const path = join(root, 'radiocli.json'); + writeFileSync(path, '{unfinished'); + await runCommand(['_complete', 'play', '--co']); + expect(readFileSync(path, 'utf8')).toBe('{unfinished'); + expect(logs).toEqual(['--country\n--codec']); + expect(errors).toEqual([]); + }); + + it.each([ + [['play', '--fav'], '--favorites'], [['help', '--'], '--all'], [['help', 'pl'], 'play'] + ] as const)('completes the simpler playback and help forms: %s', async (words, expected) => { + await runCommand(['_complete', ...words]); + expect(logs).toEqual([expected]); + expect(existsSync(join(root, 'radiocli.json'))).toBe(false); + }); +}); diff --git a/src/cli/listener.ts b/src/cli/listener.ts new file mode 100644 index 0000000..928ae9b --- /dev/null +++ b/src/cli/listener.ts @@ -0,0 +1,184 @@ +import {randomInt} from 'node:crypto'; +import {AgentRadioService, type AgentRuntime} from '../agent/service.js'; +import {connectRadioSession, type RadioSessionResult, type RadioSessionStatus} from '../agent/session.js'; +import {connectActiveAlarms} from '../alarms/active-session.js'; +import {JsonLibraryStore, stationKey} from '../storage/store.js'; +import {matchesStationId} from '../storage/station-identity.js'; +import {ProviderManager} from '../providers/provider-manager.js'; +import {computeListeningStats} from '../activity/stats.js'; +import type {SearchOptions, Station} from '../types.js'; +import {integerOption, parseListenerArgs, type ListenerCommand, type ParsedCommand} from './arguments.js'; +import {CliOutput, clean} from './output.js'; +import {pickStation, requireInteractive, watchNow} from './interactive.js'; +import {completionScript} from './completion.js'; + +export async function runListenerCommand(command: ListenerCommand, args: string[], runtime: AgentRuntime): Promise { + const parsed = parseListenerArgs(command, args); + const {words, flags, options} = parsed; + if (command === 'completion') { + if (words.length !== 1) throw new Error('Usage: radiocli completion '); + const script = completionScript(words[0]!); + if (!flags.quiet) console.log(flags.json ? JSON.stringify(script) : script); + return; + } + if (options.has('--pick')) requireInteractive('--pick'); + if (options.has('--watch')) requireInteractive('--watch'); + const store = new JsonLibraryStore(); + const state = store.snapshot(); + const providers = new ProviderManager(); + const service = new AgentRadioService(runtime, store, providers, 'cli'); + const output = new CliOutput(flags, state.settings); + const query = words.join(' ').trim(); + const filters = searchOptions(parsed); + const noWords = () => { if (words.length) throw new Error(`Unexpected argument: ${clean(words[0])}\nRun radiocli ${command} --help.`); }; + const finish = (result: Pick & Partial>, background = false) => { + if (!result.ok && (output.mode !== 'json' || flags.quiet)) throw new Error(result.message); + output.result(result, background); + if (!result.ok) process.exitCode = 1; + }; + const select = async (stations: Station[], forcePick = options.has('--pick')): Promise => { + if (!stations.length) throw new Error('No stations found. Try radiocli search with a broader query, or radiocli favorites.'); + if (stations.length === 1 && !forcePick) return stations[0]!; + if (process.stdin.isTTY && process.stdout.isTTY && output.mode === 'human' && !flags.noInput && !flags.quiet) return pickStation(stations, output, state.favorites); + const target = command === 'preset' ? 'preset save ' : command === 'search' ? 'play' : command; + throw new Error(`Several stations match. Use an exact provider:id with radiocli ${target}:\n${stations.slice(0, 20).map(station => ` ${clean(stationKey(station))} ${clean(station.name)}`).join('\n')}`); + }; + const saved = uniqueStations([...state.favorites, ...state.imported, ...state.recent.map(item => item.station), ...(state.presets ?? []).map(preset => preset.station)]); + const resolve = async (text: string): Promise<{station: Station; queue: Station[]}> => { + if (/^https?:\/\//i.test(text)) throw new Error('Import a stream first with radiocli import , then play its saved name or ID.'); + const preset = (state.presets ?? []).find(item => item.name === normalize(text)); + const exactIds = saved.filter(station => matchesStationId(station, text)); + if (preset || exactIds.length) { + const station = preset?.station ?? await select(exactIds); + if (!matchesFilters(station, filters)) throw new Error('The selected saved station does not match those filters. Remove the filters or search for another station.'); + return {station, queue: [station]}; + } + if (/^(radio-browser|radio-garden|playlist):/.test(text)) { + const split = text.indexOf(':'); + const station = await providers.byId(text.slice(0, split) as Station['provider'], text.slice(split + 1)); + if (!station) throw new Error(`Station not found: ${clean(text)}. Use radiocli search to find a current ID, or import this station into your library.`); + if (!matchesFilters(station, filters)) throw new Error('The station does not match those filters.'); + return {station, queue: [station]}; + } + const matching = saved.filter(station => matchesFilters(station, filters)); + const exact = matching.filter(station => normalize(station.name) === normalize(text)); + const local = exact.length ? exact : matching.filter(station => normalize(text).split(/\s+/).every(word => normalize(station.name).includes(word))); + const stations = local.length ? local : await providers.search(text, state.settings, filters); + const remoteExact = stations.filter(station => normalize(station.name) === normalize(text)); + const candidates = remoteExact.length && !options.has('--pick') ? remoteExact : stations; + const station = options.has('--random') && stations.length ? stations[randomInt(stations.length)]! : await select(candidates); + return {station, queue: stations}; + }; + const currentStation = async (): Promise => { + const station = (await readStatus())?.station; + if (!station) throw new Error('No station is playing. Supply a station name, or start playback with radiocli play "station name".'); + return station; + }; + + if (command === 'now' || command === 'status') { + noWords(); + if (options.has('--watch')) await watchNow(readStatus, output); + else output.now(await readStatus()); + return; + } + if (command === 'play') { + if (options.has('--random') && options.has('--pick')) throw new Error('Choose either --random or --pick.'); + if (query && options.has('--source')) throw new Error('Choose a station name or a station queue (--favorites, --recent, --popular, or --source).'); + const selectingStation = query || ['--source', '--random', '--pick', '--country', '--language', '--codec', '--min-bitrate', '--limit'].some(option => options.has(option)); + const active = selectingStation ? null : await readStatus(); + if (active?.station && ['paused', 'playing', 'loading'].includes(active.playback.state)) { + if ((await connectActiveAlarms()).length) throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.'); + if (active.playback.state === 'paused' && !options.has('--keep-playing')) finish(await service.control({type: 'resume'}, false), true); + else finish({ok: true, message: `Kept current station ${active.station.name} (${active.playback.state}).`, status: active}, true); + return; + } + let selection: {station: Station; queue: Station[]}; + if (active?.station) selection = {station: active.station, queue: active.queue}; + else if (query) selection = await resolve(query); + else { + const source = options.get('--source') ?? (filters.countryCode ? 'country' : 'recent'); + if (!['recent', 'favorite', 'popular', 'country'].includes(String(source))) throw new Error('--source must be recent, favorites, popular, or country (favorite is also accepted).'); + if (source === 'country' && !filters.countryCode) throw new Error('Use --country with a two-letter code, for example radiocli play --country JP.'); + let queue = source === 'favorite' ? state.favorites : source === 'recent' ? state.recent.map(item => item.station) : source === 'country' ? await providers.byCountry(filters.countryCode!, 100) : await providers.popular(100); + queue = queue.filter(station => matchesFilters(station, filters)).slice(0, filters.limit); + if (!queue.length) throw new Error(`No ${source === 'favorite' ? 'favorite' : source} stations match. Start with radiocli play "station name".`); + const station = options.has('--pick') ? await select(queue, true) : queue[options.has('--random') ? randomInt(queue.length) : 0]!; + selection = {station, queue}; + } + finish(await service.playStation(selection.station, selection.queue, {openUi: false, ifPlaying: options.has('--keep-playing') ? 'keep' : 'replace'}), true); + return; + } + if (['pause', 'resume', 'stop', 'next', 'previous', 'mute', 'unmute', 'volume'].includes(command)) { + if (command === 'volume') { + if (words.length !== 1) throw new Error('Usage: radiocli volume <0-100>'); + finish(await service.control({type: 'set-volume', volume: integerOption(words[0], 70, 'Volume', 0, 100)}, false)); + } else { + noWords(); + const control = command === 'mute' || command === 'unmute' ? {type: 'set-muted' as const, muted: command === 'mute'} : {type: command as 'pause' | 'resume' | 'stop' | 'next' | 'previous'}; + finish(await service.control(control, false)); + } + return; + } + if (command === 'search' || command === 'favorites' || command === 'recent') { + if (command === 'search' && !query) throw new Error('Usage: radiocli search [--country CODE] [--pick]\nExample: radiocli search jazz --pick'); + if (command !== 'search') noWords(); + const stations = command === 'search' ? await providers.search(query, state.settings, filters) : (command === 'favorites' ? state.favorites : state.recent.map(item => item.station)).slice(0, filters.limit); + if (options.has('--pick')) finish(await service.playStation(await select(stations, true), stations, {openUi: false}), true); + else output.stations(stations, state.favorites, command === 'favorites' ? 'No favorites yet. Tune a station, then run radiocli favorite.' : command === 'recent' ? 'No recent stations yet. Start with radiocli play jazz.' : undefined); + return; + } + if (command === 'countries') { noWords(); output.countries(await providers.countries(integerOption(options.get('--limit'), 30, '--limit', 1, 100))); return; } + if (command === 'favorite' || command === 'unfavorite') { + finish(await service.favoriteStation(query ? (await resolve(query)).station : await currentStation(), command === 'favorite')); + return; + } + if (command === 'preset' || command === 'presets') { + if (command === 'presets') noWords(); + const action = command === 'presets' ? 'list' : words[0] ?? 'list'; + if (action === 'list') { + if (words.length > 1) throw new Error('Usage: radiocli preset list'); + output.presets(state.presets ?? []); + } else if (action === 'save') { + const name = words[1]; + if (!name) throw new Error('Usage: radiocli preset save [station name|provider:id]'); + // Validate the shortcut before any directory request or selection prompt. + if (!/^[a-z0-9][a-z0-9_-]{0,39}$/i.test(name)) throw new Error('Preset names must be 1-40 letters, numbers, hyphens, or underscores, starting with a letter or number.'); + const station = words.length > 2 ? (await resolve(words.slice(2).join(' '))).station : await currentStation(); + store.savePreset(name, station); + finish({ok: true, message: `Saved preset ${name.toLowerCase()}: ${station.name}. Play it with radiocli play ${name.toLowerCase()}.`}); + } else if (action === 'remove') { + if (words.length !== 2) throw new Error('Usage: radiocli preset remove '); + store.removePreset(words[1]!); + finish({ok: true, message: `Removed preset ${words[1]!.toLowerCase()}.`}); + } else throw new Error('Usage: radiocli preset . Run radiocli preset --help for examples.'); + return; + } + if (command === 'stats') { noWords(); output.stats(computeListeningStats(state.activity.sessions)); } +} + +async function readStatus(signal?: AbortSignal): Promise { + const client = await connectRadioSession(undefined, {source: 'cli', signal}); + return client ? client.status() : null; +} + +function searchOptions({options}: ParsedCommand): SearchOptions { + const countryCode = options.get('--country'); + if (countryCode && (typeof countryCode !== 'string' || !/^[a-z]{2}$/i.test(countryCode))) throw new Error('--country requires a two-letter code, for example US, GB, or JP.'); + return { + limit: integerOption(options.get('--limit'), 20, '--limit', 1, 100), + countryCode: typeof countryCode === 'string' ? countryCode.toUpperCase() : undefined, + language: options.get('--language') as string | undefined, + codec: options.get('--codec') as string | undefined, + minBitrate: options.has('--min-bitrate') ? integerOption(options.get('--min-bitrate'), 0, '--min-bitrate', 0, 10000) : undefined + }; +} + +function matchesFilters(station: Station, options: SearchOptions): boolean { + return (!options.countryCode || station.countryCode?.toUpperCase() === options.countryCode) + && (!options.language || normalize(station.language ?? '').includes(normalize(options.language))) + && (!options.codec || normalize(station.codec ?? '') === normalize(options.codec)) + && (options.minBitrate === undefined || (station.bitrate ?? 0) >= options.minBitrate); +} + +function uniqueStations(stations: Station[]): Station[] { return [...new Map(stations.map(station => [stationKey(station), station])).values()]; } +function normalize(value: string): string { return value.normalize('NFKC').trim().toLowerCase(); } diff --git a/src/cli/output.test.ts b/src/cli/output.test.ts new file mode 100644 index 0000000..bb92287 --- /dev/null +++ b/src/cli/output.test.ts @@ -0,0 +1,51 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {CliOutput} from './output.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import {displayWidth} from '../ui/format.js'; +import type {Station} from '../types.js'; + +const flags = {json: false, plain: false, quiet: false, noInput: false, noColor: false}; +const station: Station = {id: 'test', provider: 'playlist', name: '東京 Jazz\u001b]0;bad title\u0007\nFM', country: 'Japan', countryCode: 'JP', tags: [], codec: 'MP3', bitrate: 128}; +const settings = new JsonLibraryStore('/nonexistent-radiocli-output-fixture/library.json').snapshot().settings; +afterEach(() => vi.restoreAllMocks()); + +describe('terminal and machine output', () => { + it('aligns Unicode tables within the terminal width and strips untrusted escape sequences', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const output = new CliOutput(flags, settings, {isTTY: true, columns: 42}, {NO_COLOR: '1', LANG: 'en_US.UTF-8'}); + output.stations([station], [station]); + const rendered = String(log.mock.calls[0]![0]); + const table = rendered.split('\n\n')[0]!; + expect(table).not.toContain('\u001b'); + expect(table).toContain('*'); + expect(table).toContain('東京'); + for (const line of table.split('\n')) expect(displayWidth(line)).toBeLessThanOrEqual(41); + expect(table).toMatch(/STATION\s{2,}COUNTRY/); + }); + + it('keeps escape codes out of pipes even when FORCE_COLOR is set', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const output = new CliOutput(flags, settings, {isTTY: false, columns: 80}, {FORCE_COLOR: '3'}); + output.stations([station], []); + const rendered = String(log.mock.calls[0]![0]); + expect(rendered).not.toContain('\u001b'); + expect(rendered.split('\t')).toHaveLength(5); + expect(rendered).toContain('東京 Jazz FM'); + }); + + it('honors ASCII and no-color preferences on a terminal', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const output = new CliOutput({...flags, noColor: true}, {...settings, asciiMode: true}, {isTTY: true, columns: 40}, {FORCE_COLOR: '3'}); + output.stations([{...station, name: 'A station with a very long name', country: undefined}], []); + const rendered = String(log.mock.calls[0]![0]); + expect(rendered).not.toContain('\u001b'); + expect(rendered).not.toMatch(/[—…·]/); + }); + + it('uses the saved theme only for terminal headings', () => { + const output = new CliOutput(flags, {...settings, theme: 'amber'}, {isTTY: true, columns: 80}, {FORCE_COLOR: '3'}); + expect(output.heading('Listening')).toBe('\u001b[1;38;2;255;176;0mListening\u001b[0m'); + const plain = new CliOutput({...flags, plain: true}, settings, {isTTY: true, columns: 80}, {FORCE_COLOR: '3'}); + expect(plain.heading('Listening')).toBe('Listening'); + }); +}); diff --git a/src/cli/output.ts b/src/cli/output.ts new file mode 100644 index 0000000..0ad85a9 --- /dev/null +++ b/src/cli/output.ts @@ -0,0 +1,126 @@ +import type {AppSettings, Country, Station} from '../types.js'; +import type {ListeningStats} from '../activity/stats.js'; +import type {RadioSessionResult, RadioSessionStatus} from '../agent/session.js'; +import {stationKey} from '../storage/store.js'; +import {sanitizeTerminalText} from '../safety.js'; +import {resolveTerminalCapabilities} from '../platform/terminal.js'; +import {displayWidth, padDisplayEnd, truncate} from '../ui/format.js'; +import {toAsciiSafe} from '../ui/ascii.js'; +import {themeAccent} from '../ui/theme.js'; +import type {CliFlags} from './arguments.js'; + +export const clean = (value: unknown): string => sanitizeTerminalText(value == null ? '' : String(value)) ?? ''; + +export class CliOutput { + readonly mode: 'human' | 'plain' | 'json'; + readonly terminal; + readonly width: number; + private readonly color: boolean; + + constructor(readonly flags: CliFlags, private readonly settings: AppSettings, evidence = {isTTY: Boolean(process.stdout.isTTY), columns: process.stdout.columns || 100}, env = process.env) { + this.mode = flags.json ? 'json' : flags.plain || !evidence.isTTY ? 'plain' : 'human'; + this.terminal = resolveTerminalCapabilities(env, {isTTY: evidence.isTTY, asciiMode: settings.asciiMode, reduceMotion: settings.reduceMotion}); + this.width = Math.max(12, evidence.columns - 1); + this.color = this.mode === 'human' && evidence.isTTY && !flags.noColor && this.terminal.colorLevel > 0; + } + + text(value: string): string { return this.terminal.unicode ? clean(value) : toAsciiSafe(clean(value)); } + + heading(value: string): string { + return this.accent(this.text(value)); + } + + private accent(text: string): string { + if (!this.color) return text; + if (this.terminal.colorLevel < 3) return `\u001b[1m${text}\u001b[0m`; + const hex = themeAccent(this.settings.theme).slice(1); + const rgb = [0, 2, 4].map(offset => parseInt(hex.slice(offset, offset + 2), 16)).join(';'); + return `\u001b[1;38;2;${rgb}m${text}\u001b[0m`; + } + + print(value: unknown, human: string, plain = human): void { + if (this.flags.quiet) return; + console.log(this.mode === 'json' ? JSON.stringify(value, null, 2) : this.mode === 'plain' ? plain : human); + } + + stations(stations: Station[], favorites: Station[], empty = 'No stations found. Try a broader search or remove a filter.', showHint = true): void { + const favoriteKeys = new Set(favorites.map(stationKey)); + const rows = stations.map(station => ({id: stationKey(station), name: station.name, country: station.country ?? '', countryCode: station.countryCode ?? '', codec: station.codec ?? '', bitrate: station.bitrate ?? null, language: station.language ?? '', tags: station.tags, favorite: favoriteKeys.has(stationKey(station))})); + // Preserve the original five-column search TSV contract for existing pipes. + const plain = rows.map(row => [row.id, row.name, row.country, row.codec, row.bitrate ?? ''].map(clean).join('\t')).join('\n'); + const hint = showHint ? '\n\n' + this.text('* Favorite · Tune by name with radiocli play "station name", or add --pick.') : ''; + const human = stations.length ? this.table(['#', 'STATION', 'COUNTRY', 'QUALITY'], rows.map((row, index) => [String(index + 1), `${row.favorite ? '* ' : ''}${row.name}`, row.country || row.countryCode || '—', [row.codec, row.bitrate ? `${row.bitrate} kbps` : ''].filter(Boolean).join(' ') || '—'])) + hint : this.text(empty); + if (!stations.length && this.mode === 'plain') { if (!this.flags.quiet) process.stderr.write(`${this.text(empty)}\n`); return; } + this.print(rows, human, plain); + } + + countries(countries: Country[]): void { + this.print(countries, countries.length ? this.table(['CODE', 'COUNTRY', 'STATIONS'], countries.map(country => [country.code, country.name, String(country.stationCount)])) : 'No countries found. Try again when the directory is available.', countries.map(country => [country.code, country.stationCount, country.name].map(clean).join('\t')).join('\n')); + } + + now(status: RadioSessionStatus | null): void { + const fields = this.nowFields(status); + this.print(status, this.nowLines(status).map((line, index) => index === 0 ? this.accent(this.fit(line)) : this.fit(line)).join('\n'), Object.entries(fields).map(([key, value]) => `${key}\t${clean(value)}`).join('\n')); + } + + nowLine(status: RadioSessionStatus | null): string { + return this.text(this.nowLines(status).join(' · ')); + } + + result(result: Pick & Partial>, background = false): void { + const lines = [this.text(result.message)]; + if (result.status?.persistenceWarning) lines.push(this.text(result.status.persistenceWarning)); + if (background && result.ok && result.status?.owner === 'headless') lines.push('Playback continues in the background. Use radiocli now or radiocli stop.'); + this.print(result, lines.join('\n'), `${result.ok ? 'ok' : 'error'}\t${clean(result.message)}`); + } + + presets(presets: {name: string; station: Station}[]): void { + const rows = presets.map(preset => ({name: preset.name, stationId: stationKey(preset.station), stationName: preset.station.name})); + this.print(rows, rows.length ? this.table(['PRESET', 'STATION'], rows.map(row => [row.name, row.stationName])) + '\n\nPlay a preset: radiocli play ' : 'No presets yet. Tune a station, then run radiocli preset save work.', rows.map(row => [row.name, row.stationId, row.stationName].map(clean).join('\t')).join('\n')); + } + + stats(stats: ListeningStats): void { + const hours = Math.floor(stats.totalSeconds / 3600); + const minutes = Math.floor(stats.totalSeconds % 3600 / 60); + const lines = ['Listening stats', `${hours}h ${minutes}m listened · ${stats.sessions} sessions · ${stats.listenedStationCount} stations`, `${stats.currentStreak} day streak · ${stats.longestStreak} day best · ${stats.activeDays} active days`, `Most listened: ${this.text(stats.favoriteStation?.name ?? 'Start listening with radiocli play "station name".')}`]; + this.print(stats, lines.map((line, index) => index === 0 ? this.heading(this.fit(line)) : this.fit(this.text(line))).join('\n'), Object.entries({total_seconds: stats.totalSeconds, sessions: stats.sessions, stations: stats.listenedStationCount, current_streak: stats.currentStreak, longest_streak: stats.longestStreak, active_days: stats.activeDays, favorite_station: stats.favoriteStation?.name ?? ''}).map(([key, value]) => `${key}\t${clean(value)}`).join('\n')); + } + + table(headers: string[], rows: string[][]): string { + const sanitized = [headers, ...rows].map(row => row.map(value => this.text(value))); + const widths = headers.map((_, index) => Math.min(index === 1 ? 44 : 24, Math.max(...sanitized.map(row => displayWidth(row[index] ?? ''))))); + while (widths.reduce((sum, width) => sum + width, 0) + (widths.length - 1) * 2 > this.width) { + const biggest = widths.indexOf(Math.max(...widths)); + if (widths[biggest]! <= 3) break; + widths[biggest] = widths[biggest]! - 1; + } + return sanitized.map((row, rowIndex) => { + const line = row.map((value, index) => padDisplayEnd(this.fit(value, widths[index]), widths[index]!)).join(' ').trimEnd(); + return rowIndex === 0 ? this.accent(this.fit(line)) : this.fit(line); + }).join('\n'); + } + + fit(value: string, width = this.width): string { + const result = truncate(value, width); + return this.terminal.unicode ? result : toAsciiSafe(result); + } + + private nowFields(status: RadioSessionStatus | null) { + return { + state: status?.playback.state ?? 'idle', station_id: status?.station ? stationKey(status.station) : '', + station: status?.station?.name ?? '', track: status?.nowPlaying?.title ?? '', + output: status?.playback.airPlayDeviceName ?? (status?.playback.backend === 'airplay' ? 'AirPlay' : 'Local speakers'), + volume: status?.playback.volume ?? this.settings.volume, muted: status?.playback.muted ?? false, + backend: status?.playback.backend ?? 'none', warning: status?.persistenceWarning ?? '' + }; + } + + private nowLines(status: RadioSessionStatus | null): string[] { + if (!status?.station) return ['No station is playing. Start with radiocli play "station name".']; + const fields = this.nowFields(status); + const lines = [`${fields.station} · ${fields.state[0]!.toUpperCase()}${fields.state.slice(1)}`, fields.track || 'Track metadata unavailable', `${fields.output} · ${fields.backend} · Volume ${fields.volume}%${fields.muted ? ' (muted)' : ''}`]; + if (status.playback.message && clean(status.playback.message) !== clean(status.station.name)) lines.push(status.playback.message); + if (status.persistenceWarning) lines.push(status.persistenceWarning); + return lines.map(line => this.text(line)); + } +} diff --git a/src/cli/render-tui.tsx b/src/cli/render-tui.tsx new file mode 100644 index 0000000..db41f8a --- /dev/null +++ b/src/cli/render-tui.tsx @@ -0,0 +1,31 @@ +export async function renderTui(printHelp: () => void, 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 {rtlSafeOutput} = await import('../ui/rtl-output.js'); + const [{render}, {App}, {decodeAgentCommand}, {openTuiSession}] = await Promise.all([import('ink'), import('../ui/App.js'), import('../agent/service.js'), import('../agent/tui-session.js')]); + const initialAgentCommand = encodedCommand ? decodeAgentCommand(encodedCommand) : undefined; + const {session: playbackSession, warning: playbackRecoveryWarning} = await openTuiSession(); + try { + const app = render(, { + stdout: terminal.interactive && process.stdout.isTTY && !terminal.screenReader ? rtlSafeOutput(process.stdout) : process.stdout, + // 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']} + }); + await app.waitUntilExit(); + } catch (error) { + playbackSession?.disconnect(); + if (playbackSession) console.error('The TUI closed unexpectedly. Playback continues; reopen radiocli to reconnect, or run radiocli stop.'); + throw error; + } + await playbackSession?.close(); +} diff --git a/src/platform/capabilities.test.ts b/src/platform/capabilities.test.ts index 4714da1..ff551ea 100644 --- a/src/platform/capabilities.test.ts +++ b/src/platform/capabilities.test.ts @@ -50,3 +50,14 @@ describe('independent capabilities', () => { expect(report.playback.status).toBe('available'); }); }); + +describe('experimental Linux AirPlay capability evidence', () => { + const host = identifyPlatform({platform: 'linux', env: {}, release: '6.6'}); + const evidence = {backends: ['mpv', 'airplay'], commands: ['avahi-browse', 'ffmpeg'], graphicalSession: true}; + it('never mistakes installed tools for a ready daemon or loadable sender', () => { + expect(platformCapabilities(host, evidence).airPlay.status).toBe('unverified'); + expect(platformCapabilities(host, {...evidence, airPlay: {ready: false, discoveryService: 'unavailable', message: 'Avahi stopped'}}).airPlay).toEqual({status: 'unavailable', message: 'Avahi stopped'}); + expect(platformCapabilities(host, {...evidence, airPlay: {ready: true, discoveryService: 'unverified', message: 'Receiver connection required'}}).airPlay.status).toBe('unverified'); + expect(platformCapabilities(host, {...evidence, airPlay: {ready: true, discoveryService: 'ready', message: 'Experimental prerequisites ready'}}).airPlay.status).toBe('available'); + }); +}); diff --git a/src/platform/capabilities.ts b/src/platform/capabilities.ts index 3e27a05..48991a9 100644 --- a/src/platform/capabilities.ts +++ b/src/platform/capabilities.ts @@ -12,6 +12,7 @@ type CapabilityEvidence = { unicode?: boolean; color?: boolean; screenReader?: boolean; + airPlay?: {ready: boolean; discoveryService: 'ready' | 'unavailable' | 'unverified'; message: string}; }; const available = (message: string, adapter?: string): Capability => ({status: 'available', message, ...(adapter ? {adapter} : {})}); @@ -63,6 +64,13 @@ export function platformCapabilities(host: PlatformProfile, evidence: Capability 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.') + airPlay: !adapters.airPlay ? unavailable('Experimental AirPlay is supported on macOS and native Linux; this environment is not supported.') + : evidence.airPlay ? evidence.airPlay.ready + ? evidence.airPlay.discoveryService === 'unverified' ? unverified(evidence.airPlay.message, 'airplay') : available(evidence.airPlay.message, 'airplay') + : unavailable(evidence.airPlay.message) + : evidence.backends.includes('airplay') + ? host.id === 'linux' ? unverified('Experimental Linux output requires an explicit discovery-service and native-sender check. Select a receiver each session.', 'airplay') + : available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay') + : unavailable(`Experimental AirPlay requires FFmpeg, ${host.id === 'linux' ? 'Avahi' : 'Bonjour'}, and the safe optional sender.`) }; } diff --git a/src/platform/desktop-media.test.ts b/src/platform/desktop-media.test.ts new file mode 100644 index 0000000..d2b37f0 --- /dev/null +++ b/src/platform/desktop-media.test.ts @@ -0,0 +1,81 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {desktopMediaPolicy, startDesktopMediaControls} from './desktop-media.js'; +import type {OmarchyProfile} from './omarchy.js'; +import * as mpris from './mpris.js'; +import type {PlayerController, MetadataEvent, PlayerEvent} from '../player/player-controller.js'; +import type {RadioSessionStatus} from '../agent/session.js'; + +afterEach(() => vi.restoreAllMocks()); + +const quattro: OmarchyProfile = {kind: 'quattro', version: '4.0.4-1', major: 4, source: 'package', packageName: 'omarchy', root: '/usr/share/omarchy', session: {type: 'wayland', remote: false, localDesktop: true}, terminal: {available: true, command: '/usr/bin/omarchy-launch-tui', missing: []}}; +const env = {DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus'}; +const compatible = () => ({status: 'compatible' as const, args: ['--load-scripts=no'], reason: 'Stock plugin suppressed.'}); + +describe('desktop media selection', () => { + it('automatically enables only the packaged Quattro local desktop', () => { + expect(desktopMediaPolicy({}, {platform: 'linux', env, omarchy: () => quattro, mpv: compatible})).toMatchObject({enabled: true, preference: 'auto', mpvArgs: ['--load-scripts=no']}); + for (const kind of ['legacy', 'development', 'unknown', 'none'] as const) { + expect(desktopMediaPolicy({}, {platform: 'linux', env, omarchy: () => ({...quattro, kind}), mpv: compatible}).enabled).toBe(false); + } + expect(desktopMediaPolicy({}, {platform: 'linux', env, omarchy: () => ({...quattro, source: 'helper'}), mpv: compatible}).enabled).toBe(false); + expect(desktopMediaPolicy({}, {platform: 'linux', env, omarchy: () => ({...quattro, session: {...quattro.session, localDesktop: false, remote: true}}), mpv: compatible}).enabled).toBe(false); + }); + + it.each(['darwin', 'win32', 'freebsd', 'openbsd', 'netbsd', 'android', 'aix', 'sunos'] as const)('keeps %s behavior unchanged without probing or loading D-Bus', platform => { + const omarchy = vi.fn(); const mpv = vi.fn(); + expect(desktopMediaPolicy({desktopMediaControls: 'on'}, {platform, env, omarchy, mpv}).enabled).toBe(false); + expect(omarchy).not.toHaveBeenCalled(); expect(mpv).not.toHaveBeenCalled(); + }); + + it('supports explicit Linux opt-in and environment rollback without desktop detection', () => { + const omarchy = vi.fn(); + expect(desktopMediaPolicy({desktopMediaControls: 'on'}, {platform: 'linux', env, omarchy, mpv: compatible}).enabled).toBe(true); + expect(omarchy).not.toHaveBeenCalled(); + expect(desktopMediaPolicy({desktopMediaControls: 'on'}, {platform: 'linux', env: {...env, RADIOCLI_DESKTOP_MEDIA: 'off'}, mpv: compatible})).toMatchObject({enabled: false, preference: 'off', mpvArgs: []}); + expect(desktopMediaPolicy({desktopMediaControls: 'off'}, {platform: 'linux', env: {...env, RADIOCLI_DESKTOP_MEDIA: 'on'}, mpv: compatible}).enabled).toBe(true); + }); + + it('preserves playback when custom scripts cannot be safely inspected', () => { + expect(desktopMediaPolicy({desktopMediaControls: 'on'}, {platform: 'linux', env, mpv: () => ({status: 'unsupported', args: [], reason: 'Custom profile.'})})).toMatchObject({enabled: false, reason: 'Custom profile.', mpvArgs: []}); + }); + + it.each(['', 'tcp:host=example.test,port=1234', 'unixexec:path=/tmp/command', 'autolaunch:'])('does not connect to an unsafe or absent bus: %s', address => { + const mpv = vi.fn(); + expect(desktopMediaPolicy({desktopMediaControls: 'on'}, {platform: 'linux', env: {DBUS_SESSION_BUS_ADDRESS: address}, mpv}).enabled).toBe(false); + expect(mpv).not.toHaveBeenCalled(); + }); +}); + +describe('desktop player event bridge', () => { + it('clears an old broadcast title on stopped station selection and invalidates queued commands after disposal', async () => { + const native = {refresh: vi.fn(), close: vi.fn()}; + const start = vi.spyOn(mpris, 'startMpris').mockReturnValue(native); + const first = {provider: 'playlist' as const, id: 'first', name: 'First', tags: []}; + const second = {...first, id: 'second', name: 'Second'}; + const status: RadioSessionStatus = {owner: 'tui', station: first, queue: [first, second], playback: {backend: 'mpv', state: 'playing', ready: true, streamUrl: 'https://station.test/live', volume: 40, muted: false}}; + let metadata: MetadataEvent | undefined; + let change: PlayerEvent | undefined; + let permitted: (() => boolean) | undefined; + const removeState = vi.fn(); const removeMetadata = vi.fn(); + const player = { + desktopMediaCompatible: () => true, + onChange(listener: PlayerEvent) { change = listener; listener(status.playback); return removeState; }, + onMetadata(listener: MetadataEvent) { metadata = listener; return removeMetadata; } + } as unknown as PlayerController; + const bridge = startDesktopMediaControls({player, identity: '/private/radio.json', policy: {enabled: true, preference: 'on', reason: 'test', mpvArgs: []}, status: () => status, + command: async (_command, gate) => { permitted = gate; return {ok: true, message: 'queued', status}; }, suspended: () => false, env}); + const endpoint = start.mock.calls[0]![0]; + metadata!({title: 'Old station song', updatedAt: 'now'}); + expect(endpoint.snapshot().title).toBe('Old station song'); + status.playback = {...status.playback, state: 'stopped', ready: false}; + change!(status.playback); + status.station = second; + bridge.refresh(); + expect(endpoint.snapshot()).toMatchObject({station: second, title: undefined}); + await endpoint.command({type: 'next', preserveStop: true}); + expect(permitted!()).toBe(true); + bridge.close(); + expect(permitted!()).toBe(false); + expect(removeState).toHaveBeenCalledOnce(); expect(removeMetadata).toHaveBeenCalledOnce(); expect(native.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/platform/desktop-media.ts b/src/platform/desktop-media.ts new file mode 100644 index 0000000..b02d2e5 --- /dev/null +++ b/src/platform/desktop-media.ts @@ -0,0 +1,70 @@ +import {resolve} from 'node:path'; +import type {AppSettings} from '../types.js'; +import type {PlayerController} from '../player/player-controller.js'; +import {planMpvDesktopMedia} from '../player/mpv-desktop-media.js'; +import type {RadioSessionCommand, RadioSessionResult, RadioSessionStatus} from '../agent/session.js'; +import {identifyOmarchy, type OmarchyProfile} from './omarchy.js'; +import {sessionBusSocket, startMpris} from './mpris.js'; +import {resolveCommandDetails} from './executables.js'; + +type PolicyOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + omarchy?: () => OmarchyProfile; + mpv?: () => {status: 'compatible' | 'unsupported'; args: string[]; reason: string}; +}; +export type DesktopMediaPolicy = {enabled: boolean; preference: 'auto' | 'on' | 'off'; reason: string; mpvArgs: string[]}; + +export function desktopMediaPolicy(settings: Pick, options: PolicyOptions = {}): DesktopMediaPolicy { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const override = env.RADIOCLI_DESKTOP_MEDIA; + const preference = override === 'auto' || override === 'on' || override === 'off' ? override : settings.desktopMediaControls ?? 'auto'; + const disabled = (reason: string): DesktopMediaPolicy => ({enabled: false, preference, reason, mpvArgs: []}); + if (preference === 'off') return disabled('Desktop media controls are disabled.'); + if (platform !== 'linux') return disabled('Desktop media controls currently require Linux.'); + if (!sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? '')) return disabled('A local Unix session bus is required.'); + if (preference === 'auto') { + const profile = (options.omarchy ?? (() => identifyOmarchy({platform, env})))(); + if (profile.kind !== 'quattro' || profile.source !== 'package' || !profile.session.localDesktop) return disabled('Automatic desktop media controls require a packaged Omarchy Quattro local desktop.'); + } + const mpv = (options.mpv ?? (() => planMpvDesktopMedia({platform, env, executable: resolveCommandDetails('mpv', {platform, env}).path})))(); + if (mpv.status !== 'compatible') return disabled(mpv.reason); + return {enabled: true, preference, reason: mpv.reason, mpvArgs: mpv.args}; +} + +/** State and ICY updates reuse the owning player's event stream. No additional + * playback polling is needed and the bridge never connects through MCP. */ +export function startDesktopMediaControls(options: { + player: PlayerController; + identity: string; + policy: DesktopMediaPolicy; + status(): RadioSessionStatus; + command(command: RadioSessionCommand, permitted?: () => boolean): Promise; + suspended(): boolean; + env?: NodeJS.ProcessEnv; +}): {refresh(): void; close(): void} { + if (!options.policy.enabled) return {refresh() {}, close() {}}; + let title: string | undefined; + let previousStream: string | undefined; + let previousStation: string | undefined; + let disposed = false; + const bridge = startMpris({ + identity: resolve(options.identity), + address: (options.env ?? process.env).DBUS_SESSION_BUS_ADDRESS ?? '', + command: command => options.command(command, () => !disposed), + snapshot: () => ({...options.status(), title, available: !options.suspended() && options.player.desktopMediaCompatible()}) + }); + const refresh = () => { + const station = options.status().station; + const key = station ? `${station.provider}\0${station.id}` : undefined; + if (key !== previousStation) { title = undefined; previousStation = key; } + bridge.refresh(); + }; + const removeState = options.player.onChange(state => { + if (state.streamUrl !== previousStream) { title = undefined; previousStream = state.streamUrl; } + refresh(); + }); + const removeMetadata = options.player.onMetadata(metadata => { title = metadata.title; refresh(); }); + return {refresh, close() { disposed = true; removeState(); removeMetadata(); bridge.close(); }}; +} diff --git a/src/platform/diagnostics.native.test.ts b/src/platform/diagnostics.native.test.ts new file mode 100644 index 0000000..6b3524b --- /dev/null +++ b/src/platform/diagnostics.native.test.ts @@ -0,0 +1,66 @@ +import {spawn, execFile} from 'node:child_process'; +import {once} from 'node:events'; +import {existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {promisify} from 'node:util'; +import {describe, expect, it} from 'vitest'; +import {probeAirPlayReadiness} from '../player/airplay-readiness.js'; +import {probeDoctorScheduler} from './diagnostics.js'; +import {identifyPlatform} from './runtime.js'; + +const run = promisify(execFile); + +/** The Linux native CI job supplies D-Bus tools. The private daemon has only + * harmless marker services; this never activates a real user/system service. */ +describe.skipIf(process.platform !== 'linux' || !process.env.DBUS_SESSION_BUS_ADDRESS)('native read-only Doctor probes', () => { + it('does not activate missing scheduler or Avahi services', async () => { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-doctor-bus-')); + const services = join(directory, 'services'); + mkdirSync(services); + const markerScript = join(directory, 'mark.cjs'); + writeFileSync(markerScript, "require('node:fs').writeFileSync(process.argv[2], 'activated');\n"); + const markers = ['systemd', 'avahi'].map(name => join(directory, name)); + for (const [index, name] of ['org.freedesktop.systemd1', 'org.freedesktop.Avahi'].entries()) { + writeFileSync(join(services, `${name}.service`), `[D-BUS Service]\nName=${name}\nExec=${quote(process.execPath)} ${quote(markerScript)} ${quote(markers[index]!)}\n`); + } + const config = join(directory, 'bus.conf'); + writeFileSync(config, `sessionunix:tmpdir=/tmp${xml(services)}`); + const daemon = spawn('dbus-daemon', ['--nofork', '--print-address=1', `--config-file=${config}`], {stdio: ['ignore', 'pipe', 'ignore']}); + const closed = once(daemon, 'close'); + try { + let output = ''; + daemon.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + await until(() => output.includes('\n')); + const address = output.split('\n')[0]!; + const env = {...process.env, DBUS_SESSION_BUS_ADDRESS: address, DBUS_SYSTEM_BUS_ADDRESS: address}; + const scheduler = await probeDoctorScheduler(identifyPlatform({env}), env); + expect(scheduler?.supported ?? false).toBe(false); + const airPlay = await probeAirPlayReadiness({platform: 'linux', env, hasCommand: () => true, + sender: async () => ({loaded: true, message: 'Fixture sender.'})}); + expect(airPlay.discoveryService).toBe('unavailable'); + expect(markers.map(existsSync)).toEqual([false, false]); + + // Positive control: prove this daemon really can activate the fixture. + // get-property ignores --auto-start=no on systemd 252, so the production + // probe must use an explicit Properties.Get method call instead. + await run('busctl', [`--address=${address}`, '--auto-start=yes', '--timeout=0.5', 'call', + 'org.freedesktop.systemd1', '/org/freedesktop/systemd1', 'org.freedesktop.DBus.Properties', 'Get', 'ss', + 'org.freedesktop.systemd1.Manager', 'Version'], {timeout: 2000}).catch(() => undefined); + await until(() => existsSync(markers[0]!)); + } finally { + daemon.kill('SIGTERM'); + await closed; + rmSync(directory, {recursive: true, force: true}); + } + }, 10_000); +}); + +async function until(predicate: () => boolean): Promise { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 20)); } + throw new Error('The private Doctor bus fixture did not become ready.'); +} + +function quote(value: string): string { return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; } +function xml(value: string): string { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); } diff --git a/src/platform/diagnostics.test.ts b/src/platform/diagnostics.test.ts new file mode 100644 index 0000000..122fcc9 --- /dev/null +++ b/src/platform/diagnostics.test.ts @@ -0,0 +1,428 @@ +import {EventEmitter} from 'node:events'; +import * as childProcess from 'node:child_process'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {PassThrough} from 'node:stream'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {probeDoctorScheduler, probeNativeIntegrations} from './diagnostics.js'; +import * as schedulers from '../alarms/scheduler.js'; +import * as executables from './executables.js'; +import * as airPlaySender from '../player/airplay-sender-health.js'; +import {mprisServiceName} from './mpris.js'; +import type {OmarchyProfile} from './omarchy.js'; +import {identifyOmarchy} from './omarchy.js'; +import {identifyPlatform} from './runtime.js'; + +vi.mock('node:child_process', async importOriginal => { + const actual = await importOriginal(); + return {...actual, spawn: vi.fn()}; +}); + +type Options = Parameters[0]; +const desktop = {HOME: '/home/private-account', PATH: '/usr/bin', XDG_RUNTIME_DIR: '/run/user/1000', WAYLAND_DISPLAY: 'wayland-1', XDG_SESSION_TYPE: 'wayland', DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus'}; +const linux = identifyPlatform({platform: 'linux', env: desktop, osRelease: '', libc: 'glibc', release: '6.6'}); +const quattro: OmarchyProfile = {kind: 'quattro', version: '4.0.4-1', major: 4, source: 'package', packageName: 'omarchy', root: '/home/private-account/omarchy', + session: {type: 'wayland', remote: false, localDesktop: true}, terminal: {available: true, command: '/home/private-account/bin/omarchy-launch-tui', missing: []}}; +const airPlay = {eligible: true, discoveryExecutable: true, discoveryService: 'ready' as const, senderLoaded: true, ffmpeg: true, ready: true, message: 'Experimental prerequisites ready.'}; +const result = (output: string) => ({output, code: 0, timedOut: false, truncated: false}); +const success: NonNullable = async (command, args) => result(command.endsWith('busctl') ? 'b false' : args.includes('get-volume') ? 'Volume: 0.50' : 'Volume: front-left: 32768 / 50% / -18.06 dB'); +let root: string; +let library: string; + +function fixture(overrides: Partial = {}): Options { + return {host: linux, env: desktop, libraryPath: () => library, resolve: name => name.startsWith('/') ? name : `/usr/bin/${name}`, + omarchy: () => quattro, mpv: () => ({status: 'compatible', args: [], reason: 'Compatible.'}), + terminalPlan: () => ({terminal: 'linux:/home/private-account/bin/ghostty', command: '/home/private-account/bin/ghostty'}), + run: success, isSocket: () => true, airPlay: async () => airPlay, ...overrides}; +} + +beforeEach(() => { vi.mocked(childProcess.spawn).mockReset(); root = mkdtempSync(join(tmpdir(), 'radiocli-diagnostics-')); library = join(root, 'library.json'); }); +afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals(); rmSync(root, {recursive: true, force: true}); }); + +describe('read-only native diagnostic report', () => { + it('keeps policy, bus readiness, current owner, terminal prerequisites and audio response independent', async () => { + const run = vi.fn(success); + const report = await probeNativeIntegrations(fixture({run})); + expect(report.omarchy).toEqual({status: 'detected', kind: 'quattro', version: '4.0.4-1', major: 4, source: 'package', packageName: 'omarchy', + session: {type: 'wayland', remote: false, localDesktop: true}, terminal: {prerequisitesReady: true, missing: []}}); + expect(report.desktopMedia).toMatchObject({preference: {value: 'auto', source: 'default', status: 'available'}, policy: {eligible: true, status: 'ready'}, sessionBus: {status: 'ready'}, owner: {status: 'absent'}}); + expect(report.terminalLauncher).toMatchObject({selected: 'linux:ghostty', prerequisites: 'ready', launch: 'unverified'}); + expect(report.audio).toMatchObject({status: 'ready', backend: 'pipewire', wpctl: {installed: true, status: 'ready'}, pactl: {installed: true, status: 'ready'}}); + expect(report.airPlay).toMatchObject({...airPlay, ready: false, enabled: false, state: 'disabled', prerequisitesReady: true, message: expect.stringContaining('setup --airplay')}); + expect(JSON.stringify(report)).not.toMatch(/private-account|\/run\/user|library\.json|org\.mpris|32768|0\.50/); + expect(existsSync(library)).toBe(false); + expect(run.mock.calls.map(call => call[1])).toEqual([ + ['--address=unix:path=%2Frun%2Fuser%2F1000%2Fbus', '--auto-start=no', '--timeout=0.5', 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'NameHasOwner', 's', mprisServiceName(library)], + ['get-volume', '@DEFAULT_AUDIO_SINK@'], ['--server=unix:/run/user/1000/pulse/native', 'get-sink-volume', '@DEFAULT_SINK@'] + ]); + expect(JSON.stringify(run.mock.calls)).not.toMatch(/RequestName|StartService|set-volume|set-mute|avahi-browse|dns-sd|systemctl/); + }); + + it('reports a registered owner without treating current policy as the owner of that bridge', async () => { + const report = await probeNativeIntegrations(fixture({env: {...desktop, RADIOCLI_DESKTOP_MEDIA: 'off'}, run: async () => result('b true')})); + expect(report.desktopMedia).toMatchObject({preference: {value: 'off'}, policy: {eligible: false}, sessionBus: {status: 'ready'}, owner: {status: 'registered'}}); + expect(report.desktopMedia.owner.message).toContain('playback state was not inspected'); + }); + + it.each([ + ['omarchy 4.0.4-1', 'quattro', true], ['omarchy-dev 4.0.4-1', 'development', false], + ['omarchy 3.3.3-1', 'legacy', false], ['omarchy 5.0.0-1', 'unknown', false], ['', 'none', false] + ] as const)('uses installation evidence %s without changing Linux identity', async (metadata, kind, eligible) => { + const report = await probeNativeIntegrations(fixture({omarchy: () => identifyOmarchy({platform: 'linux', env: desktop, readFile: () => null, + isDirectory: path => Boolean(metadata) && path === '/usr/share/omarchy', resolve: command => command.startsWith('/') ? command : `/usr/bin/${command}`, + run: (_command, args) => metadata.startsWith(`${args[1]} `) ? metadata : null})})); + expect(report.omarchy.kind).toBe(kind); + expect(report.desktopMedia.policy.eligible).toBe(eligible); + expect(linux.id).toBe('linux'); + }); + + it('redacts unrecognized helper versions and private roots and commands', async () => { + const report = await probeNativeIntegrations(fixture({omarchy: () => ({...quattro, kind: 'unknown', version: 'https://secret.test/token', terminal: {...quattro.terminal, missing: ['/home/private-account/token']}})})); + expect(report.omarchy.version).toBeNull(); + expect(report.omarchy.terminal.missing).toEqual([]); + expect(JSON.stringify(report)).not.toMatch(/private-account|secret\.test|token/); + }); + + it.each(['headless', 'x11', 'wayland'] as const)('reports a remote %s session separately from installed Quattro', async type => { + const report = await probeNativeIntegrations(fixture({omarchy: () => ({...quattro, session: {type, remote: true, localDesktop: false}, terminal: {...quattro.terminal, available: false, missing: ['local-wayland-session']}})})); + expect(report.omarchy).toMatchObject({kind: 'quattro', session: {type, remote: true}, terminal: {prerequisitesReady: false}}); + expect(report.desktopMedia.policy.eligible).toBe(false); + }); + + it('contains every optional probe failure without disclosing raw errors', async () => { + const secret = () => { throw new Error('https://secret.test/token /home/private-account unixexec:path=/secret'); }; + const report = await probeNativeIntegrations(fixture({omarchy: secret, terminalPlan: secret, mpv: secret, run: async () => secret(), airPlay: async () => secret()})); + expect(report.omarchy.status).toBe('unverified'); + expect(report.desktopMedia.policy.status).toBe('unverified'); + expect(report.desktopMedia.sessionBus.status).toBe('unverified'); + expect(report.terminalLauncher.prerequisites).toBe('unavailable'); + expect(report.audio.status).toBe('unverified'); + expect(report.airPlay).toMatchObject({ready: false, discoveryService: 'unverified'}); + expect(JSON.stringify(report)).not.toMatch(/secret|token|private-account|unixexec/); + }); + + it('isolates a failed audio tool from the other local backend and terminal selection', async () => { + const report = await probeNativeIntegrations(fixture({run: async (command, args, env) => { if (command.endsWith('wpctl')) throw new Error('failed'); return success(command, args, env); }})); + expect(report.audio).toMatchObject({status: 'ready', backend: 'pulse', wpctl: {status: 'unverified'}, pactl: {status: 'ready'}}); + expect(report.terminalLauncher.prerequisites).toBe('ready'); + }); + + it('does not probe the execution host when inspecting an injected different platform', async () => { + const platform = process.platform === 'linux' ? 'darwin' : 'linux'; + const spawn = vi.mocked(childProcess.spawn); + const report = await probeNativeIntegrations({host: identifyPlatform({platform, env: {}, osRelease: '', libc: 'glibc', release: '6.6'}), env: {}, libraryPath: () => library}); + expect(spawn).not.toHaveBeenCalled(); + expect(report.terminalLauncher.prerequisites).toBe('unverified'); + expect(report.airPlay.ready).toBe(false); + }); + + it.each(['iterm', 'kitty', 'wezterm', 'ghostty'])('keeps the uninspected macOS %s application separate from the available launch utility', async name => { + vi.stubGlobal('process', {...process, platform: 'darwin'}); + const env = {RADIOCLI_ALARM_TERMINAL: `darwin:${name}`}; + const report = await probeNativeIntegrations(fixture({ + host: identifyPlatform({platform: 'darwin', env}), env, terminalPlan: undefined, + resolve: command => ['/usr/bin/open', '/usr/bin/osascript'].includes(command) ? command : undefined + })); + expect(report.terminalLauncher).toMatchObject({selected: `darwin:${name}`, prerequisites: 'unverified', launch: 'unverified'}); + expect(report.terminalLauncher.message).toContain('target terminal application has not been inspected'); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + + it('passes resolved fallback executables through the real AirPlay readiness boundary with a restricted PATH', async () => { + vi.stubGlobal('process', {...process, platform: 'linux'}); + vi.spyOn(airPlaySender, 'probeAirPlaySender').mockResolvedValue({loaded: true, message: 'Loaded'}); + vi.mocked(childProcess.spawn).mockImplementation((_command, args) => { + const child = Object.assign(new EventEmitter(), {stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(), unref: vi.fn()}); + queueMicrotask(() => { child.stdout.write(args?.includes('GetState') ? 'i 2' : 'b false'); child.emit('close', 0); }); + return child as unknown as childProcess.ChildProcess; + }); + const report = await probeNativeIntegrations(fixture({env: {...desktop, PATH: '/nonexistent'}, run: undefined, airPlay: undefined, isSocket: () => false})); + expect(report.airPlay.discoveryService).toBe('ready'); + const avahi = vi.mocked(childProcess.spawn).mock.calls.find(([, args]) => args?.includes('GetState')); + expect(avahi?.[0]).toBe('/usr/bin/busctl'); + expect(avahi?.[2]).toMatchObject({env: {PATH: '/nonexistent'}}); + }); +}); + +describe('desktop media preference inspection does not migrate or repair libraries', () => { + it.each(['auto', 'on', 'off'] as const)('reads only the %s preference without rewriting a read-only file', async value => { + const text = JSON.stringify({settings: {desktopMediaControls: value}, imported: [{streamUrl: 'https://secret.test/token'}], unrelated: 'preserve me'}); + writeFileSync(library, text, {mode: 0o400}); + const before = statSync(library); + const report = await probeNativeIntegrations(fixture()); + expect(report.desktopMedia.preference).toMatchObject({value, source: 'library', status: 'available'}); + expect(readFileSync(library, 'utf8')).toBe(text); + expect(statSync(library).mtimeMs).toBe(before.mtimeMs); + expect(readdirSync(root)).toEqual(['library.json']); + expect(JSON.stringify(report)).not.toContain('secret.test'); + }); + + it.each(['not json', '{"settings":{"desktopMediaControls":"invalid"}}', '{"settings":null}', 'null', '[]'])('keeps invalid library bytes unchanged: %s', async text => { + writeFileSync(library, text); + const report = await probeNativeIntegrations(fixture()); + expect(report.desktopMedia.preference).toMatchObject({value: 'auto', status: 'unverified'}); + expect(report.desktopMedia.policy).toMatchObject({eligible: false, status: 'unverified'}); + expect(readFileSync(library, 'utf8')).toBe(text); + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it('respects a valid environment preference even if the library is unreadable', async () => { + const report = await probeNativeIntegrations(fixture({env: {...desktop, RADIOCLI_DESKTOP_MEDIA: 'off'}, libraryPath: () => { throw new Error('private path'); }})); + expect(report.desktopMedia.preference).toMatchObject({value: 'off', source: 'environment', status: 'available'}); + expect(report.desktopMedia.sessionBus.status).toBe('ready'); + expect(report.desktopMedia.owner.status).toBe('unverified'); + }); + + it('reports an inaccessible library path without creating backup or replacement state', async () => { + const text = '{"settings":{"desktopMediaControls":"off"}}'; + writeFileSync(library, text); + const report = await probeNativeIntegrations(fixture({libraryPath: () => { throw Object.assign(new Error('access denied'), {code: 'EACCES'}); }})); + expect(report.desktopMedia.preference.status).toBe('unverified'); + expect(readdirSync(root)).toEqual(['library.json']); + expect(readFileSync(library, 'utf8')).toBe(text); + }); + + // Windows chmod does not remove read access; root also bypasses POSIX modes. + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('reports a POSIX file that cannot be read without creating backup or replacement state', async () => { + writeFileSync(library, '{"settings":{"desktopMediaControls":"off"}}', {mode: 0}); + const report = await probeNativeIntegrations(fixture()); + expect(report.desktopMedia.preference.status).toBe('unverified'); + expect(readdirSync(root)).toEqual(['library.json']); + chmodSync(library, 0o600); + expect(readFileSync(library, 'utf8')).toContain('"off"'); + }); + + it('rejects directories and oversized regular files as preference evidence', async () => { + mkdirSync(library); + expect((await probeNativeIntegrations(fixture())).desktopMedia.preference.status).toBe('unverified'); + rmSync(library, {recursive: true}); + writeFileSync(library, ' '.repeat(2 * 1024 * 1024 + 1)); + expect((await probeNativeIntegrations(fixture())).desktopMedia.preference.status).toBe('unverified'); + expect(statSync(library).size).toBe(2 * 1024 * 1024 + 1); + }); + + it.skipIf(process.platform === 'win32')('rejects a FIFO without blocking or replacing it', async () => { + childProcess.execFileSync('mkfifo', [library]); + const start = Date.now(); + expect((await probeNativeIntegrations(fixture())).desktopMedia.preference.status).toBe('unverified'); + expect(Date.now() - start).toBeLessThan(1000); + expect(statSync(library).isFIFO()).toBe(true); + }); +}); + +describe('only normalized local bus and audio transports are probed', () => { + it.each(['', 'unixexec:path=/tmp/run', 'tcp:host=example.test,port=1234', 'autolaunch:', 'unix:path=relative', 'unix:path=/ok,path=/other', 'unix:path=/bad%00', 'unix:path=/bad%ZZ'])('rejects a missing or unsupported bus without running it: %s', address => { + return (async () => { + const run = vi.fn(success); + const report = await probeNativeIntegrations(fixture({env: {...desktop, DBUS_SESSION_BUS_ADDRESS: address}, run, resolve: name => name === 'busctl' ? '/usr/bin/busctl' : undefined})); + expect(report.desktopMedia).toMatchObject({policy: {eligible: false}, sessionBus: {status: 'unavailable'}, owner: {status: 'unverified'}}); + expect(run).not.toHaveBeenCalled(); + })(); + }); + + it.each([ + ['unixexec:path=/tmp/run;unix:path=/run/user/1000/my%20bus,guid=private;autolaunch:', 'unix:path=%2Frun%2Fuser%2F1000%2Fmy%20bus'], + ['unix:abstract=/tmp/bus%2Cname,guid=private', 'unix:abstract=%2Ftmp%2Fbus%2Cname'], + ['unix:path=/tmp/bus,unexpected=unixexec%3Apath%3D%2Ftmp%2Frun', 'unix:path=%2Ftmp%2Fbus'] + ])('normalizes valid local transport from %s before passing it to busctl', async (address, normalized) => { + const run = vi.fn(success); + await probeNativeIntegrations(fixture({env: {...desktop, DBUS_SESSION_BUS_ADDRESS: address}, run})); + const bus = run.mock.calls.find(call => call[0].endsWith('busctl'))!; + expect(bus[1][0]).toBe(`--address=${normalized}`); + expect(bus[1]).toContain('--auto-start=no'); + expect(bus[2].DBUS_SESSION_BUS_ADDRESS).toBeUndefined(); + expect(JSON.stringify(bus)).not.toMatch(/unixexec|autolaunch|guid|unexpected/); + }); + + it('keeps installed bus/audio tools separate from unavailable services', async () => { + const report = await probeNativeIntegrations(fixture({run: async () => ({...result('private failure'), code: 1})})); + expect(report.desktopMedia).toMatchObject({policy: {eligible: true}, sessionBus: {status: 'unavailable'}, owner: {status: 'unverified'}}); + expect(report.audio).toMatchObject({status: 'unavailable', backend: null, wpctl: {installed: true}, pactl: {installed: true}}); + expect(JSON.stringify(report)).not.toContain('private failure'); + }); + + it('leaves a valid but unprobed bus unverified when busctl is missing', async () => { + const report = await probeNativeIntegrations(fixture({resolve: name => name === 'busctl' ? undefined : `/usr/bin/${name}`})); + expect(report.desktopMedia).toMatchObject({policy: {eligible: true}, sessionBus: {status: 'unverified'}, owner: {status: 'unverified'}}); + }); + + it('does not run audio clients when their local sockets are absent', async () => { + const run = vi.fn(success); + const report = await probeNativeIntegrations(fixture({isSocket: () => false, run})); + expect(report.audio).toMatchObject({status: 'unavailable', wpctl: {installed: true, status: 'unavailable'}, pactl: {installed: true, status: 'unavailable'}}); + expect(run).toHaveBeenCalledTimes(1); + expect(run.mock.calls[0]![0]).toContain('busctl'); + }); + + it('does not use remote audio configuration, debug destinations or daemon flags', async () => { + const run = vi.fn(success); + const report = await probeNativeIntegrations(fixture({env: {...desktop, PULSE_SERVER: 'tcp:secret.test', PIPEWIRE_REMOTE: 'tcp:secret.test', PIPEWIRE_DAEMON: 'true', PIPEWIRE_DEBUG: '/private/debug'}, run})); + expect(report.audio.status).toBe('unavailable'); + expect(run).toHaveBeenCalledTimes(1); + expect(JSON.stringify(run.mock.calls)).not.toMatch(/secret\.test|\/private\/debug|PIPEWIRE_DAEMON/); + }); + + it.each([{...result('b true'), timedOut: true}, {...result('b true'), truncated: true}, result('b true\nhttps://secret.test'), result('x'.repeat(5000))])('does not accept unbounded, timed out or malformed responses', async output => { + const report = await probeNativeIntegrations(fixture({run: async () => output})); + expect(report.desktopMedia.sessionBus.status).toBe('unavailable'); + expect(report.audio.status).toBe('unavailable'); + expect(JSON.stringify(report)).not.toMatch(/secret\.test|x{20}/); + }); +}); + +describe('native diagnostic subprocess bounds', () => { + function child() { + return Object.assign(new EventEmitter(), {stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true), unref: vi.fn()}); + } + function nativeOptions(): Options { + return fixture({host: {...linux, platform: process.platform}, run: undefined, resolve: name => name === 'busctl' ? '/usr/bin/busctl' : undefined}); + } + + it('kills a stalled optional command and settles without publishing errors', async () => { + vi.useFakeTimers(); + const process = child(); + vi.mocked(childProcess.spawn).mockReturnValue(process as unknown as childProcess.ChildProcess); + const pending = probeNativeIntegrations(nativeOptions()); + await vi.advanceTimersByTimeAsync(601); + const report = await pending; + expect(process.kill).toHaveBeenCalledExactlyOnceWith('SIGKILL'); + expect(report.desktopMedia.sessionBus.status).toBe('unavailable'); + }); + + it('caps stdout and stderr together and never returns raw diagnostics', async () => { + const process = child(); + vi.mocked(childProcess.spawn).mockReturnValue(process as unknown as childProcess.ChildProcess); + const pending = probeNativeIntegrations(nativeOptions()); + process.stdout.write('b true'); + process.stderr.write('private error'.repeat(500)); + const report = await pending; + expect(process.kill).toHaveBeenCalledExactlyOnceWith('SIGKILL'); + expect(report.desktopMedia.sessionBus.status).toBe('unavailable'); + expect(JSON.stringify(report)).not.toContain('private error'); + }); +}); + +describe('Doctor scheduler probes use a bounded local transport without activation', () => { + const versionArgs = ['--user', 'show', '--property=Version', '--value']; + const capability = {name: 'systemd', supported: true, exactWake: false, catchUpAfterWake: true, message: 'Manager replied.'}; + beforeEach(() => { + // Native Linux is simulated only with a mocked spawn boundary. Other-host + // callers remain guarded, and these tests never invoke macOS host tools. + vi.stubGlobal('process', {...process, platform: 'linux'}); + vi.spyOn(executables, 'resolveCommandDetails').mockImplementation(command => ({path: `/usr/bin/${command}`, discovery: 'path'})); + }); + function adapter(command = 'systemctl', args = versionArgs) { + return vi.spyOn(schedulers, 'createSchedulerAdapter').mockImplementation((deps = {}) => ({ + capabilities: () => capability, + probeCapabilities: async () => { + const result = await deps.run!(command, args); + return {...capability, supported: result.code === 0, message: result.stderr || capability.message}; + }, + install: async () => {}, remove: async () => {}, status: async () => ({installed: false, healthy: false, message: 'Not inspected.'}) + })); + } + function child(output?: string, code = 0) { + const child = Object.assign(new EventEmitter(), {stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true), unref: vi.fn()}); + vi.mocked(childProcess.spawn).mockReturnValue(child as unknown as childProcess.ChildProcess); + if (output !== undefined) queueMicrotask(() => { child.stdout.write(output); child.emit('close', code); }); + return child; + } + + it.each(['unixexec:path=/probe-only', 'tcp:host=secret.test,port=1234', 'autolaunch:', 'unix:path=relative'])('rejects unsafe scheduler transport %s before constructing or invoking an adapter', async address => { + const create = adapter(); + const report = await probeDoctorScheduler(linux, {...desktop, DBUS_SESSION_BUS_ADDRESS: address}); + expect(report).toMatchObject({supported: false, catchUpAfterWake: false}); + expect(report?.message).toContain('local Unix session-bus'); + expect(create).not.toHaveBeenCalled(); + expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toMatch(/probe-only|secret\.test|unixexec|tcp:|autolaunch/); + }); + + it('normalizes the Unix address and translates the exact manager query without executing systemctl', async () => { + adapter(); child('v s "256.4"\n'); + const report = await probeDoctorScheduler(linux, {...desktop, DBUS_SESSION_BUS_ADDRESS: 'unixexec:path=/probe-only;unix:path=/tmp/local%20bus,guid=secret;tcp:host=secret.test', DBUS_SYSTEM_BUS_ADDRESS: 'unixexec:path=/system-secret', DBUS_STARTER_ADDRESS: 'autolaunch:secret'}); + expect(report?.supported).toBe(true); + expect(childProcess.spawn).toHaveBeenCalledExactlyOnceWith('/usr/bin/busctl', ['--address=unix:path=%2Ftmp%2Flocal%20bus', '--auto-start=no', '--timeout=2', 'call', 'org.freedesktop.systemd1', '/org/freedesktop/systemd1', 'org.freedesktop.DBus.Properties', 'Get', 'ss', 'org.freedesktop.systemd1.Manager', 'Version'], { + detached: true, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: expect.objectContaining({DBUS_SESSION_BUS_ADDRESS: 'unix:path=%2Ftmp%2Flocal%20bus', LC_ALL: 'C'}) + }); + expect(JSON.stringify(vi.mocked(childProcess.spawn).mock.calls)).not.toMatch(/unixexec|autolaunch|tcp:|secret|systemctl|DBUS_SYSTEM_BUS_ADDRESS|DBUS_STARTER_ADDRESS/); + }); + + it('does not run scheduler mutations if an adapter asks for an unexpected command', async () => { + adapter('systemctl', ['--user', 'start', 'unit.service']); + expect((await probeDoctorScheduler(linux, desktop))?.supported).toBe(false); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + + it('keeps tooling unverified when the no-activation bus tool is missing', async () => { + adapter(); + vi.mocked(executables.resolveCommandDetails).mockImplementation(command => ({path: command === 'busctl' ? null : `/usr/bin/${command}`, discovery: command === 'busctl' ? 'missing' : 'path'})); + expect(await probeDoctorScheduler(linux, desktop)).toBeUndefined(); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + + it('does not confuse an unavailable guessed bus with an unavailable headless manager', async () => { + adapter(); child('private failure', 1); + expect(await probeDoctorScheduler(linux, {XDG_RUNTIME_DIR: '/run/user/1000'})).toBeUndefined(); + expect(vi.mocked(childProcess.spawn).mock.calls[0]![1]).toContain('--address=unix:path=%2Frun%2Fuser%2F1000%2Fbus'); + }); + + it('does not probe host tools for a foreign platform profile', async () => { + const create = adapter(); + expect(await probeDoctorScheduler({...linux, platform: 'darwin', id: 'darwin'}, desktop)).toBeUndefined(); + expect(create).not.toHaveBeenCalled(); + expect(executables.resolveCommandDetails).not.toHaveBeenCalled(); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + + it('kills a stalled manager probe before the scheduler result deadline', async () => { + vi.useFakeTimers(); adapter(); const process = child(); + const pending = probeDoctorScheduler(linux, desktop); + await vi.advanceTimersByTimeAsync(2501); + expect((await pending)?.supported).toBe(false); + expect(process.kill).toHaveBeenCalledExactlyOnceWith('SIGKILL'); + }); + + it('caps combined scheduler output and keeps failure diagnostics private', async () => { + adapter(); const process = child(); + const pending = probeDoctorScheduler(linux, desktop); + process.stdout.write('v s "256"\n'); process.stderr.write('private-token'.repeat(500)); + const report = await pending; + expect(report?.supported).toBe(false); + expect(process.kill).toHaveBeenCalledExactlyOnceWith('SIGKILL'); + expect(JSON.stringify(report)).not.toContain('private-token'); + }); + + it('allows Windows startup time and still kills a stalled scheduler command', async () => { + vi.stubGlobal('process', {...process, platform: 'win32'}); + const kill = vi.spyOn(process, 'kill'); + vi.useFakeTimers(); adapter('powershell.exe', ['-NoProfile', '-NonInteractive']); const probeChild = child(); + const pending = probeDoctorScheduler({...linux, id: 'win32', platform: 'win32'}, {}); + await vi.advanceTimersByTimeAsync(5000); + expect(probeChild.kill).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5001); + expect((await pending)?.supported).toBe(false); + expect(probeChild.kill).toHaveBeenCalledExactlyOnceWith('SIGKILL'); + expect(probeChild.stdout.destroyed).toBe(true); + expect(probeChild.stderr.destroyed).toBe(true); + expect(probeChild.unref).toHaveBeenCalledOnce(); + expect(kill).not.toHaveBeenCalled(); + expect(childProcess.spawn).toHaveBeenCalledWith(expect.any(String), expect.any(Array), expect.objectContaining({detached: false, windowsHide: true})); + }); +}); + +describe('read-only Linux AirPlay consent diagnostics', () => { + it('distinguishes a disabled feature from ready prerequisites without creating a library', async () => { + const report = await probeNativeIntegrations(fixture()); + expect(report.airPlay).toMatchObject({state: 'disabled', enabled: false, ready: false, prerequisitesReady: true}); + expect(existsSync(library)).toBe(false); + }); + it('reads the flag without rewriting the library and retains missing-prerequisite evidence', async () => { + const source = JSON.stringify({settings: {linuxAirPlayEnabled: true}, untouched: 'value'}); + writeFileSync(library, source); + const report = await probeNativeIntegrations(fixture({airPlay: async () => ({...airPlay, ffmpeg: false, ready: false, message: 'FFmpeg is missing.'})})); + expect(report.airPlay).toMatchObject({state: 'enabled', enabled: true, ready: false, prerequisitesReady: false, ffmpeg: false}); + expect(readFileSync(library, 'utf8')).toBe(source); + }); +}); diff --git a/src/platform/diagnostics.ts b/src/platform/diagnostics.ts new file mode 100644 index 0000000..d64df9c --- /dev/null +++ b/src/platform/diagnostics.ts @@ -0,0 +1,264 @@ +import {statSync} from 'node:fs'; +import {posix, resolve as absolutePath} from 'node:path'; +import {readSettingsWithoutMigration} from '../storage/read-settings.js'; +import {airPlayPolicy} from '../player/airplay-policy.js'; +import {createSchedulerAdapter, type SchedulerCapabilities} from '../alarms/scheduler.js'; +import {probeAirPlayReadiness, type AirPlayReadiness} from '../player/airplay-readiness.js'; +import {runAirPlayCommand} from '../player/airplay-process.js'; +import {planMpvDesktopMedia} from '../player/mpv-desktop-media.js'; +import {desktopMediaPolicy} from './desktop-media.js'; +import {resolveCommandDetails} from './executables.js'; +import {mprisServiceName, sessionBusSocket} from './mpris.js'; +import {identifyOmarchy, type OmarchyProfile} from './omarchy.js'; +import {nativeAdapters, type PlatformProfile} from './runtime.js'; +import {createTerminalLaunch} from './terminals.js'; + +type Readiness = 'ready' | 'unavailable' | 'unverified'; +type PreferenceValue = 'auto' | 'on' | 'off'; +type Preference = {value: PreferenceValue; source: 'environment' | 'library' | 'default'; status: 'available' | 'unverified'; message: string}; +type ProbeResult = {output: string; code: number | null; timedOut: boolean; truncated: boolean}; +type Probe = (command: string, args: string[], env: NodeJS.ProcessEnv) => Promise; +type Options = { + host: PlatformProfile; + env?: NodeJS.ProcessEnv; + libraryPath(): string; + resolve?: (command: string) => string | undefined; + omarchy?: () => OmarchyProfile; + mpv?: () => ReturnType; + terminalPlan?: () => {terminal: string; command: string}; + run?: Probe; + isSocket?: (path: string) => boolean; + airPlay?: () => Promise; +}; + +const probeTimeoutMs = 600; +const maxProbeBytes = 4096; +const unavailableOwner = {status: 'unverified' as const, message: 'RadioCLI media-service registration could not be checked.'}; +const unavailableBus = {status: 'unverified' as Readiness, message: 'The local session bus could not be checked.'}; + +/** Explicit doctor checks only. No library migrations, service activation, + * terminal launches, MPRIS registration, LAN discovery, or receiver connection. */ +export async function probeNativeIntegrations(options: Options) { + const {host} = options; + const platform = host.platform as NodeJS.Platform; + const env = options.env ?? process.env; + const native = platform === process.platform; + const resolve = options.resolve ?? (native ? (command: string) => resolveCommandDetails(command, {platform, env}).path ?? undefined : () => undefined); + const safeResolve = (command: string) => attempt(() => resolve(command), undefined); + const run = options.run ?? runProbe; + const mayRun = native || Boolean(options.run); + const path = attempt(options.libraryPath, undefined); + const saved = readSettingsWithoutMigration(path); + const preference = readPreference(saved, env); + const airPlayConsent = airPlayPolicy({linuxAirPlayEnabled: saved?.linuxAirPlayEnabled === true}, {platform, env, release: host.release}); + const profile = attempt(() => options.omarchy ? options.omarchy() + : native || host.id !== 'linux' ? identifyOmarchy({platform, env}) : null, null); + const omarchy = publicOmarchy(profile, env); + const policy = mediaPolicy(preference, profile, {...options, env, resolve: safeResolve}, native); + const terminalLauncher = terminalReadiness(options, env, safeResolve, native); + const [bus, audio, airPlay] = await Promise.all([ + sessionBusReadiness(host, env, path, safeResolve, run, mayRun).catch(() => ({sessionBus: unavailableBus, owner: unavailableOwner})), + audioReadiness(options, env, safeResolve, run, mayRun).catch(() => ({ + status: 'unverified' as Readiness, backend: null, wpctl: {installed: Boolean(safeResolve('wpctl')), status: 'unverified' as Readiness}, + pactl: {installed: Boolean(safeResolve('pactl')), status: 'unverified' as Readiness}, message: 'The optional local audio check could not complete.' + })), + Promise.resolve().then(() => options.airPlay ? options.airPlay() : native + ? probeAirPlayReadiness({platform, env, release: host.release, resolve: safeResolve}) + : unverifiedAirPlay(host)).catch(() => unverifiedAirPlay(host)) + ]); + return {omarchy, desktopMedia: {preference, policy, ...bus}, terminalLauncher, audio, airPlay: { + ...airPlay, enabled: airPlayConsent.enabled, state: airPlayConsent.state, prerequisitesReady: airPlay.ready, + ready: airPlayConsent.enabled && airPlay.ready, + message: airPlayConsent.enabled ? airPlay.message : `${airPlayConsent.message} ${airPlay.message}` + }}; +} + +/** Doctor supplies a bounded read-only runner; scheduler installation and alarm + * execution keep their existing adapters and lifetimes. */ +export async function probeDoctorScheduler(host: PlatformProfile, env: NodeJS.ProcessEnv = process.env): Promise { + if (host.platform !== process.platform) return undefined; + const failed: SchedulerCapabilities = {supported: false, exactWake: false, catchUpAfterWake: false, message: 'The optional scheduler probe failed. Run radiocli alarm doctor for repair guidance.'}; + try { + const childEnv = probeEnvironment(env); + const platform = host.platform as NodeJS.Platform; + let userBusAddress: string | undefined; + let unverified = false; + if (host.id === 'linux') { + const fallback = localPath(env.XDG_RUNTIME_DIR) ?? (process.getuid ? `/run/user/${process.getuid()}` : null); + const socket = sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? (fallback ? `unix:path=${encodeURIComponent(posix.join(fallback, 'bus'))}` : '')); + if (!socket) return {...failed, name: 'systemd', message: 'The systemd user-manager check requires a supported local Unix session-bus address.'}; + childEnv.DBUS_SESSION_BUS_ADDRESS = `unix:${socket.startsWith('\0') ? 'abstract' : 'path'}=${encodeURIComponent(socket.startsWith('\0') ? socket.slice(1) : socket)}`; + userBusAddress = childEnv.DBUS_SESSION_BUS_ADDRESS; + } + const adapter = createSchedulerAdapter({platform, env: childEnv, run: async (command, args) => { + let executable: string | null; + if (userBusAddress) { + if (command !== 'systemctl' || args.join('\0') !== ['--user', 'show', '--property=Version', '--value'].join('\0')) throw new Error('Doctor permits only the read-only user-manager version check.'); + executable = resolveCommandDetails('busctl', {platform, env: childEnv}).path; + // systemctl can activate the user manager. Without a no-activation + // query tool, installed scheduler tooling remains unverified. + if (!executable) unverified = true; + // Older busctl get-property ignores --auto-start=no. The explicit + // Properties.Get method call applies the message flag consistently. + args = [`--address=${userBusAddress}`, '--auto-start=no', '--timeout=2', 'call', 'org.freedesktop.systemd1', '/org/freedesktop/systemd1', 'org.freedesktop.DBus.Properties', 'Get', 'ss', 'org.freedesktop.systemd1.Manager', 'Version']; + } else executable = resolveCommandDetails(command, {platform, env: childEnv}).path; + if (!executable) return {code: 1, stdout: '', stderr: 'The native scheduler command is unavailable.'}; + // Keep Linux below the adapter's 3-second result deadline; a Windows + // PowerShell bootstrap needs more time than the short audio/bus probes. + const result = await runProbe(executable, args, childEnv, platform === 'win32' ? 10_000 : 2_500); + const ready = usable(result) && (!userBusAddress || /^v\s+s\s+"[^"\r\n]{1,256}"\s*$/.test(result.output.trim())); + // A headless manager may expose only systemd/private. Failure on a + // guessed bus address cannot establish that this manager is unavailable. + if (!ready && userBusAddress && env.DBUS_SESSION_BUS_ADDRESS === undefined) unverified = true; + return ready ? {code: 0, stdout: result.output, stderr: ''} + : {code: 1, stdout: '', stderr: 'The native scheduler probe failed or exceeded its read-only limits.'}; + }}); + const result = await adapter.probeCapabilities?.(); + return unverified ? undefined : result; + } catch { return failed; } +} + +function readPreference(saved: Record | undefined, env: NodeJS.ProcessEnv): Preference { + const override = env.RADIOCLI_DESKTOP_MEDIA; + if (validPreference(override)) return {value: override, source: 'environment', status: 'available', message: 'RADIOCLI_DESKTOP_MEDIA selects the preference for this process.'}; + const defaults: Preference = {value: 'auto', source: 'default', status: 'available', message: 'No saved desktop media preference; the default is auto.'}; + const unknown: Preference = {value: 'auto', source: 'default', status: 'unverified', message: 'The saved desktop media preference could not be safely read; auto is shown as an unverified default.'}; + if (!saved) return unknown; + const value = saved.desktopMediaControls; + if (value === undefined) return defaults; + return validPreference(value) ? {value, source: 'library', status: 'available', message: 'The saved desktop media preference was read without changing the library.'} : unknown; +} + +function mediaPolicy(preference: Preference, profile: OmarchyProfile | null, options: Options & {env: NodeJS.ProcessEnv; resolve: NonNullable}, native: boolean) { + const unverified = {eligible: false, status: 'unverified' as Readiness, message: 'Desktop media policy could not be verified; existing playback is unaffected.'}; + if (preference.status === 'unverified') return unverified; + if (preference.value === 'auto' && options.host.id === 'linux' && !profile) return unverified; + try { + const policy = desktopMediaPolicy({desktopMediaControls: preference.value}, { + platform: options.host.platform as NodeJS.Platform, env: options.env, + omarchy: () => profile!, + mpv: options.mpv ?? (() => native ? planMpvDesktopMedia({platform: options.host.platform as NodeJS.Platform, env: options.env, executable: options.resolve('mpv')}) + : {status: 'unsupported', args: [], reason: 'The selected host mpv configuration has not been inspected.'}) + }); + return {eligible: policy.enabled, status: policy.enabled ? 'ready' as const : 'unavailable' as const, + message: policy.enabled ? 'Current policy permits desktop media controls; this does not establish an active media-service owner.' : policy.reason}; + } catch { return unverified; } +} + +function publicOmarchy(profile: OmarchyProfile | null, env: NodeJS.ProcessEnv) { + const known = new Set(['omarchy-launch-tui', 'setsid', 'uwsm-app', 'xdg-terminal-exec', 'local-wayland-session', 'session-bus']); + const missing = new Set(profile?.terminal.missing.filter(item => known.has(item)) ?? []); + if (profile?.kind === 'quattro' && !sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? '')) missing.add('session-bus'); + return { + status: !profile ? 'unverified' as const : profile.kind === 'none' ? 'absent' as const : 'detected' as const, + kind: profile?.kind ?? 'unknown', + version: profile?.version && /^(?:(?:\d+:)?\d+\.\d+\.\d+(?:[.a-z\d-]*)?|dev(?: \([a-f\d]{7,40}\))?)$/.test(profile.version) && profile.version.length < 100 ? profile.version : null, + major: profile?.major ?? null, + source: profile?.source ?? null, + packageName: profile?.packageName === 'omarchy' || profile?.packageName === 'omarchy-dev' ? profile.packageName : null, + session: profile ? {type: profile.session.type, remote: profile.session.remote, localDesktop: profile.session.localDesktop} : null, + terminal: {prerequisitesReady: Boolean(profile?.terminal.available && missing.size === 0), missing: [...missing]} + }; +} + +function terminalReadiness(options: Options, env: NodeJS.ProcessEnv, resolve: NonNullable, native: boolean) { + const base = {selected: null as string | null, prerequisites: 'unverified' as Readiness, launch: 'unverified' as const}; + if (!native && !options.terminalPlan) return {...base, message: 'Terminal selection on the requested host has not been inspected.'}; + try { + const plan = options.terminalPlan ? options.terminalPlan() + : createTerminalLaunch({platform: options.host.platform as NodeJS.Platform, env, resolve, nodePath: process.execPath, args: []}); + const selected = terminalName(plan.terminal); + const ready = Boolean(selected && resolve(plan.command) && (selected !== 'linux:omarchy-launch-tui' || sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? ''))); + if (ready && selected?.startsWith('darwin:') && selected !== 'darwin:apple-terminal') { + return {...base, selected, message: 'The macOS launch route is selected; the target terminal application has not been inspected, and no window was opened.'}; + } + return {...base, selected, prerequisites: ready ? 'ready' as const : 'unavailable' as const, + message: ready ? 'A terminal launcher and its prerequisites were found; no window was opened and TUI startup remains unverified.' : 'The selected terminal launcher is unavailable; open RadioCLI in the current terminal.'}; + } catch { + return {...base, prerequisites: 'unavailable' as const, message: 'No usable terminal launch plan was found; terminal selection is independent from the background scheduler.'}; + } +} + +function terminalName(selector: string): string | null { + const [platform, ...parts] = selector.split(':'); + if (!['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd'].includes(platform ?? '')) return null; + const name = posix.basename(parts.join(':')); + return ['apple-terminal', 'iterm', 'windows-terminal', 'console', 'omarchy-launch-tui', 'ghostty', 'wezterm', 'kitty', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'x-terminal-emulator', 'alacritty', 'foot', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'xterm', 'uxterm'].includes(name) ? `${platform}:${name}` : null; +} + +async function sessionBusReadiness(host: PlatformProfile, env: NodeJS.ProcessEnv, libraryPath: string | undefined, resolve: NonNullable, run: Probe, mayRun: boolean) { + if (host.id !== 'linux') return {sessionBus: {status: 'unavailable' as const, message: 'RadioCLI desktop media controls require Linux.'}, owner: unavailableOwner}; + const socket = sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? ''); + if (!socket) return {sessionBus: {status: 'unavailable' as const, message: 'No supported local Unix session-bus address is configured.'}, owner: unavailableOwner}; + const command = resolve('busctl'); + if (!command || !mayRun) return {sessionBus: {...unavailableBus, message: 'A local Unix bus is configured; busctl is unavailable for a read-only readiness check.'}, owner: unavailableOwner}; + // Rebuild exactly one Unix transport. Never forward fallback unixexec, TCP, + // autolaunch, GUIDs, or unrecognized parameters to the D-Bus parser. + const address = `unix:${socket.startsWith('\0') ? 'abstract' : 'path'}=${encodeURIComponent(socket.startsWith('\0') ? socket.slice(1) : socket)}`; + const name = libraryPath ? mprisServiceName(absolutePath(libraryPath)) : 'org.freedesktop.DBus'; + const result = await run(command, [`--address=${address}`, '--auto-start=no', '--timeout=0.5', 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'NameHasOwner', 's', name], probeEnvironment(env)); + const owner = usable(result) && /^b (true|false)\s*$/.exec(result.output.trim()); + if (!owner) return {sessionBus: {status: 'unavailable' as const, message: 'The local session bus did not return a usable response within the bounded check.'}, owner: unavailableOwner}; + return {sessionBus: {status: 'ready' as const, message: 'The local session bus answered a read-only query without service activation.'}, + owner: !libraryPath ? unavailableOwner : owner[1] === 'true' + ? {status: 'registered' as const, message: 'A RadioCLI media-service owner is registered for this library; playback state was not inspected.'} + : {status: 'absent' as const, message: 'No RadioCLI media-service owner is currently registered for this library.'}}; +} + +async function audioReadiness(options: Options, env: NodeJS.ProcessEnv, resolve: NonNullable, run: Probe, mayRun: boolean) { + const linux = options.host.id === 'linux'; + const wpctl = linux ? resolve('wpctl') : undefined; + const pactl = linux ? resolve('pactl') : undefined; + const socket = options.isSocket ?? (options.host.platform === process.platform ? isSocket : () => false); + const runtime = localPath(env.XDG_RUNTIME_DIR); + const pipewireRuntime = localPath(env.PIPEWIRE_RUNTIME_DIR) ?? runtime; + const pipewireName = env.PIPEWIRE_REMOTE ?? 'pipewire-0'; + const pipewireSocket = pipewireName.startsWith('/') ? localPath(pipewireName) + : /^[\w.-]{1,100}$/.test(pipewireName) && !['.', '..', 'internal'].includes(pipewireName) && pipewireRuntime ? posix.join(pipewireRuntime, pipewireName) : null; + const pulseSocket = env.PULSE_SERVER ? (env.PULSE_SERVER.startsWith('unix:') ? localPath(env.PULSE_SERVER.slice(5)) : null) + : runtime ? posix.join(runtime, 'pulse', 'native') : null; + const check = async (command: string | undefined, path: string | null, args: string[], extra: NodeJS.ProcessEnv, pattern: RegExp): Promise => { + if (!command || !path || !socket(path)) return 'unavailable'; + if (!mayRun) return 'unverified'; + try { const result = await run(command, args, {...probeEnvironment(env), ...extra}); return usable(result) && pattern.test(result.output.trim()) ? 'ready' : 'unavailable'; } + catch { return 'unverified'; } + }; + const [pipewire, pulse] = linux ? await Promise.all([ + check(wpctl, pipewireSocket, ['get-volume', '@DEFAULT_AUDIO_SINK@'], {PIPEWIRE_REMOTE: pipewireSocket ?? '', PIPEWIRE_DAEMON: 'false'}, /^Volume:\s*\d+(?:\.\d+)?(?:\s+\[MUTED\])?$/), + // libpulse disables autospawn when a server is explicitly supplied: + // https://github.com/pulseaudio/pulseaudio/blob/master/src/pulse/context.c + check(pactl, pulseSocket, [`--server=unix:${pulseSocket ?? ''}`, 'get-sink-volume', '@DEFAULT_SINK@'], {PULSE_SERVER: `unix:${pulseSocket ?? ''}`}, /^Volume:.*\b\d+%/) + ]) : ['unverified', 'unverified'] as const; + const backend = pipewire === 'ready' ? 'pipewire' as const : pulse === 'ready' ? 'pulse' as const : null; + const status: Readiness = backend ? 'ready' : !linux || pipewire === 'unverified' || pulse === 'unverified' ? 'unverified' : 'unavailable'; + return {status, backend, wpctl: {installed: Boolean(wpctl), status: pipewire}, pactl: {installed: Boolean(pactl), status: pulse}, + message: backend ? 'A local audio server answered a read-only default-output query; audible playback and alarm-volume restoration still require acceptance checks.' + : !linux ? 'This bounded audio-server check applies to Linux; playback readiness is checked when tuning.' + : status === 'unverified' ? 'Local audio-server readiness could not be verified; installed tools alone do not establish usable audio.' + : 'No local audio server answered the default-output check; installed tools alone do not establish usable audio.'}; +} + +function unverifiedAirPlay(host: PlatformProfile): AirPlayReadiness { + return {eligible: nativeAdapters(host).airPlay, discoveryExecutable: false, discoveryService: 'unverified', senderLoaded: false, ffmpeg: false, ready: false, + message: 'The optional AirPlay readiness check could not complete; local playback is unaffected.'}; +} + +function probeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {...env, LC_ALL: 'C'}; + // Native clients must not inherit remote transports, autolaunch addresses, + // daemon switches, or debug paths from optional integration overrides. + for (const key of Object.keys(result)) if (/^(?:DBUS_|PIPEWIRE_|PULSE_)/.test(key)) delete result[key]; + return result; +} + +function localPath(value: string | undefined): string | null { return value && value.startsWith('/') && value.length < 108 && !/[\s\0-\x1f\x7f;,]/.test(value) ? posix.normalize(value) : null; } +function isSocket(path: string): boolean { return attempt(() => statSync(path).isSocket(), false); } +function usable(result: ProbeResult): boolean { return result.code === 0 && !result.timedOut && !result.truncated && Buffer.byteLength(result.output) <= maxProbeBytes; } +function validPreference(value: unknown): value is PreferenceValue { return value === 'auto' || value === 'on' || value === 'off'; } +function attempt(read: () => T, fallback: T): T { try { return read(); } catch { return fallback; } } + +/** Combined stdout/stderr cap and an unconditional SIGKILL deadline. Stderr and + * command errors remain private; callers only publish interpreted facts. */ +function runProbe(command: string, args: string[], env: NodeJS.ProcessEnv, timeoutMs = probeTimeoutMs): Promise { + return runAirPlayCommand(command, args, timeoutMs, maxProbeBytes, {env}); +} diff --git a/src/platform/json-worker.ts b/src/platform/json-worker.ts new file mode 100644 index 0000000..d15c81b --- /dev/null +++ b/src/platform/json-worker.ts @@ -0,0 +1,101 @@ +import {Worker} from 'node:worker_threads'; +import {setImmediate as yieldToEventLoop} from 'node:timers/promises'; + +const largeJsonCharacters = 256 * 1024; +const maxWorkers = 2; +const maxWaitingJobs = 8; +let activeWorkers = 0; +const waiting: Array<() => void> = []; + +// Static code supports source and packed builds; values never become code. +const source = ` +const {parentPort, workerData} = require('node:worker_threads'); +try { + const value = workerData.operation === 'parse' ? JSON.parse(workerData.value) : JSON.stringify(workerData.value); + parentPort.postMessage({value}); +} catch (error) { + parentPort.postMessage({error: error.message}); +} +parentPort.close(); +`; + +class WorkerUnavailableError extends Error {} + +export function parseJsonAsync(value: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return value.length < largeJsonCharacters ? Promise.resolve(JSON.parse(value)) : jsonInWorker('parse', value, signal); +} + +export async function stringifyJsonAsync(value: unknown, large: boolean, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return large ? jsonInWorker('stringify', value, signal) : JSON.stringify(value); +} + +async function jsonInWorker(operation: 'parse' | 'stringify', value: unknown, signal?: AbortSignal): Promise { + await acquireWorker(signal); + try { + signal?.throwIfAborted(); + try { + return await new Promise((resolve, reject) => { + let worker: Worker; + try { worker = new Worker(source, {eval: true, workerData: {operation, value}, execArgv: []}); } + catch (error) { reject(new WorkerUnavailableError('JSON workers are unavailable.', {cause: error})); return; } + let settled = false; + const finish = (error?: unknown, result?: T) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + signal?.removeEventListener('abort', aborted); + void worker.terminate().catch(() => undefined); + if (error !== undefined) reject(error); + else resolve(result as T); + }; + const aborted = () => finish(signal!.reason); + const timeout = setTimeout(() => finish(new Error('JSON processing timed out.')), 30_000); + signal?.addEventListener('abort', aborted, {once: true}); + worker.once('message', (result: {value?: T; error?: string}) => { + if (result.error) finish(new SyntaxError(result.error)); + else finish(undefined, result.value); + }); + worker.once('error', error => finish(error)); + worker.once('exit', code => finish(new Error(`JSON worker exited before completing (${code}).`))); + if (signal?.aborted) aborted(); + }); + } catch (error) { + signal?.throwIfAborted(); + if (!(error instanceof WorkerUnavailableError)) throw error; + // Restricted runtimes remain functional. Yield first and honor abort; + // these environments necessarily perform the final parse on this thread. + await yieldToEventLoop(); + signal?.throwIfAborted(); + return (operation === 'parse' ? JSON.parse(value as string) : JSON.stringify(value)) as T; + } + } finally { + activeWorkers -= 1; + waiting.shift()?.(); + } +} + +function acquireWorker(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + if (activeWorkers < maxWorkers) { + activeWorkers += 1; + return Promise.resolve(); + } + if (waiting.length >= maxWaitingJobs) return Promise.reject(new Error('JSON processing is busy. Retry after the current requests finish.')); + return new Promise((resolve, reject) => { + const start = () => { + signal?.removeEventListener('abort', aborted); + activeWorkers += 1; + resolve(); + }; + const aborted = () => { + const index = waiting.indexOf(start); + if (index >= 0) waiting.splice(index, 1); + signal?.removeEventListener('abort', aborted); + reject(signal!.reason); + }; + waiting.push(start); + signal?.addEventListener('abort', aborted, {once: true}); + }); +} diff --git a/src/platform/launch-command.test.ts b/src/platform/launch-command.test.ts index 624f3f1..233620f 100644 --- a/src/platform/launch-command.test.ts +++ b/src/platform/launch-command.test.ts @@ -120,7 +120,7 @@ describe('launch path identity', () => { }); it('clears absent application overrides inherited from a persistent launcher', () => { - const keys = ['RADIOCLI_MPV_PATH', 'RADIOCLI_FFPLAY_PATH', 'RADIOCLI_VLC_PATH', 'RADIOCLI_FFMPEG_PATH', 'RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH', 'RADIOCLI_ALARM_TERMINAL']; + const keys = ['RADIOCLI_MPV_PATH', 'RADIOCLI_FFPLAY_PATH', 'RADIOCLI_VLC_PATH', 'RADIOCLI_FFMPEG_PATH', 'RADIOCLI_DESKTOP_MEDIA', 'RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH', 'RADIOCLI_ALARM_TERMINAL']; const args = ['-e', `process.stdout.write(JSON.stringify(Object.fromEntries(${JSON.stringify(keys)}.map(key => [key, process.env[key] ?? null]))))`]; const inherited = Object.fromEntries(keys.map(key => [key, 'stale-launcher-value'])); const output = run(nodeLaunchCommand(process.execPath, args, launchEnvironment({}, {platform: process.platform})), inherited); @@ -133,6 +133,21 @@ describe('launch path identity', () => { }); }); + it('captures a managed desktop-media override and preserves it through the encoded command',()=>{ + const saved=launchEnvironment({RADIOCLI_DESKTOP_MEDIA:'off'},{platform:'linux'}); + const args=['-e',"process.stdout.write(JSON.stringify(process.env.RADIOCLI_DESKTOP_MEDIA))"]; + expect(run(nodeLaunchCommand(process.execPath,args,saved),{RADIOCLI_DESKTOP_MEDIA:'on'})).toBe('off'); + }); + + it.each([{}, {DISPLAY:':2',WAYLAND_DISPLAY:'wayland-8',DBUS_SESSION_BUS_ADDRESS:'unix:path=/run/new/bus',XDG_RUNTIME_DIR:'/run/new'}])('inherits only the current desktop connection when explicitly requested: %j',desktop=>{ + const saved=launchEnvironment({HOME:'/home/saved',RADIOCLI_HOME:'/data/saved',DISPLAY:':0',WAYLAND_DISPLAY:'wayland-old',DBUS_SESSION_BUS_ADDRESS:'unix:path=/old/bus',XDG_RUNTIME_DIR:'/old/runtime',RADIOCLI_DESKTOP_MEDIA:'off'},{platform:'linux',includeDesktop:true,inheritDesktop:true}); + expect(saved).toEqual({HOME:'/home/saved',RADIOCLI_HOME:'/data/saved',RADIOCLI_DESKTOP_MEDIA:'off'}); + const keys=['HOME','RADIOCLI_HOME','RADIO_ATLAS_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','DISPLAY','WAYLAND_DISPLAY','DBUS_SESSION_BUS_ADDRESS','XDG_RUNTIME_DIR','RADIOCLI_DESKTOP_MEDIA']; + const args=['-e',`process.stdout.write(JSON.stringify(Object.fromEntries(${JSON.stringify(keys)}.filter(key=>process.env[key]!==undefined).map(key=>[key,process.env[key]]))))`]; + const inherited={...desktop,HOME:'/home/manager',RADIOCLI_HOME:'/data/wrong',RADIO_ATLAS_HOME:'/legacy/wrong',XDG_DATA_HOME:'/data/wrong',XDG_CACHE_HOME:'/cache/wrong',RADIOCLI_DESKTOP_MEDIA:'on'}; + expect(run(nodeLaunchCommand(process.execPath,args,saved,{inheritDesktop:true}),inherited)).toEqual({...saved,...desktop}); + }); + 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: ''}); }); diff --git a/src/platform/launch-command.ts b/src/platform/launch-command.ts index 1c512e9..4114d26 100644 --- a/src/platform/launch-command.ts +++ b/src/platform/launch-command.ts @@ -8,16 +8,17 @@ const applicationEnvironmentKeys = [ 'RADIOCLI_FFPLAY_PATH', 'RADIOCLI_VLC_PATH', 'RADIOCLI_FFMPEG_PATH', + 'RADIOCLI_DESKTOP_MEDIA', '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. */ +/** Persist application identity; UWSM jobs can inherit the manager's live desktop. */ export function launchEnvironment( env: NodeJS.ProcessEnv, - options: {includeDesktop?: boolean; terminal?: string; platform?: string; cwd?: string} = {} + options: {includeDesktop?: boolean; inheritDesktop?: boolean; terminal?: string; platform?: string; cwd?: string} = {} ): Record { const result: Record = {}; const platform = options.platform ?? process.platform; @@ -38,7 +39,7 @@ export function launchEnvironment( 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]); + for (const key of pathEnvironmentKeys) if(!options.inheritDesktop||key!=='XDG_RUNTIME_DIR')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. @@ -46,13 +47,13 @@ export function launchEnvironment( capturePath('USERPROFILE', homedir()); } for (const key of applicationEnvironmentKeys) add(key, env[key]); - if (options.includeDesktop) for (const key of desktopEnvironmentKeys) add(key, env[key]); + if (options.includeDesktop&&!options.inheritDesktop) 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[] { +export function nodeLaunchCommand(nodePath: string, args: readonly string[], environment: Record,options:{inheritDesktop?:boolean}={}): 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.'); } @@ -61,13 +62,14 @@ export function nodeLaunchCommand(nodePath: string, args: readonly string[], env // 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, ...applicationEnvironmentKeys, 'RADIOCLI_ALARM_TERMINAL'].map(key => `'${key}'`).join(','); + // Only explicitly opted-in jobs inherit the current manager's runtime root. + const keys = [...pathEnvironmentKeys, ...applicationEnvironmentKeys, 'RADIOCLI_ALARM_TERMINAL'].filter(key=>!options.inheritDesktop||key!=='XDG_RUNTIME_DIR').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 { +export function waitForLaunch(child: ChildProcess, options: {waitForExit?: boolean; exitTimeoutMs?: number} = {}): Promise { return new Promise((resolve, reject) => { let settled = false; let acceptedTimer: NodeJS.Timeout | undefined; @@ -108,7 +110,7 @@ export function waitForLaunch(child: ChildProcess, options: {waitForExit?: boole child.once('spawn', () => { if (options.waitForExit) { clearTimeout(startupTimer); - acceptedTimer = setTimeout(() => stop(new Error('Launch bootstrap did not complete.')), 10_000); + acceptedTimer = setTimeout(() => stop(new Error('Launch bootstrap did not complete.')), options.exitTimeoutMs ?? 10_000); } else { child.unref(); acceptedTimer = setTimeout(() => finish(), 100); diff --git a/src/platform/mpris.native.test.ts b/src/platform/mpris.native.test.ts new file mode 100644 index 0000000..7e477ce --- /dev/null +++ b/src/platform/mpris.native.test.ts @@ -0,0 +1,110 @@ +import {execFile} from 'node:child_process'; +import {randomUUID} from 'node:crypto'; +import {promisify} from 'node:util'; +import {describe, expect, it} from 'vitest'; +import {resolveDesktopTransport, serializeRadioCommands, type RadioSessionCommand} from '../agent/session.js'; +import type {Station} from '../types.js'; +import {mprisServiceName, startMpris, type MprisSnapshot} from './mpris.js'; + +const run = promisify(execFile); +const path = '/org/mpris/MediaPlayer2'; +const player = 'org.mpris.MediaPlayer2.Player'; +const properties = 'org.freedesktop.DBus.Properties'; + +/** Required Linux CI invokes this file under dbus-run-session. Other platform + * suites skip the native transport while retaining the deterministic tests. */ +describe.skipIf(process.platform !== 'linux' || !process.env.DBUS_SESSION_BUS_ADDRESS)('native Linux MPRIS protocol', () => { + it('serves typed properties and controls, keeps ownership, and releases the bus on suspension/disposal', async () => { + const identity = `radiocli-native-${randomUUID()}`; + const name = mprisServiceName(identity); + const stations: Station[] = [ + {provider: 'playlist', id: 'one', name: 'One', tags: [], streamUrl: 'https://secret:password@example.test/stream?token=private'}, + {provider: 'playlist', id: 'two', name: 'Two', tags: []} + ]; + const state: MprisSnapshot = { + available: true, owner: 'headless', station: stations[0]!, queue: stations, + playback: {backend: 'mpv', state: 'playing', ready: true, volume: 42, muted: false}, + title: 'Native radio title' + }; + const commands: RadioSessionCommand[] = []; + const bridge = startMpris({identity, address: process.env.DBUS_SESSION_BUS_ADDRESS!, snapshot: () => state, command: serializeRadioCommands(async command => { + commands.push(command); + if (command.type === 'desktop-transport') { + const resolved = resolveDesktopTransport(command.action, state); + if (!resolved) return {ok: true, message: 'State unchanged.', status: state}; + command = resolved; + } + if (command.type === 'pause') state.playback.state = 'paused'; + if (command.type === 'resume') state.playback.state = 'playing'; + if (command.type === 'stop') { state.playback.state = 'stopped'; state.playback.ready = false; } + if (command.type === 'resume' && command.restartStopped) state.playback.ready = true; + if (command.type === 'next') { state.station = stations[1]!; if (!command.preservePause) state.playback.state = 'playing'; } + if (command.type === 'set-volume') { state.playback.volume = command.volume; if (command.unmute && command.volume > 0) state.playback.muted = false; } + return {ok: true, message: 'Handled by owner.', status: state}; + })}); + let contender: ReturnType | undefined; + try { + await until(async () => (await names()).includes(name)); + const all = await call(name, `${properties}.GetAll`, `string:${player}`); + expect(all).toContain('Playing'); expect(all).toContain('Native radio title'); + expect(all).toContain('double 0.42'); expect(all).not.toContain('private'); expect(all).not.toContain('password'); + expect(all).not.toContain('mpris:length'); expect(all).toContain('CanSeek'); + const xml = await call(name, 'org.freedesktop.DBus.Introspectable.Introspect'); + expect(xml).toContain(player); expect(xml).toContain('readwrite'); + + await call(name, `${player}.Pause`); + await call(name, `${player}.Next`); + expect(commands).toContainEqual({type: 'desktop-transport', action: 'next'}); + expect(await call(name, `${properties}.Get`, `string:${player}`, 'string:PlaybackStatus')).toContain('Paused'); + await call(name, `${properties}.Set`, `string:${player}`, 'string:Volume', 'variant:double:0.62'); + expect(commands).toContainEqual({type: 'set-volume', volume: 62, unmute: true}); + expect(await call(name, `${properties}.Get`, `string:${player}`, 'string:Volume')).toContain('double 0.62'); + await call(name, `${player}.Play`); + expect(await call(name, `${properties}.Get`, `string:${player}`, 'string:PlaybackStatus')).toContain('Playing'); + await call(name, `${player}.Stop`); + expect(commands).toContainEqual({type: 'desktop-transport', action: 'stop'}); + expect(await call(name, `${properties}.Get`, `string:${player}`, 'string:CanPlay')).toContain('boolean true'); + await call(name, `${player}.Play`); + expect(commands).toContainEqual({type: 'desktop-transport', action: 'play'}); + state.playback.muted = true; bridge.refresh(); + await call(name, `${properties}.Set`, `string:${player}`, 'string:Volume', 'variant:double:0.38'); + expect(await call(name, `${properties}.Get`, `string:${player}`, 'string:Volume')).toContain('double 0.38'); + expect(state.playback.muted).toBe(false); + await call(name, `${player}.Seek`, 'int64:1'); + await call(name, `${player}.SetPosition`, 'objpath:/org/radiocli/track/stale', 'int64:1'); + await call(name, `${properties}.Set`, `string:${player}`, 'string:Rate', 'variant:double:0'); + expect(state.playback.state).toBe('paused'); + await call(name, `${properties}.Set`, `string:${player}`, 'string:Rate', 'variant:double:1'); + + contender = startMpris({identity, address: process.env.DBUS_SESSION_BUS_ADDRESS!, snapshot: () => state, command: async () => { throw new Error('Another owner must not receive this command.'); }}); + await delay(80); + await call(name, `${player}.Pause`); + expect(state.playback.state).toBe('paused'); + contender.close(); + state.available = false; bridge.refresh(); + await until(async () => !(await names()).includes(name)); + state.available = true; bridge.refresh(); + await until(async () => (await names()).includes(name)); + bridge.close(); + await until(async () => !(await names()).includes(name)); + } finally { contender?.close(); bridge.close(); } + }, 15_000); +}); + +async function call(destination: string, method: string, ...args: string[]): Promise { + const result = await run('dbus-send', ['--session', '--print-reply', '--reply-timeout=2000', `--dest=${destination}`, path, method, ...args], {timeout: 3000, maxBuffer: 64 * 1024}); + return result.stdout; +} + +async function names(): Promise { + const result = await run('dbus-send', ['--session', '--print-reply', '--reply-timeout=2000', '--dest=org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus.ListNames'], {timeout: 3000, maxBuffer: 64 * 1024}); + return result.stdout; +} + +async function until(predicate: () => Promise): Promise { + const end = Date.now() + 4000; + while (Date.now() < end) { if (await predicate()) return; await delay(20); } + throw new Error('The native session-bus condition did not become ready.'); +} + +function delay(milliseconds: number): Promise { return new Promise(resolve => setTimeout(resolve, milliseconds)); } diff --git a/src/platform/mpris.test.ts b/src/platform/mpris.test.ts new file mode 100644 index 0000000..309abb1 --- /dev/null +++ b/src/platform/mpris.test.ts @@ -0,0 +1,160 @@ +import {EventEmitter} from 'node:events'; +import {Socket} from 'node:net'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {mprisProperties, mprisServiceName, sessionBusSocket, startMpris, type MprisSnapshot} from './mpris.js'; +import type {RadioSessionCommand} from '../agent/session.js'; + +type WireMessage = {type: number; serial: number; replySerial?: number; interface?: string; member?: string; signature?: string; body?: unknown[]; errorName?: string; destination?: string; sender?: string; path?: string}; +const station = {provider: 'playlist' as const, id: 'https://user:secret@radio.test/private', name: 'RadioCLI station', tags: []}; +const state = (): MprisSnapshot => ({owner: 'tui', station, queue: [station, {...station, id: 'second'}], playback: {backend: 'mpv', state: 'playing', volume: 70, muted: false, ready: true}, available: true}); +const cleanups: Array<() => void> = []; +afterEach(() => { for (const close of cleanups.splice(0)) close(); vi.useRealTimers(); }); +const flush = async () => { for (let count = 0; count < 12; count++) await Promise.resolve(); }; + +async function fixture(nameReply = 1) { + let snapshot = state(); + let nextRequestSerial = 100; + const sent: WireMessage[] = []; + const connections: Array = []; + function makeConnection() { + const emitter = new EventEmitter(); + const connection = Object.assign(emitter, {stream: new Socket(), message(message: WireMessage) { + sent.push(message); + if (message.member === 'Hello' || message.member === 'RequestName') queueMicrotask(() => emitter.emit('message', {type: 2, replySerial: message.serial, body: [message.member === 'Hello' ? ':1.8' : nameReply]})); + }}); + connections.push(connection); + return connection; + } + const command = vi.fn(async (_command: RadioSessionCommand) => ({ok: true, message: 'ok', status: snapshot})); + const connect = vi.fn(async () => makeConnection()); + const bridge = startMpris({identity: '/private/library/radiocli.json', address: 'unix:path=/run/user/1000/bus', snapshot: () => snapshot, command, connect}); + cleanups.push(bridge.close); + await flush(); + connections[0]!.emit('connect'); await flush(); + return {bridge, sent, connections, command, connect, set(value: Partial) { snapshot = {...snapshot, ...value}; }, async request(member: string, extras: Partial = {}) { + const serial = nextRequestSerial++; + connections.at(-1)!.emit('message', {type: 1, serial, sender: ':1.22', path: '/org/mpris/MediaPlayer2', interface: 'org.mpris.MediaPlayer2.Player', member, ...extras}); + await flush(); return sent.find(message => message.replySerial === serial) ?? sent.at(-1)!; + }}; +} + +describe('MPRIS metadata and capabilities', () => { + it('reports real capabilities and omits fake seeking, duration, and private URLs', () => { + const properties = mprisProperties({...state(), title: 'Track https://user:secret@host/path /private/token C:\\Users\\private file:///secret'}); + expect(properties.player).toMatchObject({PlaybackStatus: ['s', 'Playing'], CanSeek: ['b', false], CanPause: ['b', true], CanGoNext: ['b', true], Position: ['x', 0]}); + const serialized = JSON.stringify(properties); + expect(serialized).toContain('Track'); + for (const privateValue of ['secret', '/private', 'Users', 'file:', 'mpris:length', 'xesam:url']) expect(serialized).not.toContain(privateValue); + expect(mprisServiceName('/private/home')).toMatch(/^org\.mpris\.MediaPlayer2\.radiocli\.h[0-9a-f]{24}$/); + expect(mprisServiceName('/private/home')).not.toBe(mprisServiceName('/other/home')); + }); + it.each(['ffplay', 'vlc', 'airplay'])('does not promise pause for %s', backend => { + expect(mprisProperties({...state(), playback: {...state().playback, backend}}).player.CanPause).toEqual(['b', false]); + }); + it('clears metadata/transport flags for errors and loading, and distinguishes pause', () => { + expect(mprisProperties({...state(), playback: {...state().playback, state: 'error', ready: false}}).player).toMatchObject({Metadata: ['a{sv}', []], CanPlay: ['b', false], CanGoNext: ['b', false], PlaybackStatus: ['s', 'Stopped']}); + expect(mprisProperties({...state(), playback: {...state().playback, state: 'paused', muted: true}}).player).toMatchObject({PlaybackStatus: ['s', 'Paused'], Volume: ['d', 0]}); + }); +}); + +describe('local session bus addresses', () => { + it('handles native abstract sockets and escaped absolute paths without a native addon', () => { + expect(sessionBusSocket('unix:abstract=/tmp/dbus-123,guid=abcd')).toBe('\0/tmp/dbus-123'); + expect(sessionBusSocket('unix:path=/run/user/1000/my%20bus,guid=abcd')).toBe('/run/user/1000/my bus'); + }); + it.each(['unixexec:path=/tmp/run', 'tcp:host=localhost,port=1', 'unix:path=relative', 'unix:path=/ok,path=/bad', 'unix:path=/ok,abstract=bad', 'unix:path=/bad%00', 'unix:path=/bad%ZZ', `unix:path=/${'x'.repeat(108)}`])('rejects %s', address => { expect(sessionBusSocket(address)).toBeNull(); }); +}); + +describe('MPRIS wire protocol and lifecycle', () => { + it('claims a stable name without replacing or queueing for another owner', async () => { + const app = await fixture(); + expect(app.sent.find(message => message.member === 'RequestName')).toMatchObject({signature: 'su', body: [mprisServiceName('/private/library/radiocli.json'), 4]}); + expect((await app.request('Get', {interface: 'org.freedesktop.DBus.Properties', signature: 'ss', body: ['org.mpris.MediaPlayer2.Player', 'CanSeek']}))).toMatchObject({type: 2, signature: 'v', body: [['b', false]]}); + expect((await app.request('GetAll', {interface: 'org.freedesktop.DBus.Properties', signature: 's', body: ['org.mpris.MediaPlayer2']}))).toMatchObject({type: 2, signature: 'a{sv}'}); + const xml = await app.request('Introspect', {interface: 'org.freedesktop.DBus.Introspectable'}); + expect(xml.body?.[0]).toContain('type="x"'); + expect(xml.body?.[0]).toContain('name="Volume" type="d" access="readwrite"'); + }); + it('sends queue actions with paused selection semantics to the real owner', async () => { + const app = await fixture(); + await app.request('Next'); await app.request('Previous'); await app.request('Pause'); + app.set({playback: {...state().playback, state: 'paused'}}); + await app.request('PlayPause'); + expect(app.command.mock.calls.map(([command]) => command)).toEqual(['next', 'previous', 'pause', 'toggle'].map(action => ({type: 'desktop-transport', action}))); + }); + it('validates property signatures, preserves unsupported controls, and clamps volume', async () => { + const app = await fixture(); + expect(await app.request('Seek', {signature: 'x', body: [50]})).toMatchObject({type: 2}); + expect(await app.request('SetPosition', {signature: 'ox', body: ['/org/radiocli/stale', 50]})).toMatchObject({type: 2}); + expect(await app.request('Set', {interface: 'org.freedesktop.DBus.Properties', signature: 'ssv', body: ['org.mpris.MediaPlayer2.Player', 'Volume', [[{type: 's', child: []}], ['1']]]})).toMatchObject({type: 3, errorName: 'org.freedesktop.DBus.Error.InvalidArgs'}); + await app.request('Set', {interface: 'org.freedesktop.DBus.Properties', signature: 'ssv', body: ['org.mpris.MediaPlayer2.Player', 'Volume', [[{type: 'd', child: []}], [2]]]}); + expect(app.command).toHaveBeenCalledExactlyOnceWith({type: 'set-volume', volume: 100, unmute: true}); + expect(await app.request('Get', {interface: 'org.freedesktop.DBus.Properties', signature: 'ss', body: ['org.mpris.MediaPlayer2.Player', 'constructor']})).toMatchObject({type: 3}); + }); + it('retains stopped selection and advertises Play/PlayPause and silent queue selection', async () => { + const app = await fixture(); + await app.request('Stop'); + expect(app.command).toHaveBeenLastCalledWith({type: 'desktop-transport', action: 'stop'}); + app.set({playback: {...state().playback, state: 'stopped', ready: false}}); + expect(mprisProperties({...state(), playback: {...state().playback, state: 'stopped', ready: false}}).player).toMatchObject({CanPlay: ['b', true], CanPause: ['b', true], CanGoNext: ['b', true]}); + await app.request('Next'); + expect(app.command).toHaveBeenLastCalledWith({type: 'desktop-transport', action: 'next'}); + await app.request('Play'); + expect(app.command).toHaveBeenLastCalledWith({type: 'desktop-transport', action: 'play'}); + await app.request('PlayPause'); + expect(app.command).toHaveBeenLastCalledWith({type: 'desktop-transport', action: 'toggle'}); + }); + it('implements capability no-ops and treats Rate zero as Pause without changing the rate', async () => { + const app = await fixture(); + await app.request('Set', {interface: 'org.freedesktop.DBus.Properties', signature: 'ssv', body: ['org.mpris.MediaPlayer2.Player', 'Rate', [[{type: 'd', child: []}], [0]]]}); + expect(app.command).toHaveBeenLastCalledWith({type: 'desktop-transport', action: 'pause'}); + expect(await app.request('Set', {interface: 'org.freedesktop.DBus.Properties', signature: 'ssv', body: ['org.mpris.MediaPlayer2.Player', 'Rate', [[{type: 'd', child: []}], [1]]]})).toMatchObject({type: 2}); + app.command.mockClear(); + app.set({station: null, queue: [], playback: {...state().playback, state: 'stopped', ready: false}}); + for (const member of ['Next', 'Previous', 'Pause', 'Play', 'Stop']) expect(await app.request(member)).toMatchObject({type: 2}); + expect(app.command.mock.calls.map(([command]) => command)).toEqual(['next', 'previous', 'pause', 'play', 'stop'].map(action => ({type: 'desktop-transport', action}))); + }); + it('does not publish duplicate unchanged events and clears the service when alarms take ownership', async () => { + const app = await fixture(); + const before = app.sent.length; + app.bridge.refresh(); app.bridge.refresh(); + expect(app.sent).toHaveLength(before); + app.set({available: false}); app.bridge.refresh(); + expect(app.connections[0]!.stream.destroyed).toBe(true); + await app.request('Play'); + expect(app.command).not.toHaveBeenCalled(); + }); + it('never exposes a second service when the data home already has an owner', async () => { + vi.useFakeTimers(); + const app = await fixture(3); + expect(app.connections[0]!.stream.destroyed).toBe(true); + expect(app.sent.some(message => message.member === 'PropertiesChanged')).toBe(false); + await app.request('Next'); expect(app.command).not.toHaveBeenCalled(); + }); + it('reconnects after bus restart and never replies through a replacement connection for an old request', async () => { + vi.useFakeTimers(); + const app = await fixture(); + let complete: (() => void) | undefined; + app.command.mockImplementationOnce(() => new Promise(resolve => { complete = () => resolve({ok: true, message: 'ok', status: state()}); })); + await app.request('Next'); + app.connections[0]!.emit('error', new Error('Restart')); + await vi.advanceTimersByTimeAsync(500); + app.connections[1]!.emit('connect'); await flush(); + const before = app.sent.length; + complete!(); await flush(); + expect(app.sent).toHaveLength(before); + app.bridge.close(); await vi.advanceTimersByTimeAsync(60_000); + expect(app.connect).toHaveBeenCalledTimes(2); + }); + it('ignores authentication finishing after the bridge was disposed', async () => { + vi.useFakeTimers(); + const app = await fixture(); + app.connections[0]!.emit('end'); + await vi.advanceTimersByTimeAsync(500); + app.bridge.close(); + const before = app.sent.length; + app.connections[1]!.emit('connect'); await flush(); + expect(app.sent).toHaveLength(before); + expect(app.connections[1]!.stream.destroyed).toBe(true); + }); +}); diff --git a/src/platform/mpris.ts b/src/platform/mpris.ts new file mode 100644 index 0000000..4ba217d --- /dev/null +++ b/src/platform/mpris.ts @@ -0,0 +1,251 @@ +import {createHash} from 'node:crypto'; +import {createConnection as connectSocket, type Socket} from 'node:net'; +import type {EventEmitter} from 'node:events'; +import type {RadioSessionCommand, RadioSessionResult, RadioSessionStatus} from '../agent/session.js'; + +const objectPath = '/org/mpris/MediaPlayer2'; +const rootInterface = 'org.mpris.MediaPlayer2'; +const playerInterface = `${rootInterface}.Player`; +const propertiesInterface = 'org.freedesktop.DBus.Properties'; +type Variant = [string, unknown]; +type Properties = Record; +type Message = {type: number; serial: number; replySerial?: number; sender?: string; destination?: string; path?: string; interface?: string; member?: string; signature?: string; body?: unknown[]; errorName?: string}; +type Connection = EventEmitter & {stream: Socket; message(message: Message): void}; +export type MprisSnapshot = RadioSessionStatus & {title?: string; available: boolean}; +type Options = { + identity: string; + address: string; + snapshot(): MprisSnapshot; + command(command: RadioSessionCommand): Promise; + connect?: (path: string) => Promise; +}; + +/** Only local Unix transports are accepted. Never hand a unixexec or TCP + * address to the dependency's address parser. Node handles abstract sockets. */ +export function sessionBusSocket(address: string): string | null { + if (address.length > 4096 || /[\0\r\n]/.test(address)) return null; + for (const entry of address.split(';')) { + if (!entry.startsWith('unix:')) continue; + const values = new Map(); + try { + for (const part of entry.slice(5).split(',')) { + const at = part.indexOf('='); + if (at <= 0) throw new Error('Invalid bus address'); + const key = part.slice(0, at); + if (values.has(key)) throw new Error('Duplicate bus address key'); + values.set(key, decodeURIComponent(part.slice(at + 1))); + } + } catch { continue; } + const path = values.get('path'); + const abstract = values.get('abstract'); + if (path && !abstract && path.startsWith('/') && !/[\0\r\n]/.test(path) && Buffer.byteLength(path) < 108) return path; + if (abstract && !path && !/[\0\r\n]/.test(abstract) && Buffer.byteLength(abstract) < 107) return `\0${abstract}`; + } + return null; +} + +export function mprisServiceName(identity: string): string { + return `${rootInterface}.radiocli.h${createHash('sha256').update(identity).digest('hex').slice(0, 24)}`; +} + +export function mprisProperties(snapshot: MprisSnapshot): {root: Properties; player: Properties} { + const {playback, station, queue} = snapshot; + const active = snapshot.available && playback.ready && ['playing', 'paused'].includes(playback.state); + const selected = snapshot.available && Boolean(station) && (active || ['idle', 'stopped'].includes(playback.state)); + const pause = selected && playback.backend === 'mpv'; + const metadata: Array<[string, Variant]> = []; + if (selected && station) { + metadata.push(['mpris:trackid', ['o', `/org/radiocli/track/t${createHash('sha256').update(`${station.provider}\0${station.id}`).digest('hex').slice(0, 24)}`]]); + metadata.push(['xesam:title', ['s', publicText(snapshot.title) || publicText(station.name) || 'RadioCLI']]); + const stationName = publicText(station.name); + if (stationName) metadata.push(['xesam:album', ['s', stationName]]); + } + return { + root: { + CanQuit: ['b', false], CanRaise: ['b', false], HasTrackList: ['b', false], + Identity: ['s', 'RadioCLI'], SupportedUriSchemes: ['as', []], SupportedMimeTypes: ['as', []] + }, + player: { + PlaybackStatus: ['s', active ? playback.state === 'paused' ? 'Paused' : 'Playing' : 'Stopped'], + Rate: ['d', 1], Metadata: ['a{sv}', metadata], Volume: ['d', playback.muted ? 0 : Math.max(0, Math.min(1, playback.volume / 100))], + Position: ['x', 0], MinimumRate: ['d', 1], MaximumRate: ['d', 1], + CanGoNext: ['b', selected && queue.length > 1], CanGoPrevious: ['b', selected && queue.length > 1], + CanPlay: ['b', selected], CanPause: ['b', pause], CanSeek: ['b', false], CanControl: ['b', snapshot.available] + } + }; +} + +function publicText(value: string | undefined): string { + // Broadcast text is untrusted; URI/path-shaped metadata can contain tokens. + return (value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '').replace(/(?:[a-z][a-z0-9+.-]*:\/\/|\bfile:|\b[a-z]:[\\/]|(?:^|\s)[/~\\])\S*/gi, '').trim().slice(0, 512); +} + +/** A small, typed MPRIS endpoint. Closing the private connection releases its + * name, so no ReleaseName race can release a replacement owner's service. */ +export function startMpris(options: Options): {refresh(): void; close(): void} { + const path = sessionBusSocket(options.address); + let disposed = false; + let generation = 0; + let connection: Connection | undefined; + let connected = false; + let registered = false; + let starting = false; + let serial = 1; + let retries = 0; + let timer: NodeJS.Timeout | undefined; + let lastProperties = ''; + const pending = new Map(); + const available = () => !disposed && options.snapshot().available; + const send = (message: Omit): void => { + if (connected && connection && !connection.stream.destroyed) connection.message({...message, serial: serial++}); + }; + const disconnect = () => { + generation += 1; + registered = false; + starting = false; + connected = false; + if (timer) clearTimeout(timer); + timer = undefined; + for (const request of pending.values()) { clearTimeout(request.timer); request.reject(); } + pending.clear(); + connection?.stream.destroy(); + connection = undefined; + }; + const retry = () => { + disconnect(); + if (!available()) return; + timer = setTimeout(() => { timer = undefined; void start(); }, Math.min(30_000, 500 * 2 ** Math.min(6, retries++))); + timer.unref(); + }; + const call = (member: string, signature = '', body: unknown[] = []): Promise => new Promise((resolve, reject) => { + if (!connected || !connection || connection.stream.destroyed) { reject(new Error('Session bus disconnected.')); return; } + const id = serial++; + const timeout = setTimeout(() => { pending.delete(id); reject(new Error('Session bus did not respond.')); }, 2000); + timeout.unref(); + pending.set(id, {resolve, reject: () => reject(new Error('Session bus disconnected.')), timer: timeout}); + connection!.message({type: 1, serial: id, path: '/org/freedesktop/DBus', destination: 'org.freedesktop.DBus', interface: 'org.freedesktop.DBus', member, signature, body}); + }); + const error = (request: Message, name: string, detail: string) => send({type: 3, destination: request.sender, replySerial: request.serial, errorName: `org.freedesktop.DBus.Error.${name}`, signature: 's', body: [detail]}); + const reply = (request: Message, signature = '', body: unknown[] = []) => send({type: 2, destination: request.sender, replySerial: request.serial, signature, body}); + const receive = async (request: Message, current: number) => { + if (current !== generation || disposed) return; + if (request.type === 2 || request.type === 3) { + const handler = request.replySerial === undefined ? undefined : pending.get(request.replySerial); + if (handler) { + pending.delete(request.replySerial!); clearTimeout(handler.timer); + if (request.type === 2) handler.resolve(request.body ?? []); else handler.reject(); + } + return; + } + if (request.type !== 1 || !registered) return; + if (request.path !== objectPath) { error(request, 'UnknownObject', 'Unknown object.'); return; } + try { + if (request.interface === 'org.freedesktop.DBus.Introspectable' && request.member === 'Introspect' && !request.signature) { reply(request, 's', [introspection]); return; } + if (request.interface === 'org.freedesktop.DBus.Peer' && request.member === 'Ping' && !request.signature) { reply(request); return; } + const properties = mprisProperties(options.snapshot()); + if (request.interface === propertiesInterface) { + const [iface, property, value] = request.body ?? []; + const values = iface === rootInterface ? properties.root : iface === playerInterface ? properties.player : null; + if (!values) { error(request, 'UnknownInterface', 'Unknown interface.'); return; } + if (request.member === 'GetAll' && request.signature === 's') { reply(request, 'a{sv}', [Object.entries(values)]); return; } + if (typeof property !== 'string' || !Object.hasOwn(values, property)) { error(request, 'UnknownProperty', 'Unknown property.'); return; } + if (request.member === 'Get' && request.signature === 'ss') { reply(request, 'v', [values[property]]); return; } + if (request.member === 'Set' && request.signature === 'ssv') { + if (iface === playerInterface && property === 'Rate') { + const rate = doubleVariant(value); + if (rate === 1) reply(request); + else if (rate === 0) { + const result = await options.command({type: 'desktop-transport', action: 'pause'}); + if (!result.ok) throw new Error('Control unavailable.'); + if (current === generation) { reply(request); refresh(); } + } else error(request, 'NotSupported', 'Live radio plays at its broadcast rate.'); + return; + } + if (iface !== playerInterface || property !== 'Volume') { error(request, 'PropertyReadOnly', 'Property cannot be changed.'); return; } + const volume = doubleVariant(value); + if (volume === null) { error(request, 'InvalidArgs', 'Volume must be a finite double.'); return; } + const result = await options.command({type: 'set-volume', volume: Math.min(1, Math.max(0, volume)) * 100, unmute: true}); + if (!result.ok) throw new Error('Control unavailable.'); + if (current === generation) { reply(request); refresh(); } + return; + } + error(request, 'InvalidArgs', 'Invalid properties request.'); return; + } + if (request.interface === playerInterface && (request.member === 'Seek' && request.signature === 'x' || request.member === 'SetPosition' && request.signature === 'ox')) { reply(request); return; } + if (request.interface !== playerInterface || request.signature) { error(request, 'NotSupported', 'This action is not supported for live radio.'); return; } + let action: Extract['action'] | undefined; + switch (request.member) { + case 'Next': action = 'next'; break; + case 'Previous': action = 'previous'; break; + case 'Pause': action = 'pause'; break; + case 'Play': action = 'play'; break; + case 'PlayPause': action = 'toggle'; break; + case 'Stop': action = 'stop'; break; + } + if (!action) { error(request, 'NotSupported', 'This action is currently unavailable.'); return; } + const result = await options.command({type: 'desktop-transport', action}); + if (!result.ok) throw new Error('Control unavailable.'); + if (current === generation) { reply(request); refresh(); } + } catch { if (current === generation) error(request, 'Failed', 'RadioCLI could not complete the control request.'); } + }; + const start = async () => { + if (!path || !available() || connection || timer || starting) return; + starting = true; + const current = ++generation; + try { + const created = await (options.connect ?? nativeConnection)(path); + if (disposed || current !== generation || !available()) { created.stream.destroy(); return; } + connection = created; + created.on('error', () => { if (current === generation) retry(); }); + created.on('end', () => { if (current === generation) retry(); }); + created.stream.on('close', () => { if (current === generation) retry(); }); + created.on('message', message => { void receive(message as Message, current); }); + timer = setTimeout(() => { if (current === generation) retry(); }, 2000); + timer.unref(); + created.once('connect', () => { + if (current !== generation || connection !== created || !available()) { created.stream.destroy(); return; } + connected = true; + if (timer) clearTimeout(timer); timer = undefined; + void (async () => { + await call('Hello'); + if (current !== generation) return; + const [result] = await call('RequestName', 'su', [mprisServiceName(options.identity), 4]); + if (current !== generation) return; + // DO_NOT_QUEUE: never replace or wait in the ownership queue. + if (result !== 1) { retry(); return; } + registered = true; starting = false; retries = 0; lastProperties = ''; refresh(); + })().catch(() => { if (current === generation) retry(); }); + }); + } catch { if (current === generation) retry(); } + }; + const refresh = () => { + if (!available()) { disconnect(); return; } + if (!registered) { void start(); return; } + const properties = mprisProperties(options.snapshot()).player; + // Position is not a clock for a live broadcast and does not emit changes. + const changed = Object.entries(properties).filter(([name]) => name !== 'Position' && name !== 'CanControl'); + const encoded = JSON.stringify(changed); + if (encoded === lastProperties) return; + lastProperties = encoded; + send({type: 4, path: objectPath, interface: propertiesInterface, member: 'PropertiesChanged', signature: 'sa{sv}as', body: [playerInterface, changed, []]}); + }; + void start(); + return {refresh, close() { disposed = true; disconnect(); }}; +} + +async function nativeConnection(path: string): Promise { + const module = await import('@homebridge/dbus-native'); + const factory = module.default as unknown as {createConnection(options: {stream: Socket; authMethods: string[]}): Connection}; + const stream = connectSocket(path); + return factory.createConnection({stream, authMethods: ['EXTERNAL']}); +} + +function doubleVariant(value: unknown): number | null { + if (!Array.isArray(value) || !Array.isArray(value[0]) || !Array.isArray(value[1])) return null; + const [type] = value[0] as Array<{type?: string}>; + const [number] = value[1] as unknown[]; + return value[0].length === 1 && type?.type === 'd' && typeof number === 'number' && Number.isFinite(number) ? number : null; +} + +const propertyXml = (values: Record, writable: string[] = []) => Object.entries(values).map(([name, type]) => ``).join(''); +const introspection = `${propertyXml({CanQuit: 'b', CanRaise: 'b', HasTrackList: 'b', Identity: 's', SupportedUriSchemes: 'as', SupportedMimeTypes: 'as'})}${['Next', 'Previous', 'Pause', 'PlayPause', 'Stop', 'Play'].map(name => ``).join('')}${propertyXml({PlaybackStatus: 's', Rate: 'd', Metadata: 'a{sv}', Volume: 'd', Position: 'x', MinimumRate: 'd', MaximumRate: 'd', CanGoNext: 'b', CanGoPrevious: 'b', CanPlay: 'b', CanPause: 'b', CanSeek: 'b', CanControl: 'b'}, ['Volume', 'Rate'])}`; diff --git a/src/platform/network-body.test.ts b/src/platform/network-body.test.ts new file mode 100644 index 0000000..d77a50e --- /dev/null +++ b/src/platform/network-body.test.ts @@ -0,0 +1,68 @@ +import {setImmediate as nextEventLoopTurn} from 'node:timers/promises'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {withExternalResponse} from './network.js'; + +beforeEach(() => { + for (const key of ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy', 'RADIOCLI_OFFLINE']) vi.stubEnv(key, undefined); +}); +afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); + +describe('bounded network bodies and asynchronous JSON', () => { + it('rejects an oversized declared body before invoking the consumer and cancels it', async () => { + const cancel = vi.fn(); + const consume = vi.fn(response => response.json()); + await expect(withExternalResponse('https://example.test', { + timeoutMs: 1000, maxResponseBytes: 16, + fetchImpl: async () => new Response(new ReadableStream({cancel}), {headers: {'content-length': '17'}}) + }, consume)).rejects.toThrow(/safety limit/); + expect(consume).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('counts actual bytes for chunked bodies with missing or deceptive content length', async () => { + for (const declared of [undefined, '1']) { + const cancel = vi.fn(); + const body = new ReadableStream({start(controller) { controller.enqueue(new TextEncoder().encode('東京')); }, cancel}); + await expect(withExternalResponse('https://example.test', { + timeoutMs: 1000, maxResponseBytes: 5, + fetchImpl: async () => new Response(body, {headers: declared ? {'content-length': declared} : {}}) + }, response => response.text())).rejects.toThrow(/5 byte safety limit/); + expect(cancel).toHaveBeenCalledOnce(); + } + }); + + it('retains redirect metadata and makes large JSON parsing yield to input', async () => { + const rows = Array.from({length: 4000}, (_, index) => ({index, title: '東京 · Rádio'.repeat(30)})); + const response = Response.json(rows); + Object.defineProperty(response, 'url', {value: 'https://example.test/redirected'}); + Object.defineProperty(response, 'redirected', {value: true}); + const events: string[] = []; + const reading = withExternalResponse('https://example.test', {timeoutMs: 5000, fetchImpl: async () => response}, async bounded => { + expect(bounded.url).toBe(response.url); + expect(bounded.redirected).toBe(true); + const parsed = await bounded.json(); + events.push('parsed'); + return parsed; + }); + await nextEventLoopTurn(); + events.push('input'); + expect(await reading).toEqual(rows); + expect(events).toEqual(['input', 'parsed']); + }); + + it('cancels a locked custom stream when its caller aborts', async () => { + const controller = new AbortController(); + const reason = new Error('stop'); + const cancel = vi.fn(); + let reading!: () => void; + const ready = new Promise(resolve => { reading = resolve; }); + const body = new ReadableStream({pull() { reading(); return new Promise(() => undefined); }, cancel}); + const request = withExternalResponse('https://example.test', { + timeoutMs: 5000, init: {signal: controller.signal}, fetchImpl: async () => new Response(body) + }, response => response.json()); + await ready; + controller.abort(reason); + await expect(request).rejects.toBe(reason); + expect(cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/platform/network.ts b/src/platform/network.ts index c9d3de8..fe7674e 100644 --- a/src/platform/network.ts +++ b/src/platform/network.ts @@ -1,8 +1,9 @@ import {resolve4, resolve6} from 'node:dns/promises'; import {request as requestHttps, type RequestOptions as HttpsRequestOptions} from 'node:https'; +import {parseJsonAsync} from './json-worker.js'; type FetchLike = (input: string | URL, init?: RequestInit) => Promise; -type ExternalRequestOptions = {timeoutMs: number; init?: RequestInit; fetchImpl?: FetchLike}; +type ExternalRequestOptions = {timeoutMs: number; init?: RequestInit; fetchImpl?: FetchLike; maxResponseBytes?: number}; type ResolvedAddress = {address: string; family: 4 | 6}; type ResolverRuntime = { resolve4?: (hostname: string) => Promise; @@ -40,7 +41,7 @@ export function networkDiagnostic() { /** Keep the request deadline active through body consumption, including errors. */ export async function withExternalResponse( url: string | URL, - {timeoutMs, init = {}, fetchImpl = fetch}: ExternalRequestOptions, + {timeoutMs, init = {}, fetchImpl = fetch, maxResponseBytes = maxResolvedResponseBytes}: ExternalRequestOptions, consume: (response: Response) => T | Promise ): Promise { if (networkPolicy().offline) throw new Error('RadioCLI is offline (RADIOCLI_OFFLINE=1).'); @@ -61,6 +62,7 @@ export async function withExternalResponse( try { // A custom fetch can resolve after the deadline without observing abort. signal.throwIfAborted(); + response = boundedResponse(response, maxResponseBytes, signal); return await consume(response); } finally { cancelBody(response); @@ -76,6 +78,56 @@ export async function withExternalResponse( } } +/** Count actual decoded body bytes, including chunked responses and deceptive + * content lengths. Preserve redirects used by playlist/import consumers. */ +function boundedResponse(response: Response, maxBytes: number, signal: AbortSignal): Response { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Response size limit must be a positive number of bytes.'); + if (!response.body) return response; + const length = Number(response.headers.get('content-length')); + if (Number.isFinite(length) && length > maxBytes) throw responseSizeError(maxBytes); + const reader = response.body.getReader(); + let bytes = 0; + let ended = false; + let controller: ReadableStreamDefaultController; + const cleanup = () => signal.removeEventListener('abort', aborted); + const aborted = () => { + if (ended) return; + ended = true; + cleanup(); + controller.error(signal.reason); + void reader.cancel(signal.reason).catch(() => undefined); + }; + const body = new ReadableStream({ + start(value) { controller = value; signal.addEventListener('abort', aborted, {once: true}); }, + async pull(value) { + try { + const chunk = await reader.read(); + if (ended) return; + if (chunk.done) { ended = true; cleanup(); value.close(); return; } + bytes += chunk.value.byteLength; + if (bytes > maxBytes) { + ended = true; + cleanup(); + const error = responseSizeError(maxBytes); + value.error(error); + void reader.cancel(error).catch(() => undefined); + return; + } + value.enqueue(chunk.value); + } catch (error) { if (!ended) { ended = true; cleanup(); value.error(error); } } + }, + cancel(reason) { ended = true; cleanup(); void reader.cancel(reason).catch(() => undefined); } + }); + const bounded = new Response(body, {status: response.status, statusText: response.statusText, headers: response.headers}); + for (const key of ['url', 'redirected', 'type'] as const) Object.defineProperty(bounded, key, {value: response[key]}); + Object.defineProperty(bounded, 'json', {value: async () => parseJsonAsync(await bounded.text(), signal)}); + return bounded; +} + +function responseSizeError(maxBytes: number): Error { + return new Error(`HTTP response exceeded the ${maxBytes} byte safety limit.`); +} + /** * HTTPS fetch fallback for systems where libc getaddrinfo() reports EAI_AGAIN * but Node's DNS-protocol resolver can still reach the configured nameserver. diff --git a/src/platform/omarchy.test.ts b/src/platform/omarchy.test.ts new file mode 100644 index 0000000..488485c --- /dev/null +++ b/src/platform/omarchy.test.ts @@ -0,0 +1,175 @@ +import {describe, expect, it, vi} from 'vitest'; +import {identifyOmarchy, type OmarchyProbeOptions} from './omarchy.js'; +import {identifyPlatform} from './runtime.js'; + +const desktop = {HOME: '/home/radio', PATH: '/usr/bin', WAYLAND_DISPLAY: 'wayland-1', XDG_SESSION_TYPE: 'wayland', DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus'}; +const packagedRoot = '/usr/share/omarchy'; + +function fixture(overrides: OmarchyProbeOptions = {}): OmarchyProbeOptions { + return { + platform: 'linux', env: desktop, + isDirectory: path => path === packagedRoot, + readFile: () => null, + resolve: command => command.startsWith('/') ? command : `/usr/bin/${command}`, + run: (_command, args) => args[1] === 'omarchy' ? 'omarchy 4.0.4-1\n' : null, + ...overrides + }; +} + +describe('Omarchy installation evidence', () => { + it('identifies a packaged release without changing its platform ID', () => { + const run = vi.fn((_command: string, args: readonly string[]) => args[1] === 'omarchy' ? 'omarchy 4.0.4-1\n' : null); + const profile = identifyOmarchy(fixture({run})); + expect(profile).toEqual({ + kind: 'quattro', version: '4.0.4-1', major: 4, source: 'package', root: packagedRoot, packageName: 'omarchy', + session: {type: 'wayland', remote: false, localDesktop: true}, + terminal: {available: true, command: `${packagedRoot}/bin/omarchy-launch-tui`, missing: []} + }); + expect(identifyPlatform({platform: 'linux', env: desktop}).id).toBe('linux'); + expect(run.mock.calls.map(call => call[1])).toEqual([['-Q', 'omarchy-dev'], ['-Q', 'omarchy']]); + expect(run).toHaveBeenCalledWith('/usr/bin/pacman', ['-Q', 'omarchy'], expect.objectContaining({timeout: 500, maxBuffer: 4096})); + }); + + it.each([ + ['omarchy-dev 4.0.4-1', 'development', 4], + ['omarchy 3.3.3-1', 'legacy', 3], + ['omarchy 5.0.0-1', 'unknown', 5], + ['omarchy 4.1.0.rc1-1', 'unknown', null], + ['omarchy 1:4.0.4-2', 'quattro', 4] + ] as const)('classifies installed metadata %s without promoting unverified releases', (output, kind, major) => { + const profile = identifyOmarchy(fixture({run: (_command, args) => output.startsWith(`${args[1]} `) ? output : null})); + expect(profile).toMatchObject({kind, major}); + expect(profile.terminal.available).toBe(kind === 'quattro'); + }); + + it('retains helper version diagnostics without assuming stable channel when package queries fail', () => { + const run = vi.fn((command: string) => command.endsWith('/omarchy-version') ? '4.0.4-1\n' : null); + const profile = identifyOmarchy(fixture({run})); + expect(profile).toMatchObject({kind: 'unknown', version: '4.0.4-1', major: 4, source: 'helper', packageName: null, terminal: {available: false}}); + expect(run).toHaveBeenLastCalledWith(`${packagedRoot}/bin/omarchy-version`, [], expect.objectContaining({env: expect.objectContaining({OMARCHY_PATH: packagedRoot}), timeout: 500, maxBuffer: 4096})); + expect(run).toHaveBeenCalledTimes(3); + }); + + it('preserves an explicit development helper result without activating Quattro', () => { + expect(identifyOmarchy(fixture({run: command => command.endsWith('/omarchy-version') ? 'dev (f00ba42)' : null}))).toMatchObject({kind: 'development', version: 'dev (f00ba42)', major: null, source: 'helper', terminal: {available: false}}); + }); + + it.each([null, '', 'dev (f00ba42)', '4.0.4\n5.0.0', '4.0.4\0', '4'.repeat(4097)])('does not activate Quattro from malformed, development or missing helper output %s', output => { + const profile = identifyOmarchy(fixture({run: command => command.endsWith('/omarchy-version') ? output : null})); + expect(profile.kind).not.toBe('quattro'); + expect(profile.terminal.available).toBe(false); + }); + + it('requires installed evidence even when Omarchy and Hyprland environment hints are present', () => { + const run = vi.fn(() => '4.0.4-1'); + const readFile = vi.fn(() => null); + const profile = identifyOmarchy(fixture({env: {...desktop, OMARCHY_PATH: '/fake/omarchy', OMARCHY_VERSION: '4.0.4', XDG_CURRENT_DESKTOP: 'Hyprland'}, isDirectory: () => false, readFile, run})); + expect(profile.kind).toBe('none'); + expect(profile.terminal.available).toBe(false); + expect(run).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + }); + + it.each([ + ['3.3.3\n', 'legacy'], ['4.0.4\n', 'development'] + ] as const)('recognizes a legacy user checkout with version %s separately from packaged Quattro', (version, kind) => { + const root = '/home/radio/.local/share/omarchy'; + const run = vi.fn(() => null); + expect(identifyOmarchy(fixture({isDirectory: path => path === root, readFile: path => path === `${root}/version` ? version : null, run}))).toMatchObject({kind, version: version.trim(), source: 'legacy-metadata', root, terminal: {available: false}}); + expect(run).not.toHaveBeenCalled(); + }); + + it('treats an installed custom checkout as development even alongside a packaged release', () => { + const run = vi.fn(() => 'omarchy 4.0.4-1'); + const profile = identifyOmarchy(fixture({env: {...desktop, OMARCHY_PATH: '/work/omarchy'}, isDirectory: path => path === packagedRoot || path === '/work/omarchy', run})); + expect(profile).toMatchObject({kind: 'development', root: '/work/omarchy', terminal: {available: false}}); + expect(run).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'win32', 'freebsd', 'android'] as const)('does not probe installed Linux files or commands on %s', platform => { + const isDirectory = vi.fn(() => true); + const run = vi.fn(() => 'omarchy 4.0.4-1'); + expect(identifyOmarchy(fixture({platform, isDirectory, run})).kind).toBe('none'); + expect(isDirectory).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + }); + + it('does not apply desktop detection in Termux', () => { + const isDirectory = vi.fn(() => true); + expect(identifyOmarchy(fixture({env: {...desktop, TERMUX_VERSION: '0.118.3'}, isDirectory})).kind).toBe('none'); + expect(isDirectory).not.toHaveBeenCalled(); + }); + + it.skipIf(process.platform === 'linux')('does not run native probes for a different injected platform without probe dependencies', () => { + const resolve = vi.fn(() => '/usr/bin/pacman'); + expect(identifyOmarchy({platform: 'linux', env: desktop, resolve}).kind).toBe('none'); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('caches bounded installation probes briefly while revalidating launcher capabilities on every call', () => { + let time = 1000; + let helper = true; + const run = vi.fn((_command: string, args: readonly string[]) => args[1] === 'omarchy' ? 'omarchy 4.0.4-1' : null); + const options = fixture({now: () => time, run, resolve: command => posixBasename(command) === 'uwsm-app' && !helper ? undefined : command.startsWith('/') ? command : `/usr/bin/${command}`}); + expect(identifyOmarchy(options).terminal.available).toBe(true); + helper = false; + expect(identifyOmarchy(options).terminal.missing).toContain('uwsm-app'); + expect(run).toHaveBeenCalledTimes(2); + time += 5000; + identifyOmarchy(options); + expect(run).toHaveBeenCalledTimes(4); + }); +}); + +describe('Omarchy session and launcher prerequisites', () => { + it.each([ + {SSH_CONNECTION: 'remote 1 local 2'}, {SSH_CLIENT: 'remote 1 2'}, {SSH_TTY: '/dev/pts/1'}, {XDG_SESSION_REMOTE: 'true'} + ])('keeps installation evidence separate from a remote session (%j)', remote => { + const profile = identifyOmarchy(fixture({env: {...desktop, ...remote}})); + expect(profile.kind).toBe('quattro'); + expect(profile.session).toMatchObject({remote: true, localDesktop: false}); + expect(profile.terminal.missing).toContain('local-wayland-session'); + }); + + it.each([ + [{}, 'headless'], [{DISPLAY: ':0'}, 'x11'], [{...desktop, XDG_SESSION_TYPE: 'tty'}, 'headless'] + ] as const)('does not enable native launch for %j', (env, type) => { + const profile = identifyOmarchy(fixture({env})); + expect(profile).toMatchObject({kind: 'quattro', session: {type}, terminal: {available: false}}); + expect(profile.terminal.missing).toContain('local-wayland-session'); + }); + + it.each(['omarchy-launch-tui', 'setsid', 'uwsm-app', 'xdg-terminal-exec'])('requires the actual %s prerequisite', missing => { + const profile = identifyOmarchy(fixture({resolve: command => posixBasename(command) === missing ? undefined : command.startsWith('/') ? command : `/usr/bin/${command}`})); + expect(profile.terminal).toMatchObject({available: false, missing: [missing]}); + }); + + it('does not claim launcher readiness from missing user-bus connection information', () => { + const profile = identifyOmarchy(fixture({env: {...desktop, DBUS_SESSION_BUS_ADDRESS: ''}})); + expect(profile.terminal.missing).toEqual(['session-bus']); + }); + + it('rejects relative or control-character helper paths from a resolver', () => { + for (const path of ['uwsm-app', '/usr/bin/uwsm-app\n']) { + expect(identifyOmarchy(fixture({resolve: command => posixBasename(command) === 'uwsm-app' ? path : command.startsWith('/') ? command : `/usr/bin/${command}`})).terminal.missing).toContain('uwsm-app'); + } + }); + + it('does not mistake fallback executable locations for helpers reachable from a restricted PATH', () => { + const resolve = vi.fn((command: string) => command.startsWith('/isolated/bin/') ? undefined : command.startsWith('/') ? command : `/usr/bin/${command}`); + const profile = identifyOmarchy(fixture({env: {...desktop, PATH: '/isolated/bin'}, resolve})); + expect(profile).toMatchObject({kind: 'quattro', terminal: {available: false, missing: ['setsid', 'uwsm-app', 'xdg-terminal-exec']}}); + expect(resolve).toHaveBeenCalledWith('/isolated/bin/uwsm-app'); + expect(resolve).not.toHaveBeenCalledWith('uwsm-app'); + }); + + it('accepts system helpers after a Node version-manager directory without changing PATH or its order', () => { + const env = {...desktop, PATH: '/home/radio/.local/share/mise/installs/node/24/bin:/usr/bin'}; + const resolve = vi.fn((command: string) => command.startsWith('/home/radio/.local/share/mise/') ? undefined : command.startsWith('/') ? command : `/usr/bin/${command}`); + expect(identifyOmarchy(fixture({env, resolve})).terminal.available).toBe(true); + expect(resolve.mock.calls.filter(([command]) => command.endsWith('/setsid')).map(([command]) => command)).toEqual(['/home/radio/.local/share/mise/installs/node/24/bin/setsid', '/usr/bin/setsid']); + expect(env.PATH).toBe('/home/radio/.local/share/mise/installs/node/24/bin:/usr/bin'); + }); +}); + +function posixBasename(path: string): string { return path.slice(path.lastIndexOf('/') + 1); } diff --git a/src/platform/omarchy.ts b/src/platform/omarchy.ts new file mode 100644 index 0000000..9c51ef4 --- /dev/null +++ b/src/platform/omarchy.ts @@ -0,0 +1,152 @@ +import {spawnSync} from 'node:child_process'; +import {closeSync, constants, fstatSync, openSync, readSync, statSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {posix} from 'node:path'; +import {resolveCommandDetails} from './executables.js'; +import {identifyPlatform} from './runtime.js'; + +export type OmarchyProfile = { + kind: 'quattro' | 'legacy' | 'development' | 'unknown' | 'none'; + version: string | null; + major: number | null; + source: 'package' | 'helper' | 'legacy-metadata' | null; + root: string | null; + packageName: string | null; + session: {type: 'wayland' | 'x11' | 'headless'; remote: boolean; localDesktop: boolean}; + /** Prerequisites only: launcher acceptance and TUI readiness are checked separately. */ + terminal: {available: boolean; command: string | null; missing: string[]}; +}; + +type CommandProbe = (command: string, args: readonly string[], options: {env: NodeJS.ProcessEnv; timeout: number; maxBuffer: number}) => string | null; +export type OmarchyProbeOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + home?: string; + resolve?: (command: string) => string | undefined; + readFile?: (path: string) => string | null; + isDirectory?: (path: string) => boolean; + run?: CommandProbe; + now?: () => number; +}; + +type Installation = Pick; +const packagedRoot = '/usr/share/omarchy'; +const maxProbeBytes = 4096; +const probeTimeoutMs = 500; +const installationCache = new Map(); +const probeIdentities = new WeakMap(); +let nextProbeIdentity = 0; +const absent: Installation = {kind: 'none', version: null, major: null, source: null, root: null, packageName: null}; + +/** Omarchy is desktop evidence, not a replacement for the Linux platform ID. */ +export function identifyOmarchy(options: OmarchyProbeOptions = {}): OmarchyProfile { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const remote = Boolean(env.SSH_CONNECTION?.trim() || env.SSH_CLIENT?.trim() || env.SSH_TTY?.trim() || /^(?:1|true|yes)$/i.test(env.XDG_SESSION_REMOTE ?? '')); + const headless = /^(?:tty|unspecified)$/i.test(env.XDG_SESSION_TYPE ?? ''); + const type = !headless && env.WAYLAND_DISPLAY?.trim() ? 'wayland' : !headless && env.DISPLAY?.trim() ? 'x11' : 'headless'; + const session: OmarchyProfile['session'] = {type, remote, localDesktop: !remote && type !== 'headless'}; + const unavailable: OmarchyProfile['terminal'] = {available: false, command: null, missing: []}; + const injected = Boolean(options.readFile || options.isDirectory || options.run); + // Cross-platform callers/tests must not inspect the execution host's files. + if (identifyPlatform({platform, env}).id !== 'linux' || platform !== process.platform && !injected) { + return {...absent, session, terminal: unavailable}; + } + const resolve = options.resolve ?? (command => resolveCommandDetails(command, {platform, env}).path ?? undefined); + const home = options.home ?? env.HOME ?? homedir(); + const readFile = options.readFile ?? readSmallFile; + const isDirectory = options.isDirectory ?? directoryExists; + const run = options.run ?? probeCommand; + const now = (options.now ?? Date.now)(); + const key = [home, env.OMARCHY_PATH, env.PATH, options.resolve, readFile, isDirectory, run].map(value => typeof value === 'function' ? probeIdentity(value) : value).join('\0'); + // Cache installation probes, but revalidate launcher executables/session on + // every request so saved alarm selectors do not retain stale capabilities. + const cached = installationCache.get(key); + const installation = cached && now >= cached.checkedAt && now - cached.checkedAt < 5000 ? cached.installation + : detectInstallation({env, home, resolve, readFile, isDirectory, run}); + if (installation !== cached?.installation) { + if (installationCache.size >= 32) installationCache.clear(); + installationCache.set(key, {checkedAt: now, installation}); + } + if (installation.kind !== 'quattro') return {...installation, session, terminal: unavailable}; + + const command = resolveAbsolute(resolve, posix.join(packagedRoot, 'bin', 'omarchy-launch-tui')) ?? resolveAbsolute(resolve, 'omarchy-launch-tui'); + // The upstream script invokes these by name. Fallback executable discovery + // outside PATH cannot establish that its unchanged environment can run them. + const missing = ['setsid', 'uwsm-app', 'xdg-terminal-exec'].filter(name => !reachableOnPath(resolve, name, env.PATH)); + if (!command) missing.unshift('omarchy-launch-tui'); + if (!session.localDesktop || type !== 'wayland') missing.push('local-wayland-session'); + if (!env.DBUS_SESSION_BUS_ADDRESS?.trim()) missing.push('session-bus'); + return {...installation, session, terminal: {available: missing.length === 0, command, missing}}; +} + +function detectInstallation(options: Required>): Installation { + const {env, home, resolve, readFile, isDirectory, run} = options; + const configuredRoot = safeAbsolute(env.OMARCHY_PATH); + // A custom checkout is deliberately kept separate from the packaged release. + if (configuredRoot && configuredRoot !== packagedRoot && isDirectory(configuredRoot)) { + const metadata = versionFrom(readFile(posix.join(configuredRoot, 'version'))); + const helper = resolveAbsolute(resolve, posix.join(configuredRoot, 'bin', 'omarchy-version')); + if (metadata || helper) return {kind: metadata?.major === 3 ? 'legacy' : 'development', version: metadata?.version ?? null, major: metadata?.major ?? null, source: metadata ? 'legacy-metadata' : 'helper', root: configuredRoot, packageName: null}; + } + if (isDirectory(packagedRoot)) { + const pacman = resolveAbsolute(resolve, 'pacman'); + if (pacman) { + for (const name of ['omarchy-dev', 'omarchy']) { + const output = boundedOutput(run(pacman, ['-Q', name], {env, timeout: probeTimeoutMs, maxBuffer: maxProbeBytes})); + const match = output && new RegExp(`^${name} ([^\\s]+)$`).exec(output); + if (!match) continue; + const version = versionFrom(match[1]!); + return {kind: name === 'omarchy-dev' ? 'development' : releaseKind(version?.major), version: version?.version ?? match[1]!, major: version?.major ?? null, source: 'package', root: packagedRoot, packageName: name}; + } + } + const helper = resolveAbsolute(resolve, posix.join(packagedRoot, 'bin', 'omarchy-version')); + const output = helper ? boundedOutput(run(helper, [], {env: {...env, OMARCHY_PATH: packagedRoot}, timeout: probeTimeoutMs, maxBuffer: maxProbeBytes})) : null; + const version = versionFrom(output); + // The packaged helper strips the package name from both omarchy-dev and + // omarchy. Its numeric output preserves version diagnostics but cannot + // establish the stable channel needed for automatic Quattro integration. + return {kind: output?.startsWith('dev') ? 'development' : 'unknown', version: version?.version ?? output, major: version?.major ?? null, source: output ? 'helper' : null, root: packagedRoot, packageName: null}; + } + const legacyRoot = safeAbsolute(posix.join(home, '.local', 'share', 'omarchy')); + const metadata = legacyRoot && isDirectory(legacyRoot) ? versionFrom(readFile(posix.join(legacyRoot, 'version'))) : null; + if (metadata && legacyRoot) return {kind: metadata.major === 3 ? 'legacy' : 'development', ...metadata, source: 'legacy-metadata', root: legacyRoot, packageName: null}; + return {...absent}; +} + +function releaseKind(major: number | undefined): Installation['kind'] { return major === 4 ? 'quattro' : major !== undefined && major < 4 ? 'legacy' : 'unknown'; } +function probeIdentity(probe: object): number { const current = probeIdentities.get(probe); if (current !== undefined) return current; const next = ++nextProbeIdentity; probeIdentities.set(probe, next); return next; } +function boundedOutput(value: string | null): string | null { const trimmed = value?.trim(); return trimmed && Buffer.byteLength(value!, 'utf8') <= maxProbeBytes && !/[\0\r\n]/.test(trimmed) ? trimmed : null; } +function versionFrom(value: string | null): {version: string; major: number} | null { + const version = boundedOutput(value); + const match = version && /^(?:\d+:)?(\d+)\.\d+\.\d+(?:-\d+(?:\.\d+)?)?$/.exec(version); + return match ? {version: version!, major: Number(match[1])} : null; +} +function safeAbsolute(value: string | undefined): string | null { return value && posix.isAbsolute(value) && !/[\0\r\n]/.test(value) ? posix.normalize(value) : null; } +function resolveAbsolute(resolve: (command: string) => string | undefined, command: string): string | null { return safeAbsolute(resolve(command)); } +function reachableOnPath(resolve: (command: string) => string | undefined, command: string, path: string | undefined): boolean { + if (path === undefined || /[\0\r\n]/.test(path)) return false; + return path.split(':').some(directory => { + const candidate = posix.resolve(directory || '.', command); + return resolveAbsolute(resolve, candidate) === candidate; + }); +} +function directoryExists(path: string): boolean { try { return statSync(path).isDirectory(); } catch { return false; } } +function readSmallFile(path: string): string | null { + let descriptor: number | undefined; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); + const stat = fstatSync(descriptor); + if (!stat.isFile() || stat.size > maxProbeBytes) return null; + const buffer = Buffer.alloc(maxProbeBytes + 1); + const count = readSync(descriptor, buffer, 0, buffer.length, 0); + return count > maxProbeBytes ? null : buffer.toString('utf8', 0, count); + } catch { return null; } + finally { if (descriptor !== undefined) closeSync(descriptor); } +} +function probeCommand(command: string, args: readonly string[], options: Parameters[2]): string | null { + try { + const result = spawnSync(command, args, {...options, killSignal: 'SIGKILL', encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}); + return result.error || result.status !== 0 ? null : boundedOutput(result.stdout); + } catch { return null; } +} diff --git a/src/platform/packages.test.ts b/src/platform/packages.test.ts index 50779e1..d9cf022 100644 --- a/src/platform/packages.test.ts +++ b/src/platform/packages.test.ts @@ -1,8 +1,19 @@ import {describe, expect, it} from 'vitest'; import {detectPackageManager, ffplayInstallCommand, mpvInstallCommand, packageCommandInvocation, packageInstallCommand, packageManagerNeedsRoot, packageManagerNotes, platformLabel} from './packages.js'; import {identifyPlatform} from './runtime.js'; +import {identifyOmarchy} from './omarchy.js'; describe('native package plans', () => { + it('adds Quattro pacman recovery guidance without changing installation commands', () => { + const host = identifyPlatform({platform: 'linux', osRelease: 'ID=arch', env: {}}); + const profile = identifyOmarchy({platform: 'linux', env: {}, isDirectory: path => path === '/usr/share/omarchy', readFile: () => null, resolve: command => `/usr/bin/${command}`, run: (_command, args) => args[1] === 'omarchy' ? 'omarchy 4.0.4-1' : null}); + expect(packageManagerNotes('pacman', host, profile).join(' ')).toMatch(/Omarchy Quattro.*uwsm-app.*supported Omarchy update workflow.*does not upgrade/s); + expect(packageInstallCommand('pacman', 'mpv')).toMatchObject({program: 'sudo', args: ['pacman', '-S', '--needed', '--noconfirm', 'mpv']}); + expect(packageInstallCommand('pacman', 'ffmpeg')).toMatchObject({program: 'sudo', args: ['pacman', '-S', '--needed', '--noconfirm', 'ffmpeg']}); + expect(packageManagerNotes('pacman', host, {...profile, kind: 'legacy'})).toEqual([]); + expect(packageManagerNotes('pacman', host, {...profile, kind: 'none'})).toEqual([]); + expect(packageManagerNotes('apt', host, profile)).toEqual([]); + }); 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}; diff --git a/src/platform/packages.ts b/src/platform/packages.ts index a38e51f..9e1be23 100644 --- a/src/platform/packages.ts +++ b/src/platform/packages.ts @@ -1,6 +1,7 @@ import {commandExists} from './executables.js'; import {powershellCommand} from './shell.js'; import {identifyPlatform, readLinuxOsRelease, type PlatformProfile} from './runtime.js'; +import {identifyOmarchy, type OmarchyProfile} from './omarchy.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; @@ -109,7 +110,11 @@ 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[] { +export function packageManagerNotes(manager: SetupPackageManager | null, host: PlatformProfile = identifyPlatform(), omarchy?: OmarchyProfile): string[] { + if (manager === 'pacman' && host.id === 'linux' && (omarchy ?? identifyOmarchy({platform: 'linux'})).kind === 'quattro') return [ + 'Omarchy Quattro uses the existing Arch mpv and ffmpeg packages. Native terminal launching requires a local Wayland session, a user session bus, and the Omarchy TUI launcher with setsid, uwsm-app, and xdg-terminal-exec.', + 'If pacman reports stale databases or package conflicts, complete the supported Omarchy update workflow and rerun setup. RadioCLI setup does not upgrade the distribution or bypass package checks.' + ]; 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.', diff --git a/src/platform/runtime.test.ts b/src/platform/runtime.test.ts index 1daad8f..fc655b3 100644 --- a/src/platform/runtime.test.ts +++ b/src/platform/runtime.test.ts @@ -22,6 +22,7 @@ describe('platform identity and adapter policy', () => { 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(nativeAdapters(host).airPlay).toBe(false); expect(identifyPlatform({platform: 'linux', release: '6.6', libc: 'musl', env: {}})).toMatchObject({isWsl: false, libc: 'musl'}); }); @@ -45,7 +46,7 @@ describe('platform identity and adapter policy', () => { 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('linux'))).toMatchObject({scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix', airPlay: true, ipc: 'unix-socket'}); expect(nativeAdapters(host('win32'))).toMatchObject({scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', airPlay: false, ipc: 'named-pipe', posixPermissions: false}); }); diff --git a/src/platform/runtime.ts b/src/platform/runtime.ts index e35a05a..42370f1 100644 --- a/src/platform/runtime.ts +++ b/src/platform/runtime.ts @@ -73,7 +73,7 @@ export function nativeAdapters(host: PlatformProfile = identifyPlatform()): Nati 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 (host.id === 'linux') return {...portable, scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix', airPlay: !host.isWsl}; if (['freebsd', 'openbsd', 'netbsd'].includes(host.id)) return {...portable, terminal: 'unix'}; return portable; } diff --git a/src/platform/terminals.test.ts b/src/platform/terminals.test.ts index 1561e36..02b88c1 100644 --- a/src/platform/terminals.test.ts +++ b/src/platform/terminals.test.ts @@ -5,10 +5,14 @@ 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'; +import type {OmarchyProbeOptions} from './omarchy.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; +const quattroEnvironment={PATH:'/usr/bin',WAYLAND_DISPLAY:'wayland-1',XDG_SESSION_TYPE:'wayland',DBUS_SESSION_BUS_ADDRESS:'unix:path=/run/user/1000/bus'}; +function quattroProbes(overrides:OmarchyProbeOptions={}):OmarchyProbeOptions{return{isDirectory:path=>path==='/usr/share/omarchy',readFile:()=>null,run:(_command,args)=>args[1]==='omarchy'?'omarchy 4.0.4-1':null,...overrides};} +const quattroResolve=(command:string)=>command.startsWith('/')?command:`/usr/bin/${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[]} { @@ -24,6 +28,44 @@ function nodeInvocation(args: readonly string[], environment: NodeJS.ProcessEnv } describe('shared graphical terminal discovery',()=>{ + it('prefers the upstream TUI launcher only for an installed local Quattro Wayland session',()=>{ + expect(detectGraphicalTerminal('linux',quattroEnvironment,quattroResolve,quattroProbes())).toBe('linux:omarchy-launch-tui'); + expect(detectGraphicalTerminal('linux',{...quattroEnvironment,SSH_CONNECTION:'remote 1 local 2'},quattroResolve,quattroProbes())).toBe('linux:/usr/bin/ghostty'); + expect(detectGraphicalTerminal('linux',{...quattroEnvironment,WAYLAND_DISPLAY:undefined,DISPLAY:':0'},quattroResolve,quattroProbes())).toBe('linux:/usr/bin/ghostty'); + }); + it.each([ + {RADIOCLI_ALARM_TERMINAL:'linux:/tools/foot'}, {TERMINAL:'/tools/foot'}, {TERM_PROGRAM:'/tools/foot'} + ])('preserves explicit supported terminal choice %j on Quattro',preference=>{ + const run=vi.fn(()=>null); + expect(detectGraphicalTerminal('linux',{...quattroEnvironment,...preference},quattroResolve,quattroProbes({run}))).toBe('linux:/tools/foot'); + expect(run).not.toHaveBeenCalled(); + }); + it.each(['omarchy-launch-tui','setsid','uwsm-app','xdg-terminal-exec'])('falls back to unchanged generic ordering when Quattro lacks %s',missing=>{ + const available=(command:string)=>posix.basename(command)===missing?undefined:quattroResolve(command); + expect(detectGraphicalTerminal('linux',quattroEnvironment,available,quattroProbes())).toBe('linux:/usr/bin/ghostty'); + expect(detectGraphicalTerminal('linux',{...quattroEnvironment,RADIOCLI_ALARM_TERMINAL:'linux:omarchy-launch-tui'},available,quattroProbes())).toBe('linux:/usr/bin/ghostty'); + expect(detectGraphicalTerminal('linux',{...quattroEnvironment,RADIOCLI_ALARM_TERMINAL:'linux:omarchy-launch-tui',TERMINAL:'foot'},available,quattroProbes())).toBe('linux:/usr/bin/foot'); + }); + it('revalidates a persisted native selector and falls back when the installed release changed',()=>{ + const env={...quattroEnvironment,RADIOCLI_ALARM_TERMINAL:'linux:omarchy-launch-tui'}; + expect(detectGraphicalTerminal('linux',env,quattroResolve,quattroProbes())).toBe('linux:omarchy-launch-tui'); + expect(detectGraphicalTerminal('linux',env,quattroResolve,quattroProbes({run:(_command,args)=>args[1]==='omarchy'?'omarchy 3.3.3-1':null}))).toBe('linux:/usr/bin/ghostty'); + expect(detectGraphicalTerminal('linux',env,()=>undefined,quattroProbes())).toBe('linux:unsupported'); + }); + it('uses the direct-terminal fallback when the Quattro helper prerequisites are outside the launch PATH',()=>{ + const resolve=(command:string)=>command.startsWith('/isolated/bin/')?undefined:quattroResolve(command); + const env={...quattroEnvironment,PATH:'/isolated/bin',RADIOCLI_ALARM_TERMINAL:'linux:omarchy-launch-tui'}; + const plan=createTerminalLaunch({platform:'linux',env,nodePath:'/node',args:['/cli.js'],resolve,omarchy:quattroProbes()}); + expect(plan).toMatchObject({terminal:'linux:/usr/bin/ghostty',command:'/usr/bin/ghostty'}); + expect(env.PATH).toBe('/isolated/bin'); + }); + it('does not select the native launcher from ambiguous numeric helper-only version evidence',()=>{ + const probes=quattroProbes({run:command=>command.endsWith('/omarchy-version')?'4.0.4-1':null}); + expect(detectGraphicalTerminal('linux',quattroEnvironment,quattroResolve,probes)).toBe('linux:/usr/bin/ghostty'); + }); + it.each(['freebsd','openbsd','netbsd'] as const)('does not apply a saved Quattro selector to %s',platform=>{ + expect(detectGraphicalTerminal(platform,{...quattroEnvironment,RADIOCLI_ALARM_TERMINAL:'linux:omarchy-launch-tui'},quattroResolve,quattroProbes())).toBe(`${platform}:/usr/bin/ghostty`); + }); 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`);}); @@ -37,6 +79,20 @@ describe('shared graphical terminal discovery',()=>{ }); describe('graphical terminal invocation plans',()=>{ + it.skipIf(process.platform==='win32')('preserves literal argv and application identity through the Quattro helper boundary',()=>{ + const root=fixture();const nodeDirectory=join(root,"Node ' 单播");mkdirSync(nodeDirectory);const nodePath=join(nodeDirectory,'node');symlinkSync(process.execPath,nodePath); + const values=['space value',"apostrophe's",'"quoted"','$(false); & | < > %PATH%!','单播','']; + const dataHome="/Radio Data ' ; $() 单播"; + const args=['-e','process.stdout.write(JSON.stringify({args:process.argv.slice(1),home:process.env.RADIOCLI_HOME}))','--',...values]; + const plan=createTerminalLaunch({platform:'linux',env:{...quattroEnvironment,RADIOCLI_HOME:dataHome},nodePath,args,resolve:quattroResolve,omarchy:quattroProbes()}); + expect(plan).toMatchObject({terminal:'linux:omarchy-launch-tui',command:'/usr/share/omarchy/bin/omarchy-launch-tui'}); + expect(plan.args.slice(0,3)).toEqual(['--app-id=org.radiocli.RadioCLI',nodePath,'-e']); + expect(plan.args).not.toContain('/bin/sh'); + expect(JSON.parse(execFileSync(plan.args[1]!,plan.args.slice(2),{env:{RADIOCLI_HOME:'/unrelated/server'},encoding:'utf8'}))).toEqual({args:values,home:dataHome}); + }); + it('requires an absolute Node path for the Omarchy helper',()=>{ + expect(()=>createTerminalLaunch({platform:'linux',env:quattroEnvironment,nodePath:'node',args:['cli.js'],resolve:quattroResolve,omarchy:quattroProbes()})).toThrow(/absolute Node/); + }); it.each([ ['darwin','apple-terminal'],['darwin','iterm'],['darwin','wezterm'],['darwin','ghostty'],['darwin','kitty'], ['linux','/tools/kitty'],['win32','console'],['win32','windows-terminal'] @@ -140,6 +196,7 @@ describe('graphical terminal invocation plans',()=>{ ].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:''}; + const launchStarted=Date.now(); 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(); @@ -165,7 +222,12 @@ describe('graphical terminal invocation plans',()=>{ 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});}); + }}).catch(error=>{ + const nodeStages=existsSync(stages)?readFileSync(stages,'utf8').slice(-8_000):''; + const powershellStage=existsSync(powershellStages)?readFileSync(powershellStages,'utf8').slice(-4_000):''; + throw new Error(`Windows console launcher failed: ${JSON.stringify({...diagnostic,elapsedMs:Date.now()-launchStarted,nodeStages,powershellStage})}`,{cause:error}); + }); + const bootstrapMs=Date.now()-launchStarted; 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. @@ -175,7 +237,8 @@ describe('graphical terminal invocation plans',()=>{ 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); + console.log(`windows_console_smoke=${JSON.stringify({bootstrapMs,totalMs:Date.now()-launchStarted,ttyHandles:'passed',literalArguments:'passed'})}`); + },45_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]]))))`; @@ -196,18 +259,33 @@ describe('graphical terminal invocation plans',()=>{ }); describe('terminal launcher acceptance',()=>{ + it('keeps Quattro helper acceptance separate from application readiness',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();const launch=vi.fn(()=>child); + const result=launchTerminalCommand({platform:'linux',env:quattroEnvironment,nodePath:'/node',args:['/cli.js'],resolve:quattroResolve,omarchy:quattroProbes(),spawn:launch}); + expect(launch).toHaveBeenCalledWith('/usr/share/omarchy/bin/omarchy-launch-tui',expect.arrayContaining(['--app-id=org.radiocli.RadioCLI','/node']),expect.objectContaining({detached:true,stdio:'ignore',windowsHide:false})); + child.emit('spawn');child.emit('close',0);await expect(result).resolves.toBe('linux:omarchy-launch-tui'); + }); 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); + 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,()=>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); + // Cold PowerShell/.NET startup on ARM64 can exceed the old ten-second limit. + child.emit('spawn');await vi.advanceTimersByTimeAsync(11_000); 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('still terminates a Windows console bootstrap that stalls beyond thirty seconds',async()=>{ + vi.useFakeTimers();const child=new EventEmitter() as ChildProcess;child.unref=vi.fn();child.kill=vi.fn(); + const result=launchTerminalCommand({platform:'win32',env:{RADIOCLI_ALARM_TERMINAL:'win32:console'},nodePath:'/node',args:['/cli.js'],resolve,spawn:()=>child}).catch(error=>error as Error); + child.emit('spawn');await vi.advanceTimersByTimeAsync(29_999);expect(child.kill).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1);expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + await vi.advanceTimersByTimeAsync(1_000);expect(await result).toMatchObject({message:'Launch bootstrap did not complete.'}); + expect(child.kill).toHaveBeenLastCalledWith('SIGKILL');expect(child.unref).toHaveBeenCalledOnce(); + }); 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); diff --git a/src/platform/terminals.ts b/src/platform/terminals.ts index a68fa01..9c07660 100644 --- a/src/platform/terminals.ts +++ b/src/platform/terminals.ts @@ -4,10 +4,11 @@ import {resolveCommandDetails} from './executables.js'; import {identifyPlatform,nativeAdapters} from './runtime.js'; import {powershellCommand} from './shell.js'; import {launchEnvironment, nodeLaunchCommand, waitForLaunch} from './launch-command.js'; +import {identifyOmarchy, type OmarchyProbeOptions} from './omarchy.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}; +export type TerminalOptions={platform?:NodeJS.Platform;env?:NodeJS.ProcessEnv;resolve?:TerminalResolver;spawn?:TerminalSpawn;omarchy?:OmarchyProbeOptions}; type TerminalCommand=TerminalOptions&{nodePath:string;args:readonly string[];title?:string;closeOnExit?:boolean}; type TerminalLaunch={terminal:string;command:string;args:string[];environment?:Record}; @@ -22,8 +23,10 @@ const unixTerminals:UnixTerminal[]=[ {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']; +const omarchyTerminal='linux:omarchy-launch-tui'; -export function detectGraphicalTerminal(platform:NodeJS.Platform=process.platform,env:NodeJS.ProcessEnv=process.env,resolve:TerminalResolver=resolver(platform,env)):string{ +export function detectGraphicalTerminal(platform:NodeJS.Platform=process.platform,env:NodeJS.ProcessEnv=process.env,resolveCommand?:TerminalResolver,omarchy?:OmarchyProbeOptions):string{ + const resolve=resolveCommand??resolver(platform,env); 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(); @@ -38,7 +41,10 @@ export function detectGraphicalTerminal(platform:NodeJS.Platform=process.platfor return env.WT_SESSION?'win32:windows-terminal':'win32:console'; } if(adapter!=='unix'||!hasDisplay(env))return unsupported; - if(configured){ + const nativeOmarchy=()=>identifyOmarchy({...omarchy,platform,env,...(resolveCommand?{resolve}: {})}).terminal.available; + if(configured===omarchyTerminal){ + if(nativeOmarchy())return omarchyTerminal; + }else 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); @@ -48,13 +54,14 @@ export function detectGraphicalTerminal(platform:NodeJS.Platform=process.platfor const requested=env.TERMINAL?.trim()||env.TERM_PROGRAM?.trim(); const selected=requested&&resolveUnixTerminal(requested,env,resolve); if(selected)return`${host.id}:${selected}`; + if(nativeOmarchy())return omarchyTerminal; 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]; + const terminal=detectGraphicalTerminal(platform,env,options.resolve,options.omarchy);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); @@ -81,6 +88,12 @@ export function createTerminalLaunch(options:TerminalCommand):TerminalLaunch{ 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]}; } + if(terminal===omarchyTerminal){ + if(!posix.isAbsolute(options.nodePath))throw new Error('The Omarchy launcher requires an absolute Node executable path.'); + const command=resolve('/usr/share/omarchy/bin/omarchy-launch-tui')??resolve('omarchy-launch-tui'); + if(!command||!posix.isAbsolute(command)||/[\0\r\n]/.test(command))throw new Error('The Omarchy TUI launcher is unavailable.'); + return{terminal,command,args:['--app-id=org.radiocli.RadioCLI',...direct]}; + } 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. @@ -99,7 +112,9 @@ export async function launchTerminalCommand(options:TerminalCommand):PromiseresolveCommandDetails(command,{platform,env}).path??undefined;} diff --git a/src/player/airplay-discovery.test.ts b/src/player/airplay-discovery.test.ts index e533728..f91ae72 100644 --- a/src/player/airplay-discovery.test.ts +++ b/src/player/airplay-discovery.test.ts @@ -1,18 +1,19 @@ 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'; +import {discoverAirPlayDevices, parseAvahiBrowseOutput, 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 command = vi.spyOn(commands, 'resolveCommandDetails').mockReturnValue({path: '/usr/bin/dns-sd', discovery: 'path'}); const spawn = vi.mocked(childProcess.spawn); try { expect(await discoverAirPlayDevices({platform: 'darwin'})).toEqual([]); expect(spawn).not.toHaveBeenCalled(); + expect(command).not.toHaveBeenCalled(); } finally {spawn.mockClear(); command.mockRestore(); vi.unstubAllEnvs();} }); @@ -107,3 +108,60 @@ Lookup C869CD3DF60C@Living Room._raop._tcp.local ).toBeNull(); }); }); + +const avahiRecord = (id = 'AABBCC@Office', address = '192.168.10.4', txt = '"cn=0,1" "pw=false"', protocol = 'IPv4') => `=;eth0;${protocol};${id};_raop._tcp;local;office.local;${address};5000;${txt}`; +const ready = async () => ({eligible: true, discoveryExecutable: true, discoveryService: 'ready' as const, senderLoaded: true, ffmpeg: true, ready: true, message: 'Ready'}); +const resolve = (command: string) => `/usr/bin/${command}`; + +describe('bounded Linux Avahi discovery', () => { + it('decodes UTF-8 byte escapes and quoted TXT without breaking semicolons or escapes', () => { + const records = parseAvahiBrowseOutput(avahiRecord('AABBCC@Caf\\195\\169\\059Room', '192.168.10.4', '"cn=0,1" "pw=true" "am=Room; \\"speaker\\" \\\\ stereo"')); + expect(records).toEqual([expect.objectContaining({id: 'AABBCC@Café;Room', name: 'Café;Room', host: '192.168.10.4', port: 5000, requiresPassword: true, airplay2: false, txt: ['cn=0,1', 'pw=true', 'am=Room; "speaker" \\ stereo']})]); + }); + + it('deduplicates interfaces, excludes IPv6-only receivers, and detects local IPv4 output', () => { + const output = [avahiRecord(), avahiRecord().replace('eth0', 'wlan0'), avahiRecord('AABBCC@Office', 'fe80::5', '"cn=0"', 'IPv6'), avahiRecord('ONLY@IPv6', 'fe80::6', '"cn=0"', 'IPv6'), avahiRecord('LOCAL@Here', '127.0.0.1')].join('\n'); + expect(parseAvahiBrowseOutput(output)).toEqual([expect.objectContaining({id: 'AABBCC@Office', local: false}), expect.objectContaining({id: 'LOCAL@Here', local: true})]); + }); + + it('rejects malformed records, invalid addresses/ports/TXT/UTF-8 and caps devices', () => { + const invalid = [avahiRecord('BAD@x', '0.0.0.0'), avahiRecord('BAD@x', '224.0.0.1'), avahiRecord('BAD@x', 'host.local'), avahiRecord().replace(';5000;', ';70000;'), avahiRecord().replace(';5000;', ';1e3;'), avahiRecord('BAD@x', '1.2.3.4', '"pw=true'), avahiRecord('BAD@\\255'), avahiRecord().replace('=;', '+;')]; + expect(parseAvahiBrowseOutput(invalid.join('\n'))).toEqual([]); + const many = Array.from({length: 30}, (_, index) => avahiRecord(`ID${index}@Radio`)).join('\n'); + expect(parseAvahiBrowseOutput(many, 1000)).toHaveLength(12); + expect(parseAvahiBrowseOutput(many, 2)).toHaveLength(2); + }); + + it('sanitizes terminal controls and respects legacy password advertisements on both adapters', () => { + expect(parseAvahiBrowseOutput(avahiRecord('BAD\\027[31m@Kitchen\\010Radio'))[0]?.name).toBe('Kitchen Radio'); + expect(parseRaopLookupOutput('ID@Radio', 'ID can be reached at radio.local.:5000 (interface 2)\n cn=0,1 pw=true sf=0x4')).toMatchObject({requiresPassword: true, airplay2: false}); + }); + + it('uses one bounded Avahi resolve and returns IPv4 receivers', async () => { + const run = vi.fn(async (_command: string, _args: string[], _timeout: number, _maxBytes: number) => ({output: avahiRecord(), code: 0, timedOut: false, truncated: false})); + const devices = await discoverAirPlayDevices({platform: 'linux', env: {}, release: '6.6', resolve, run, readiness: ready, timeoutMs: 9000, maxOutputBytes: 999999}); + expect(devices[0]?.id).toBe('AABBCC@Office'); + expect(run).toHaveBeenCalledOnce(); + expect(run).toHaveBeenCalledWith('/usr/bin/avahi-browse', ['--parsable', '--resolve', '--terminate', '--no-db-lookup', '_raop._tcp'], expect.any(Number), 65536, {env: {}}); + expect(run.mock.calls[0]![2]).toBeLessThanOrEqual(2500); + }); + + it('reports a stopped service instead of confusing it with zero receivers', async () => { + const run = vi.fn(); + await expect(discoverAirPlayDevices({platform: 'linux', env: {}, release: '6.6', resolve, run, readiness: async () => ({...await ready(), ready: false, discoveryService: 'unavailable', message: 'The Avahi service is not ready.'})})).rejects.toThrow('Avahi service is not ready'); + expect(run).not.toHaveBeenCalled(); + await expect(discoverAirPlayDevices({platform: 'linux', env: {}, release: '6.6', resolve, run: async () => ({output: '', code: 1, timedOut: false, truncated: false}), readiness: ready})).rejects.toThrow('Avahi discovery failed'); + }); + + it('reports a missing Linux discovery executable without starting a probe', async () => { + const run = vi.fn(); + await expect(discoverAirPlayDevices({platform: 'linux', env: {}, release: '6.6', hasCommand: () => false, run})).rejects.toThrow('requires avahi-browse'); + expect(run).not.toHaveBeenCalled(); + }); + + it.each([{platform: 'linux' as const, env: {RADIOCLI_OFFLINE: '1'}}, {platform: 'linux' as const, env: {WSL_DISTRO_NAME: 'Ubuntu'}}, {platform: 'linux' as const, env: {TERMUX_VERSION: '1'}}, {platform: 'win32' as const, env: {}}])('never spawns discovery on an excluded environment: $env', async options => { + const run = vi.fn(); + expect(await discoverAirPlayDevices({...options, release: '6.6', hasCommand: () => true, run, readiness: ready})).toEqual([]); + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/src/player/airplay-discovery.ts b/src/player/airplay-discovery.ts index 9d14005..e60a147 100644 --- a/src/player/airplay-discovery.ts +++ b/src/player/airplay-discovery.ts @@ -1,13 +1,22 @@ -import {spawn} from 'node:child_process'; +import {isIP} from 'node:net'; import {lookup} from 'node:dns/promises'; +import type {LookupAddress} from 'node:dns'; import {networkInterfaces} from 'node:os'; import type {AirPlayDevice} from '../types.js'; -import {commandExists} from '../platform/executables.js'; +import {resolveCommandDetails} from '../platform/executables.js'; import {networkPolicy} from '../platform/network.js'; -import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; +import {isAirPlayPlatformSupported} from './backend-install.js'; +import {probeAirPlayReadiness} from './airplay-readiness.js'; +import {runAirPlayCommand, type AirPlayCommandRunner} from './airplay-process.js'; export type AirPlayDiscoveryOptions = { platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + release?: string; + resolve?: (command: string) => string | undefined; + hasCommand?: (command: string) => boolean; + run?: AirPlayCommandRunner; + readiness?: typeof probeAirPlayReadiness; timeoutMs?: number; maxDevices?: number; lookupConcurrency?: number; @@ -21,32 +30,62 @@ const defaultMaxOutputBytes = 64 * 1024; export async function discoverAirPlayDevices({ platform = process.platform, + env = process.env, + release, + resolve = command => resolveCommandDetails(command, {platform, env}).path ?? undefined, + hasCommand = command => Boolean(resolve(command)), + run = runAirPlayCommand, + readiness = probeAirPlayReadiness, timeoutMs = defaultTimeoutMs, maxDevices = defaultMaxDevices, lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes }: AirPlayDiscoveryOptions = {}): Promise { - if (networkPolicy().offline || !nativeAdapters(identifyPlatform({platform})).airPlay || !commandExists('dns-sd')) { + if (networkPolicy(env).offline || !isAirPlayPlatformSupported(platform, env, release)) { return []; } + const tool = platform === 'linux' ? 'avahi-browse' : 'dns-sd'; + const executable = hasCommand(tool) ? resolve(tool) : undefined; + if (!executable) { + if (platform === 'linux') throw new Error('AirPlay discovery requires avahi-browse. Run radiocli doctor.'); + return []; + } + + timeoutMs = Math.min(defaultTimeoutMs, Math.max(1, timeoutMs)); + maxOutputBytes = Math.min(defaultMaxOutputBytes, Math.max(1, maxOutputBytes)); + maxDevices = Math.min(defaultMaxDevices, Math.max(0, Math.floor(maxDevices))); + if (platform === 'linux') { + const deadline = Date.now() + timeoutMs; + const health = await readiness({platform, env, release, resolve, hasCommand, run}); + if (!health.ready) throw new Error(health.message); + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error('AirPlay readiness check timed out. Run radiocli doctor.'); + const result = await run(executable, ['--parsable', '--resolve', '--terminate', '--no-db-lookup', '_raop._tcp'], remaining, maxOutputBytes, {env}); + if (result.code !== 0 && !result.timedOut && !result.truncated) throw new Error('Avahi discovery failed. Check that the Avahi service is running with radiocli doctor.'); + return parseAvahiBrowseOutput(result.output, maxDevices); + } - const browseOutput = await runDnsSd(['-B', '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => ''); + const browseOutput = await run(executable, ['-B', '_raop._tcp', 'local'], timeoutMs, maxOutputBytes, {env}).then(result => result.output).catch(() => ''); const instances = parseRaopBrowseOutput(browseOutput).slice(0, Math.max(0, maxDevices)); const devices = await mapWithConcurrency( instances, Math.max(1, lookupConcurrency), async instance => { - const lookupOutput = await runDnsSd(['-L', instance, '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => ''); + const lookupOutput = await run(executable, ['-L', instance, '_raop._tcp', 'local'], timeoutMs, maxOutputBytes, {env}).then(result => result.output).catch(() => ''); const device = parseRaopLookupOutput(instance, lookupOutput); - return device ? enrichAirPlayDeviceHost(device) : null; + return device ? enrichAirPlayDeviceHost(device, timeoutMs) : null; } ); return devices.filter((device): device is AirPlayDevice => Boolean(device)); } -async function enrichAirPlayDeviceHost(device: AirPlayDevice): Promise { - const addresses = await lookup(device.host, {all: true, family: 4}).catch(() => []); +async function enrichAirPlayDeviceHost(device: AirPlayDevice, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const addresses = await Promise.race([ + lookup(device.host, {all: true, family: 4}).catch(() => []), + new Promise(resolve => { timer = setTimeout(() => resolve([]), timeoutMs); }) + ]).finally(() => clearTimeout(timer)); if (addresses.length === 0) { return device; } @@ -94,6 +133,64 @@ export function parseRaopBrowseOutput(output: string): string[] { return [...instances]; } +/** avahi-browse -pr emits resolved IPv4 and IPv6 records separately. The + * bundled sender uses udp4, so an IPv6-only record is deliberately not offered. */ +export function parseAvahiBrowseOutput(output: string, maxDevices = defaultMaxDevices): AirPlayDevice[] { + const devices = new Map(); + const local = localIpv4Addresses(); + for (const line of output.slice(0, defaultMaxOutputBytes).split('\n')) { + const fields = avahiFields(line); + if (fields.length !== 10 || fields[0] !== '=' || fields[2] !== 'IPv4' || fields[4] !== '_raop._tcp' || fields[5] !== 'local') continue; + const host = fields[7]!; + const port = Number(fields[8]); + if (isIP(host) !== 4 || host === '0.0.0.0' || Number(host.split('.')[0]) >= 224 || !/^\d+$/.test(fields[8]!) || port < 1 || port > 65535) continue; + const id = sanitizeDnsSdText(unescapeDnsSdName(fields[3]!), 256); + if (!id) continue; + const txt = avahiTxt(fields[9]!); + if (!txt) continue; + const values = txtRecordMap(txt); + const sf = Number.parseInt((values.get('sf') ?? '0').replace(/^0x/i, ''), 16) || 0; + const device: AirPlayDevice = { + id, name: displayNameForRaopInstance(id), host, port, txt, + local: local.has(host), + requiresPassword: values.get('pw')?.toLowerCase() === 'true' || (sf !== 0 && sf !== 4), + airplay2: Boolean(values.get('pk') || values.get('vv') === '2' || values.get('et')?.split(',').some(value => ['3', '4', '5'].includes(value))) + }; + const prior = devices.get(id); + if (!prior || (isLoopbackAddress(prior.host) && !isLoopbackAddress(host))) devices.set(id, device); + } + return [...devices.values()].slice(0, Math.min(defaultMaxDevices, Math.max(0, Math.floor(maxDevices)))); +} + +function avahiFields(line: string): string[] { + const fields: string[] = []; + let start = 0; + for (let index = 0; index < line.length && fields.length < 9; index += 1) { + if (line[index] === '\\') { index += 1; continue; } + if (line[index] === ';') { fields.push(line.slice(start, index)); start = index + 1; } + } + fields.push(line.slice(start)); + return fields; +} + +function avahiTxt(value: string): string[] | null { + const entries: string[] = []; + let index = 0; + while (index < value.length) { + while (/\s/.test(value[index] ?? '') && index < value.length) index += 1; + if (index === value.length) break; + if (value[index] !== '"' || entries.length === 64) return null; + const start = ++index; + while (index < value.length && value[index] !== '"') { if (value[index] === '\\') index += 1; index += 1; } + if (index === value.length) return null; + const entry = sanitizeDnsSdText(unescapeDnsSdName(value.slice(start, index)), 512); + if (entry) entries.push(entry); + index += 1; + if (index < value.length && !/\s/.test(value[index]!)) return null; + } + return entries; +} + export function parseRaopLookupOutput(instance: string, output: string): AirPlayDevice | null { const reachable = output.match(/ can be reached at (.+?)\.?:(\d+) \(interface \d+\)/); if (!reachable?.[1] || !reachable[2]) { @@ -110,13 +207,13 @@ export function parseRaopLookupOutput(instance: string, output: string): AirPlay const txtLine = output .split('\n') .map(line => line.trim()) - .find(line => /\bcn=/.test(line) || /\bsf=/.test(line) || /\bpk=/.test(line)); + .find(line => /\b(?:cn|sf|pk|pw)=/.test(line)); const txt = txtLine ? txtLine.split(/\s+/).map(entry => sanitizeDnsSdText(entry, 512)).filter(Boolean).slice(0, 64) : []; const txtMap = txtRecordMap(txt); const sf = txtMap.get('sf'); - const sfValue = sf ? Number.parseInt(sf.replace(/^0x/i, ''), 16) : 0; + const sfValue = sf ? Number.parseInt(sf.replace(/^0x/i, ''), 16) || 0 : 0; return { id, @@ -124,7 +221,7 @@ export function parseRaopLookupOutput(instance: string, output: string): AirPlay host, port, txt, - requiresPassword: sfValue !== 0 && sfValue !== 0x4, + requiresPassword: txtMap.get('pw')?.toLowerCase() === 'true' || (sfValue !== 0 && sfValue !== 0x4), airplay2: Boolean(txtMap.get('pk') || txtMap.get('vv') === '2' || txtMap.get('et')?.split(',').some(value => ['3', '4', '5'].includes(value))) }; } @@ -149,7 +246,25 @@ function txtRecordMap(txt: string[]): Map { } function unescapeDnsSdName(value: string): string { - return value.replace(/\\(\d{3})/g, (_match, code: string) => String.fromCharCode(Number(code))); + const bytes: number[] = []; + for (let index = 0; index < value.length;) { + const match = /^\\(\d{3})/.exec(value.slice(index)); + if (match) { + const byte = Number(match[1]); + if (byte > 255) return ''; + bytes.push(byte); + index += 4; + } else if (value[index] === '\\' && index + 1 < value.length) { + bytes.push(...Buffer.from(value[index + 1]!)); + index += 2; + } else { + const character = String.fromCodePoint(value.codePointAt(index)!); + bytes.push(...Buffer.from(character)); + index += character.length; + } + } + try { return new TextDecoder('utf-8', {fatal: true}).decode(Uint8Array.from(bytes)); } + catch { return ''; } } function normalizeHost(value: string): string | null { @@ -171,55 +286,6 @@ function sanitizeDnsSdText(value: string, maxBytes: number): string { return cleaned.trim(); } -function runDnsSd(args: string[], timeoutMs: number, maxOutputBytes: number): Promise { - return new Promise(resolve => { - const child = spawn('dns-sd', args, {stdio: ['ignore', 'pipe', 'pipe']}); - let output = ''; - let killedForLimit = false; - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - clearTimeout(timer); - clearTimeout(forceTimer); - resolve(output); - }; - const timer = setTimeout(() => { - child.kill('SIGTERM'); - setTimeout(() => { - if (child.exitCode === null) child.kill('SIGKILL'); - }, 250).unref(); - }, timeoutMs); - const forceTimer = setTimeout(finish, timeoutMs + 750); - const appendOutput = (chunk: Buffer) => { - if (Buffer.byteLength(output, 'utf8') >= maxOutputBytes) { - if (!killedForLimit) { - killedForLimit = true; - child.kill('SIGTERM'); - } - - return; - } - - const remaining = maxOutputBytes - Buffer.byteLength(output, 'utf8'); - output += chunk.toString('utf8').slice(0, remaining); - }; - - child.stdout.on('data', chunk => { - appendOutput(chunk as Buffer); - }); - child.stderr.on('data', chunk => { - appendOutput(chunk as Buffer); - }); - child.once('error', () => { - finish(); - }); - child.once('exit', () => { - finish(); - }); - }); -} - async function mapWithConcurrency(items: T[], concurrency: number, map: (item: T) => Promise): Promise { const results: R[] = []; let nextIndex = 0; diff --git a/src/player/airplay-policy.test.ts b/src/player/airplay-policy.test.ts new file mode 100644 index 0000000..1dc26b6 --- /dev/null +++ b/src/player/airplay-policy.test.ts @@ -0,0 +1,17 @@ +import {describe, expect, it} from 'vitest'; +import {airPlayPolicy} from './airplay-policy.js'; + +describe('AirPlay opt-in policy', () => { + it('requires an explicit saved native Linux choice', () => { + for (const settings of [{}, {linuxAirPlayEnabled: false}, {preferredBackend: 'airplay' as const, preferredAirPlayDevice: 'saved'}]) { + expect(airPlayPolicy(settings, {platform: 'linux', env: {}, release: ''})).toMatchObject({eligible: true, enabled: false, state: 'disabled'}); + } + expect(airPlayPolicy({linuxAirPlayEnabled: true}, {platform: 'linux', env: {}, release: ''})).toMatchObject({enabled: true, state: 'enabled'}); + }); + it('preserves macOS regardless of the Linux preference', () => { + for (const linuxAirPlayEnabled of [undefined, false, true]) expect(airPlayPolicy({linuxAirPlayEnabled}, {platform: 'darwin', env: {}}).enabled).toBe(true); + }); + it.each([{platform: 'win32' as const}, {platform: 'freebsd' as const}, {platform: 'android' as const}, {platform: 'linux' as const, env: {WSL_DISTRO_NAME: 'Ubuntu'}}, {platform: 'linux' as const, env: {TERMUX_VERSION: '1'}}])('never opts unsupported environments in: %o', options => { + expect(airPlayPolicy({linuxAirPlayEnabled: true}, options)).toMatchObject({eligible: false, enabled: false, state: 'unsupported'}); + }); +}); diff --git a/src/player/airplay-policy.ts b/src/player/airplay-policy.ts new file mode 100644 index 0000000..7b51af8 --- /dev/null +++ b/src/player/airplay-policy.ts @@ -0,0 +1,22 @@ +import type {AppSettings} from '../types.js'; +import {identifyPlatform, nativeAdapters} from '../platform/runtime.js'; + +const airPlayUnsupportedMessage = 'AirPlay output is experimental on macOS and native Linux and is not supported in this environment.'; +const linuxAirPlayDisabledMessage = 'Experimental Linux AirPlay is disabled for this library. Run radiocli setup --airplay to enable it.'; +export const airPlaySetupRequiredMessage = 'AirPlay setup required. Run radiocli setup --airplay, then radiocli doctor.'; + +type PolicySettings = Partial>; +type PolicyOptions = {platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; release?: string}; + +export function isAirPlayPlatformSupported(platform: NodeJS.Platform = process.platform, env?: NodeJS.ProcessEnv, release?: string): boolean { + const native = platform === process.platform; + return nativeAdapters(identifyPlatform({platform, env: env ?? (native ? process.env : {}), release: release ?? (native ? undefined : '')})).airPlay; +} + +/** Application consent is independent of optional dependencies and receiver reachability. */ +export function airPlayPolicy(settings: PolicySettings = {}, {platform = process.platform, env, release}: PolicyOptions = {}) { + const eligible = isAirPlayPlatformSupported(platform, env, release); + const enabled = eligible && (platform === 'darwin' || settings.linuxAirPlayEnabled === true); + const state = !eligible ? 'unsupported' as const : !enabled ? 'disabled' as const : 'enabled' as const; + return {eligible, enabled, state, message: !eligible ? airPlayUnsupportedMessage : !enabled ? linuxAirPlayDisabledMessage : 'Experimental AirPlay is enabled.'}; +} diff --git a/src/player/airplay-process.test.ts b/src/player/airplay-process.test.ts new file mode 100644 index 0000000..d056a7a --- /dev/null +++ b/src/player/airplay-process.test.ts @@ -0,0 +1,70 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {runAirPlayCommand} from './airplay-process.js'; + +afterEach(() => vi.unstubAllEnvs()); + +describe('bounded AirPlay subprocess probes', () => { + it('keeps stderr private and preserves UTF-8 output', async () => { + expect(await runAirPlayCommand(process.execPath, ['-e', 'process.stdout.write("Café"); process.stderr.write("secret");'], 1000, 4096)).toEqual({output: 'Café', code: 0, truncated: false, timedOut: false}); + }); + it('terminates hanging tools by the deadline', async () => { + const start = Date.now(); + const result = await runAirPlayCommand(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], 100, 4096); + expect(result.timedOut).toBe(true); + expect(Date.now() - start).toBeLessThan(1000); + }); + it('bounds combined stdout and stderr even for noisy tools', async () => { + const result = await runAirPlayCommand(process.execPath, ['-e', 'process.stdout.write("a".repeat(100000)); process.stderr.write("b".repeat(100000));'], 1000, 64); + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.output)).toBeLessThanOrEqual(64); + }); + it('contains a missing executable failure', async () => { + expect(await runAirPlayCommand('/nonexistent/radiocli-airplay-tool', [], 1000, 4096)).toMatchObject({code: null, output: '', timedOut: false}); + }); + it('uses an explicit child environment without merging ambient overrides', async () => { + vi.stubEnv('DBUS_SYSTEM_BUS_ADDRESS', 'unixexec:path=/probe-only'); + vi.stubEnv('RADIOCLI_PROBE_SECRET', 'secret-token'); + const result = await runAirPlayCommand(process.execPath, ['-e', 'process.stdout.write(JSON.stringify({bus:process.env.DBUS_SYSTEM_BUS_ADDRESS,secret:process.env.RADIOCLI_PROBE_SECRET,locale:process.env.LC_ALL,chosen:process.env.RADIOCLI_PROBE_CHOSEN}))'], 1000, 4096, {env: {RADIOCLI_PROBE_CHOSEN: 'yes'}}); + expect(result).toEqual({output: '{"locale":"C","chosen":"yes"}', code: 0, timedOut: false, truncated: false}); + }); + + it.skipIf(process.platform === 'win32').each([ + ['airplay', 'timeout'], ['airplay', 'output-cap'], ['doctor', 'timeout'], ['doctor', 'output-cap'] + ] as const)('lets the parent Node process exit after %s %s with descendant-held pipes', async (probe, stop) => { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-probe-lifetime-')); + const helper = join(directory, 'probe-helper'); + // Both stdout and stderr remain open in the sleeping descendant. Measuring + // only promise settlement would miss those handles keeping Node alive. + writeFileSync(helper, `#!/bin/sh\n/bin/sleep 5 &\nprintf ready\nprintf private >&2\n${stop === 'output-cap' ? 'printf %8192s x >&2\n' : ''}wait\n`, {mode: 0o700}); + const code = probe === 'airplay' + ? `import {runAirPlayCommand} from ${JSON.stringify(new URL('./airplay-process.ts', import.meta.url).href)}; + const result = await runAirPlayCommand(${JSON.stringify(helper)}, [], 100, 4096); + console.log(JSON.stringify({bounded: result.timedOut || result.truncated}));` + : `import {probeNativeIntegrations} from ${JSON.stringify(new URL('../platform/diagnostics.ts', import.meta.url).href)}; + import {identifyPlatform} from ${JSON.stringify(new URL('../platform/runtime.ts', import.meta.url).href)}; + const report = await probeNativeIntegrations({ + host: {...identifyPlatform(), id: 'linux'}, env: {DBUS_SESSION_BUS_ADDRESS: 'unix:path=/probe-bus'}, + libraryPath: () => ${JSON.stringify(join(directory, 'absent.json'))}, + resolve: command => command === 'busctl' ? ${JSON.stringify(helper)} : undefined, + omarchy: () => {throw new Error('unverified')}, + terminalPlan: () => {throw new Error('unverified')}, + mpv: () => ({status: 'unsupported', args: [], reason: 'unverified'}), isSocket: () => false, + airPlay: async () => ({eligible:false,discoveryExecutable:false,discoveryService:'unverified',senderLoaded:false,ffmpeg:false,ready:false,message:'unverified'}) + }); + console.log(JSON.stringify({bounded: report.desktopMedia.sessionBus.status === 'unavailable'}));`; + try { + const start = Date.now(); + const result = await promisify(execFile)(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', code], { + cwd: fileURLToPath(new URL('../../', import.meta.url)), timeout: 8000, maxBuffer: 4096 + }); + expect(result.stdout.trim()).toBe('{"bounded":true}'); + expect(Date.now() - start).toBeLessThan(3000); + } finally { rmSync(directory, {recursive: true, force: true}); } + }, 10_000); +}); +import {execFile} from 'node:child_process'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {promisify} from 'node:util'; diff --git a/src/player/airplay-process.ts b/src/player/airplay-process.ts new file mode 100644 index 0000000..b6c8c0c --- /dev/null +++ b/src/player/airplay-process.ts @@ -0,0 +1,46 @@ +import {spawn} from 'node:child_process'; + +type AirPlayCommandResult = {output: string; code: number | null; timedOut: boolean; truncated: boolean}; +export type AirPlayCommandRunner = (command: string, args: string[], timeoutMs: number, maxOutputBytes: number, options?: {env?: NodeJS.ProcessEnv}) => Promise; + +/** Short, bounded probes. Only stdout is returned; protocol diagnostics stay private. */ +export const runAirPlayCommand: AirPlayCommandRunner = (command, args, timeoutMs, maxOutputBytes, options) => new Promise(resolve => { + const chunks: Buffer[] = []; + let bytes = 0; + let totalBytes = 0; + let settled = false; + let child: ReturnType; + const finish = (code: number | null, timedOut = false, truncated = false) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({output: Buffer.concat(chunks, bytes).toString('utf8'), code, timedOut, truncated}); + }; + const stop = () => { + if (settled || !child) return; + // A dedicated POSIX process group includes ordinary helper descendants. + // Closing inherited pipes and releasing the child handle also bounds the + // parent on Windows, or if a descendant escaped into a separate session. + if (process.platform !== 'win32' && child.pid) { + try { process.kill(-child.pid, 'SIGKILL'); } catch { /* The group may already have exited. */ } + } + try { child.kill('SIGKILL'); } catch { /* Still release our handles if termination fails. */ } + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); + }; + const timer = setTimeout(() => { stop(); finish(null, true); }, Math.max(1, timeoutMs)); + try { child = spawn(command, args, {detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: {...(options?.env ?? process.env), LC_ALL: 'C'}}); } + catch { finish(null); return; } + const append = (chunk: Buffer, stdout: boolean) => { + if (settled) return; + const remaining = Math.max(0, maxOutputBytes - totalBytes); + totalBytes += chunk.length; + if (stdout && remaining > 0) { const kept = chunk.subarray(0, remaining); chunks.push(kept); bytes += kept.length; } + if (totalBytes > maxOutputBytes) { stop(); finish(null, false, true); } + }; + child.stdout?.on('data', (chunk: Buffer) => append(chunk, true)); + child.stderr?.on('data', (chunk: Buffer) => append(chunk, false)); + child.once('error', () => { stop(); finish(null); }); + child.once('close', code => finish(code)); +}); diff --git a/src/player/airplay-readiness.test.ts b/src/player/airplay-readiness.test.ts new file mode 100644 index 0000000..2170018 --- /dev/null +++ b/src/player/airplay-readiness.test.ts @@ -0,0 +1,111 @@ +import {airPlaySenderPackageJson} from './airplay-sender-package.js'; +import {EventEmitter} from 'node:events'; +import * as childProcess from 'node:child_process'; +import {PassThrough} from 'node:stream'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import * as executables from '../platform/executables.js'; +import {probeAirPlayReadiness} from './airplay-readiness.js'; +import {probeAirPlaySender, type AirPlaySenderHealth} from './airplay-sender-health.js'; + +vi.mock('node:child_process', async importOriginal => { + const actual = await importOriginal(); + return {...actual, spawn: vi.fn()}; +}); + +afterEach(() => { vi.mocked(childProcess.spawn).mockReset(); vi.restoreAllMocks(); vi.unstubAllEnvs(); }); + +const linux = {platform: 'linux' as const, env: {}, release: '6.6', resolve: (command: string) => `/usr/bin/${command}`, hasCommand: () => true}; +const success = {output: 'i 2\n', code: 0, timedOut: false, truncated: false}; +const health: AirPlaySenderHealth = {available: true, safe: true, packageName: 'node-airtunes2', version: '2.5.0', vulnerablePackages: [], warningPackages: [], message: 'Safe'}; + +describe('explicit AirPlay readiness without LAN discovery', () => { + it('checks running Avahi without service activation and native sender separately', async () => { + const run = vi.fn(async () => success); + const sender = vi.fn(async () => ({loaded: true, message: 'Loaded'})); + expect(await probeAirPlayReadiness({...linux, run, sender})).toMatchObject({ready: true, discoveryExecutable: true, discoveryService: 'ready', senderLoaded: true}); + expect(run).toHaveBeenCalledExactlyOnceWith('/usr/bin/busctl', ['--address=unix:path=%2Frun%2Fdbus%2Fsystem_bus_socket', '--auto-start=no', '--timeout=0.5', 'call', 'org.freedesktop.Avahi', '/', 'org.freedesktop.Avahi.Server', 'GetState'], 600, 4096, {env: {}}); + }); + + it('uses a platform/environment-aware resolved executable when PATH omits system directories', async () => { + const env = {PATH: '/nonexistent'}; + const resolve = vi.spyOn(executables, 'resolveCommandDetails').mockImplementation(command => ({path: `/usr/bin/${command}`, discovery: 'package-manager-shim'})); + const run = vi.fn(async () => success); + const report = await probeAirPlayReadiness({platform: 'linux', release: '6.6', env, run, sender: async () => ({loaded: true, message: 'Loaded'})}); + expect(report.discoveryService).toBe('ready'); + expect(resolve).toHaveBeenCalledWith('busctl', {platform: 'linux', env}); + expect(run).toHaveBeenCalledWith('/usr/bin/busctl', expect.any(Array), 600, 4096, {env}); + }); + + it.each(['unixexec:path=/probe-only', 'tcp:host=secret.test,port=1234', 'autolaunch:', 'unix:path=relative', 'unix:path=/bad%00'])('never runs a configured unsafe system-bus transport: %s', address => { + return (async () => { + const run = vi.fn(); + expect(await probeAirPlayReadiness({...linux, env: {DBUS_SYSTEM_BUS_ADDRESS: address}, run, sender: async () => ({loaded: true, message: 'Loaded'})})).toMatchObject({ready: false, discoveryService: 'unavailable'}); + expect(run).not.toHaveBeenCalled(); + })(); + }); + + it.each([ + {provided: {}, expected: 'unix:path=%2Frun%2Fdbus%2Fsystem_bus_socket'}, + {provided: {PATH: '/test/bin', DBUS_SYSTEM_BUS_ADDRESS: 'unixexec:path=/probe-only;unix:path=/tmp/local%20bus,guid=private;tcp:host=secret.test', DBUS_SESSION_BUS_ADDRESS: 'unixexec:path=/secret-session', DBUS_STARTER_ADDRESS: 'autolaunch:private'}, expected: 'unix:path=%2Ftmp%2Flocal%20bus'}, + {provided: {DBUS_SYSTEM_BUS_ADDRESS: 'unix:abstract=/tmp/local-bus,guid=private'}, expected: 'unix:abstract=%2Ftmp%2Flocal-bus'} + ])('uses the default runner with only the supplied environment and a normalized Unix address', async ({provided, expected}) => { + vi.stubEnv('DBUS_SYSTEM_BUS_ADDRESS', 'unixexec:path=/ambient-probe-only'); + vi.stubEnv('DBUS_SESSION_BUS_ADDRESS', 'tcp:host=ambient-secret.test'); + vi.stubEnv('DBUS_STARTER_ADDRESS', 'autolaunch:ambient-secret'); + vi.stubEnv('RADIOCLI_PROBE_SECRET', 'ambient-secret'); + const spawn = vi.mocked(childProcess.spawn).mockImplementation(() => { + const child = Object.assign(new EventEmitter(), {stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(), unref: vi.fn()}); + queueMicrotask(() => { child.stdout.write('i 2\n'); child.emit('close', 0); }); + return child as unknown as childProcess.ChildProcess; + }); + const report = await probeAirPlayReadiness({...linux, env: provided, sender: async () => ({loaded: true, message: 'Loaded'})}); + expect(report.discoveryService).toBe('ready'); + expect(spawn).toHaveBeenCalledExactlyOnceWith('/usr/bin/busctl', [`--address=${expected}`, '--auto-start=no', '--timeout=0.5', 'call', 'org.freedesktop.Avahi', '/', 'org.freedesktop.Avahi.Server', 'GetState'], { + detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: 'PATH' in provided ? {PATH: provided.PATH, LC_ALL: 'C'} : {LC_ALL: 'C'} + }); + expect(JSON.stringify(spawn.mock.calls)).not.toMatch(/unixexec|tcp:|autolaunch|ambient-secret|probe-only|DBUS_|private/); + expect(JSON.stringify(report)).not.toMatch(/\/tmp\/|\/run\/|secret/); + }); + + it('keeps macOS discovery prerequisites independent from Linux D-Bus configuration', async () => { + const run = vi.fn(); + expect(await probeAirPlayReadiness({...linux, platform: 'darwin', env: {DBUS_SYSTEM_BUS_ADDRESS: 'unixexec:path=/probe-only'}, run, sender: async () => ({loaded: true, message: 'Loaded'})})).toMatchObject({discoveryExecutable: true, discoveryService: 'unverified', ready: true}); + expect(run).not.toHaveBeenCalled(); + }); + + it.each([{...success, code: 1}, {...success, output: 'i 1'}, {...success, timedOut: true}, {...success, truncated: true}])('keeps an installed discovery tool independent from daemon readiness: %j', async result => { + expect(await probeAirPlayReadiness({...linux, run: async () => result, sender: async () => ({loaded: true, message: 'Loaded'})})).toMatchObject({ready: false, discoveryExecutable: true, discoveryService: 'unavailable', senderLoaded: true}); + }); + + it('reports an unverified service without inventing a mandatory busctl dependency', async () => { + const run = vi.fn(); + expect(await probeAirPlayReadiness({...linux, hasCommand: command => command !== 'busctl', run, sender: async () => ({loaded: true, message: 'Loaded'})})).toMatchObject({ready: true, discoveryService: 'unverified'}); + expect(run).not.toHaveBeenCalled(); + }); + + it('does not load a sender on unsupported hosts', async () => { + const sender = vi.fn(); + expect(await probeAirPlayReadiness({...linux, env: {WSL_INTEROP: '1'}, sender})).toMatchObject({eligible: false, ready: false, senderLoaded: false}); + expect(sender).not.toHaveBeenCalled(); + }); + + it('reports a present but unloadable optional sender accurately', async () => { + expect(await probeAirPlayReadiness({...linux, run: async () => success, sender: async () => ({loaded: false, message: 'Native module failed'})})).toMatchObject({ready: false, discoveryService: 'ready', senderLoaded: false}); + }); + + it('does not load absent or safety-blocked optional packages', async () => { + const run = vi.fn(); + expect(await probeAirPlaySender({health: {...health, safe: false}, run})).toMatchObject({loaded: false}); + expect(run).not.toHaveBeenCalled(); + }); + + it.each([{...success, code: 1}, {...success, code: null}, {...success, timedOut: true}, {...success, truncated: true}])('contains isolated native module failure: %j', async result => { + expect(await probeAirPlaySender({health, run: async () => result})).toMatchObject({loaded: false}); + }); + + it('bounds the isolated load and verifies its callable sender interface', async () => { + const run = vi.fn(async () => success); + expect(await probeAirPlaySender({health, run, timeoutMs: 9000})).toMatchObject({loaded: true}); + expect(run).toHaveBeenCalledWith(process.execPath, ['-e', expect.stringContaining('s.prototype.add'), airPlaySenderPackageJson], 1500, 4096); + }); +}); diff --git a/src/player/airplay-readiness.ts b/src/player/airplay-readiness.ts new file mode 100644 index 0000000..2af89c6 --- /dev/null +++ b/src/player/airplay-readiness.ts @@ -0,0 +1,61 @@ +import {resolveCommandDetails} from '../platform/executables.js'; +import {sessionBusSocket} from '../platform/mpris.js'; +import {isAirPlayPlatformSupported} from './backend-install.js'; +import {runAirPlayCommand, type AirPlayCommandRunner} from './airplay-process.js'; +import {probeAirPlaySender, type AirPlaySenderLoadability} from './airplay-sender-health.js'; + +export type AirPlayReadiness = { + eligible: boolean; + discoveryExecutable: boolean; + discoveryService: 'ready' | 'unavailable' | 'unverified'; + senderLoaded: boolean; + ffmpeg: boolean; + ready: boolean; + message: string; +}; + +type ReadinessOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + release?: string; + resolve?: (command: string) => string | undefined; + hasCommand?: (command: string) => boolean; + run?: AirPlayCommandRunner; + sender?: () => Promise; +}; + +/** Does not browse the network, activate services, or open a receiver. */ +export async function probeAirPlayReadiness({platform = process.platform, env = process.env, release, + resolve = command => resolveCommandDetails(command, {platform, env}).path ?? undefined, + hasCommand = command => Boolean(resolve(command)), run = runAirPlayCommand, sender = probeAirPlaySender}: ReadinessOptions = {}): Promise { + const eligible = isAirPlayPlatformSupported(platform, env, release); + const discoveryExecutable = eligible && hasCommand(platform === 'linux' ? 'avahi-browse' : 'dns-sd'); + const ffmpeg = eligible && hasCommand('ffmpeg'); + const busctl = eligible && platform === 'linux' && hasCommand('busctl') ? resolve('busctl') : undefined; + let discoveryService: AirPlayReadiness['discoveryService'] = 'unverified'; + const senderResult = eligible ? sender() : Promise.resolve({loaded: false, message: 'AirPlay is not supported in this environment.'}); + if (!discoveryExecutable) discoveryService = 'unavailable'; + else if (platform === 'linux' && busctl) { + const socket = sessionBusSocket(env.DBUS_SYSTEM_BUS_ADDRESS ?? 'unix:path=/run/dbus/system_bus_socket'); + if (!socket) discoveryService = 'unavailable'; + else { + // --system still honors executable/network transport overrides. Pass only + // one validated Unix address and remove all inherited D-Bus fallbacks. + const address = `unix:${socket.startsWith('\0') ? 'abstract' : 'path'}=${encodeURIComponent(socket.startsWith('\0') ? socket.slice(1) : socket)}`; + const childEnv = {...env}; + for (const key of Object.keys(childEnv)) if (key.startsWith('DBUS_')) delete childEnv[key]; + const result = await run(busctl, [`--address=${address}`, '--auto-start=no', '--timeout=0.5', 'call', 'org.freedesktop.Avahi', '/', 'org.freedesktop.Avahi.Server', 'GetState'], 600, 4096, {env: childEnv}); + discoveryService = result.code === 0 && /^i\s+2\s*$/.test(result.output.trim()) && !result.truncated && !result.timedOut ? 'ready' : 'unavailable'; + } + } + const senderLoaded = (await senderResult).loaded; + const ready = eligible && discoveryExecutable && ffmpeg && senderLoaded && discoveryService !== 'unavailable'; + const message = !eligible ? 'AirPlay is experimental on macOS and native Linux; this environment is not supported.' + : !discoveryExecutable ? `AirPlay discovery requires ${platform === 'linux' ? 'avahi-browse' : 'dns-sd'}.` + : discoveryService === 'unavailable' ? 'The Avahi discovery service is not ready. Local playback remains available.' + : !ffmpeg ? 'AirPlay playback requires FFmpeg.' + : !senderLoaded ? 'The optional AirPlay sender could not load. Local playback remains available.' + : discoveryService === 'unverified' ? 'AirPlay prerequisites are present; the discovery service and receiver connection must still be verified.' + : 'Experimental AirPlay prerequisites are ready; select a receiver to verify playback.'; + return {eligible, discoveryExecutable, discoveryService, senderLoaded, ffmpeg, ready, message}; +} diff --git a/src/player/airplay-sender-crypto.test.ts b/src/player/airplay-sender-crypto.test.ts new file mode 100644 index 0000000..461c9df --- /dev/null +++ b/src/player/airplay-sender-crypto.test.ts @@ -0,0 +1,73 @@ +import {createPublicKey, verify} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {join} from 'node:path'; +import {describe, expect, it} from 'vitest'; +import {airPlaySenderRoot} from './airplay-sender-package.js'; +import {airPlaySenderHealth} from './airplay-sender-health.js'; + +const require = createRequire(import.meta.url); +const ed25519 = require(join(airPlaySenderRoot, 'lib/ed25519.js')) as {publicKey: (seed: string) => string; sign: (seed: string, message: string) => string}; +// Deterministic test-only seeds and outputs captured from verified upstream 2.5.0. +const vectors = JSON.parse(readFileSync(new URL('./fixtures/airplay-legacy-crypto.json', import.meta.url), 'utf8')) as Array<{ + seed: string; K: string; clientPublic: string; receiverPublic: string; shared: string; receiverData: string; + public: string; confirm: {epk: string; authTag: string}; signed: string; signature: string; +}>; + +describe('AirPlay legacy Ed25519 replacement', () => { + it('matches RFC 8032 test vector 1 byte-for-byte with synchronous results', () => { + const seed = '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'; + expect(ed25519.publicKey(seed)).toBe('d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'); + expect(ed25519.sign(seed, '')).toBe(('e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555f' + + 'b8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b').toUpperCase()); + expect(ed25519.publicKey(seed.toUpperCase())).toBe(ed25519.publicKey(seed)); + }); + + it.each(['', 'ff', 'ab'.repeat(31), 'ab'.repeat(33), 'z1'.repeat(32), ' ab'.repeat(32)])('rejects malformed seeds without exposing them: %s', seed => { + expect(() => ed25519.publicKey(seed)).toThrow('Invalid Ed25519 hexadecimal input'); + }); + + it.each(['0', 'gg', '01 02'])('rejects malformed messages instead of silently truncating: %s', message => { + expect(() => ed25519.sign(vectors[0]!.seed, message)).toThrow('Invalid Ed25519 hexadecimal input'); + }); + + it('ships the fixed primitive with no elliptic dependency', () => { + const metadata = JSON.parse(readFileSync(join(airPlaySenderRoot, 'package.json'), 'utf8')); + expect(metadata.radiocliLegacyPairing).toBe('node:crypto'); + expect(metadata.dependencies).not.toHaveProperty('elliptic'); + const source = readFileSync(join(airPlaySenderRoot, 'lib/atvAuthenticator.js'), 'utf8'); + expect(source).not.toContain("require('elliptic')"); + expect(source).toContain("require('./ed25519')"); + }); +}); + +describe.skipIf(!airPlaySenderHealth().available)('AirPlay legacy pairing wire compatibility', () => { + const load = () => require(join(airPlaySenderRoot, 'lib/atvAuthenticator.js')) as { + a_pub: (seed: string) => string; + confirm: (seed: string, key: string) => {epk: string; authTag: string}; + signed: (seed: string, clientPublic: string, receiverPublic: string) => string; + signature: (shared: string, data: string, signed: string) => string; + verifier: (seed: string) => {verifierBody: Buffer; v_pri: string; v_pub: string}; + shared: (privateKey: string, publicKey: string) => string; + }; + + it.each(vectors)('preserves public keys, encrypted confirmations and signatures for seed $seed', vector => { + const auth = load(); + expect(auth.a_pub(vector.seed)).toBe(vector.public); + expect(auth.confirm(vector.seed, vector.K)).toEqual(vector.confirm); + const signed = auth.signed(vector.seed, vector.clientPublic, vector.receiverPublic); + expect(signed).toBe(vector.signed); + expect(auth.signature(vector.shared, vector.receiverData, signed)).toBe(vector.signature); + const publicKey = createPublicKey({key: {kty: 'OKP', crv: 'Ed25519', x: Buffer.from(vector.public, 'hex').toString('base64url')}, format: 'jwk'}); + expect(verify(null, Buffer.from(vector.clientPublic + vector.receiverPublic, 'hex'), publicKey, Buffer.from(signed, 'hex'))).toBe(true); + }); + + it('retains the verifier body layout and symmetric X25519 shared secret', () => { + const auth = load(); const first = auth.verifier(vectors[0]!.seed); const second = auth.verifier(vectors[1]!.seed); + expect(first.verifierBody).toHaveLength(68); + expect(first.verifierBody.subarray(0, 4)).toEqual(Buffer.from([1, 0, 0, 0])); + expect(first.verifierBody.subarray(4, 36).toString('hex')).toBe(first.v_pub); + expect(first.verifierBody.subarray(36).toString('hex')).toBe(vectors[0]!.public); + expect(auth.shared(first.v_pri, second.v_pub)).toBe(auth.shared(second.v_pri, first.v_pub)); + }); +}); diff --git a/src/player/airplay-sender-health.test.ts b/src/player/airplay-sender-health.test.ts index c3f1672..471ce17 100644 --- a/src/player/airplay-sender-health.test.ts +++ b/src/player/airplay-sender-health.test.ts @@ -1,60 +1,44 @@ -import {describe, expect, it} from 'vitest'; -import {airPlaySenderHealth} from './airplay-sender-health.js'; +import {describe, expect, it, vi} from 'vitest'; +import {airPlaySenderHealth, probeAirPlaySender} from './airplay-sender-health.js'; +import {airPlaySenderPackageJson} from './airplay-sender-package.js'; -describe('AirPlay sender health gate', () => { - it('blocks AirPlay when the optional sender is missing', () => { - const health = airPlaySenderHealth(() => null); +const sender = {name: 'node-airtunes2', version: '2.5.0-radiocli.1', root: '/test/vendor/node-airtunes2', nativeEd25519: true, dependencies: ['@noble/ed25519', 'async']}; +const dependency = {name: 'dependency', version: '1', root: '/test/node_modules'}; - expect(health).toMatchObject({ - available: false, - safe: false, - packageName: 'node-airtunes2' - }); +describe('AirPlay sender health gate', () => { + it('blocks AirPlay when the bundled sender is missing', () => { + expect(airPlaySenderHealth(() => null)).toMatchObject({available: false, safe: false, packageName: 'node-airtunes2'}); }); - it('blocks node-airtunes2 installs with high-risk transitive dependencies', () => { - const health = airPlaySenderHealth(packageLookup({ - 'node-airtunes2': '2.5.0', - protobufjs: '6.11.6', - elliptic: '6.6.1' - })); + it('keeps omit-optional installs unavailable and leaves local playback usable', () => { + const health = airPlaySenderHealth(name => name === 'node-airtunes2' ? sender : null); + expect(health).toMatchObject({available: false, safe: false, vulnerablePackages: [], warningPackages: []}); + expect(health.message).toContain('@noble/ed25519, async'); + expect(health.message).toContain('Local playback remains available'); + }); + it('detects partially installed optional runtime dependencies', () => { + const health = airPlaySenderHealth(name => name === 'node-airtunes2' ? sender : name === 'async' ? dependency : null); expect(health.safe).toBe(false); - expect(health.vulnerablePackages).toEqual(['protobufjs@6.11.6']); - expect(health.warningPackages).toEqual(['elliptic@6.6.1']); + expect(health.message).toContain('@noble/ed25519'); + expect(health.message).not.toContain('@noble/ed25519, async'); }); - it('allows the bundled sender when only the no-fix low-risk elliptic advisory remains', () => { - const health = airPlaySenderHealth(packageLookup({ - 'node-airtunes2': '2.5.0', - protobufjs: '7.6.2', - elliptic: '6.6.1' - })); - - expect(health.safe).toBe(true); - expect(health.vulnerablePackages).toEqual([]); - expect(health.warningPackages).toEqual(['elliptic@6.6.1']); + it('rejects a sender that lacks the native Ed25519 fix', () => { + const health = airPlaySenderHealth(name => name === 'node-airtunes2' ? {...sender, nativeEd25519: false} : dependency); + expect(health).toMatchObject({available: true, safe: false, vulnerablePackages: ['node-airtunes2@2.5.0-radiocli.1']}); }); - it('allows future patched sender installs', () => { - const health = airPlaySenderHealth(packageLookup({ - 'node-airtunes2': '2.5.1', - protobufjs: '7.5.8', - elliptic: '6.6.2' - })); + it('accepts the fixed sender without consulting unrelated parent-project dependencies', () => { + const lookup = vi.fn((name: string) => name === 'node-airtunes2' ? sender : dependency); + expect(airPlaySenderHealth(lookup)).toMatchObject({available: true, safe: true, warningPackages: [], vulnerablePackages: []}); + expect(lookup.mock.calls.map(([name]) => name)).toEqual(['node-airtunes2', '@noble/ed25519', 'async']); + }); - expect(health).toMatchObject({ - available: true, - safe: true, - version: '2.5.1' - }); - expect(health.warningPackages).toEqual([]); + it('probes the bundled package in an isolated process without importing a parent sender', async () => { + const run = vi.fn(async (_command: string, _args: string[]) => ({code: 0, output: '', timedOut: false, truncated: false})); + const health = airPlaySenderHealth(name => name === 'node-airtunes2' ? sender : dependency); + expect((await probeAirPlaySender({health, run})).loaded).toBe(true); + expect(run.mock.calls[0]?.[1]?.at(-1)).toBe(airPlaySenderPackageJson); }); }); - -function packageLookup(packages: Record) { - return (packageName: string) => { - const version = packages[packageName]; - return version ? {name: packageName, version, root: `/tmp/${packageName}`} : null; - }; -} diff --git a/src/player/airplay-sender-health.ts b/src/player/airplay-sender-health.ts index 2c388c8..2cb145f 100644 --- a/src/player/airplay-sender-health.ts +++ b/src/player/airplay-sender-health.ts @@ -1,6 +1,8 @@ import {readFileSync} from 'node:fs'; import {createRequire} from 'node:module'; import {dirname} from 'node:path'; +import {runAirPlayCommand, type AirPlayCommandRunner} from './airplay-process.js'; +import {airPlaySenderPackageJson} from './airplay-sender-package.js'; const require = createRequire(import.meta.url); @@ -10,6 +12,8 @@ type PackageVersion = { name: string; version: string; root: string; + nativeEd25519?: boolean; + dependencies?: string[]; }; type PackageLookup = (packageName: string, paths?: string[]) => PackageVersion | null; @@ -24,6 +28,34 @@ export type AirPlaySenderHealth = { warningPackages: string[]; }; +export type AirPlaySenderLoadability = {loaded: boolean; message: string}; +let senderProbe: {key: string; until: number; result: Promise} | undefined; + +/** Explicit diagnostics/discovery only: loading native code stays outside the TUI. */ +export async function probeAirPlaySender(options: {health?: AirPlaySenderHealth; run?: AirPlayCommandRunner; timeoutMs?: number} = {}): Promise { + const health = options.health ?? airPlaySenderHealth(); + if (!health.safe) return {loaded: false, message: health.message}; + const key = `${process.execPath}:${health.packageName}:${health.version}`; + if (!options.run && senderProbe?.key === key && senderProbe.until > Date.now()) return senderProbe.result; + const result = (async () => { + const code = [ + "const r = require('node:module').createRequire(process.argv[1]);", + "try { const s = r('./');", + "if (typeof s !== 'function' || typeof s.prototype.add !== 'function' || typeof s.prototype.stopAll !== 'function') process.exit(1);", + // The Linux binding is otherwise loaded only when constructing a receiver. + // Match the sender's JS fallback on a normal require failure, but contain + // native crashes here before advertising a loadable sender. No sockets. + "if (process.platform === 'linux') { try { r('./build/Release/airtunes'); } catch {} }", + 'process.exit(0); } catch { process.exit(1); }' + ].join('\n'); + const probe = await (options.run ?? runAirPlayCommand)(process.execPath, ['-e', code, airPlaySenderPackageJson], Math.min(1500, Math.max(1, options.timeoutMs ?? 1500)), 4096); + const loaded = probe.code === 0 && !probe.timedOut && !probe.truncated; + return {loaded, message: loaded ? 'The optional AirPlay sender loaded in an isolated process.' : 'The optional AirPlay sender could not load. Local playback remains available; run radiocli doctor.'}; + })(); + if (!options.run) senderProbe = {key, until: Date.now() + 30_000, result}; + return result; +} + export function airPlaySenderHealth(lookupPackage: PackageLookup = lookupInstalledPackage): AirPlaySenderHealth { const sender = lookupPackage(airPlaySenderPackageName); if (!sender) { @@ -37,17 +69,21 @@ export function airPlaySenderHealth(lookupPackage: PackageLookup = lookupInstall }; } - const vulnerablePackages = blockingAirPlaySenderPackages(sender, lookupPackage); - const warningPackages = warningAirPlaySenderPackages(sender, lookupPackage); - if (vulnerablePackages.length > 0) { + const missingPackages = (sender.dependencies ?? []).filter(name => !lookupPackage(name, [sender.root])); + if (missingPackages.length > 0) { + return {available: false, safe: false, packageName: sender.name, version: sender.version, + message: `Optional AirPlay sender dependencies are not installed: ${missingPackages.join(', ')}. Local playback remains available.`, + vulnerablePackages: [], warningPackages: []}; + } + if (!sender.nativeEd25519) { return { available: true, safe: false, packageName: sender.name, version: sender.version, - message: `Installed AirPlay sender ${sender.name}@${sender.version} is blocked because vulnerable transitive dependencies were found: ${vulnerablePackages.join(', ')}.`, - vulnerablePackages, - warningPackages + message: `AirPlay sender ${sender.name}@${sender.version} is missing the native Ed25519 security fix.`, + vulnerablePackages: [`${sender.name}@${sender.version}`], + warningPackages: [] }; } @@ -56,44 +92,22 @@ export function airPlaySenderHealth(lookupPackage: PackageLookup = lookupInstall safe: true, packageName: sender.name, version: sender.version, - message: warningPackages.length > 0 - ? `Bundled AirPlay sender ${sender.name}@${sender.version} is available with warnings: ${warningPackages.join(', ')}.` - : `AirPlay sender ${sender.name}@${sender.version} passed the dependency safety gate.`, + message: `AirPlay sender ${sender.name}@${sender.version} uses native Ed25519 with its optional runtime dependencies installed.`, vulnerablePackages: [], - warningPackages + warningPackages: [] }; } -function blockingAirPlaySenderPackages(sender: PackageVersion, lookupPackage: PackageLookup): string[] { - const packagePaths = [sender.root]; - const vulnerable: string[] = []; - const protobuf = lookupPackage('protobufjs', packagePaths); - - if (protobuf && compareSemver(protobuf.version, '7.5.8') < 0) { - vulnerable.push(`protobufjs@${protobuf.version}`); - } - - return vulnerable; -} - -function warningAirPlaySenderPackages(sender: PackageVersion, lookupPackage: PackageLookup): string[] { - const packagePaths = [sender.root]; - const vulnerable: string[] = []; - const elliptic = lookupPackage('elliptic', packagePaths); - - if (elliptic && compareSemver(elliptic.version, '6.6.2') < 0) { - vulnerable.push(`elliptic@${elliptic.version}`); - } - - return vulnerable; -} - function lookupInstalledPackage(packageName: string, paths?: string[]): PackageVersion | null { try { - const packageJsonPath = paths + // Package exports can hide package.json while exposing the runtime entry. + if (paths && packageName !== airPlaySenderPackageName) { + return {name: packageName, version: 'installed', root: dirname(require.resolve(packageName, {paths}))}; + } + const packageJsonPath = packageName === airPlaySenderPackageName ? airPlaySenderPackageJson : paths ? require.resolve(`${packageName}/package.json`, {paths}) : require.resolve(`${packageName}/package.json`); - const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {name?: string; version?: string}; + const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {name?: string; version?: string; radiocliLegacyPairing?: string; dependencies?: Record}; if (!parsed.version) { return null; } @@ -101,31 +115,10 @@ function lookupInstalledPackage(packageName: string, paths?: string[]): PackageV return { name: parsed.name ?? packageName, version: parsed.version, - root: dirname(packageJsonPath) + root: dirname(packageJsonPath), + ...(packageName === airPlaySenderPackageName ? {nativeEd25519: parsed.radiocliLegacyPairing === 'node:crypto', dependencies: Object.keys(parsed.dependencies ?? {})} : {}) }; } catch { return null; } } - -function compareSemver(left: string, right: string): number { - const leftParts = semverParts(left); - const rightParts = semverParts(right); - for (let index = 0; index < 3; index += 1) { - const delta = leftParts[index]! - rightParts[index]!; - if (delta !== 0) { - return delta; - } - } - - return 0; -} - -function semverParts(version: string): [number, number, number] { - const [major = '0', minor = '0', patch = '0'] = version.split(/[.-]/); - return [ - Number.parseInt(major, 10) || 0, - Number.parseInt(minor, 10) || 0, - Number.parseInt(patch, 10) || 0 - ]; -} diff --git a/src/player/airplay-sender-package.ts b/src/player/airplay-sender-package.ts new file mode 100644 index 0000000..d1eef99 --- /dev/null +++ b/src/player/airplay-sender-package.ts @@ -0,0 +1,6 @@ +import {dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +// The same relative location works from src/player and the packaged dist/player. +export const airPlaySenderPackageJson = fileURLToPath(new URL('../../vendor/node-airtunes2/package.json', import.meta.url)); +export const airPlaySenderRoot = dirname(airPlaySenderPackageJson); diff --git a/src/player/airplay-sender-patch.ts b/src/player/airplay-sender-patch.ts index 746735d..cb7a08f 100644 --- a/src/player/airplay-sender-patch.ts +++ b/src/player/airplay-sender-patch.ts @@ -1,5 +1,6 @@ import {readFileSync} from 'node:fs'; import {createRequire} from 'node:module'; +import {airPlaySenderRoot} from './airplay-sender-package.js'; const require = createRequire(import.meta.url); const setupAp2DigestGuard = 'this.status === SETUP_AP2_1 && response.code === 401'; @@ -59,6 +60,10 @@ const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0) self.clearTimeout() blob = Buffer.concat([blob, Buffer.from(data)]) + if (blob.length > 1024 * 1024) { + self.cleanup('invalid_response') + return + } while (blob.length > 0) { var endIndex = blob.indexOf('\\r\\n\\r\\n') @@ -70,6 +75,10 @@ const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0) var headers = blob.slice(0, endIndex).toString() var lengthMatch = /(?:^|\\r\\n)Content-Length:\\s*(\\d+)/i.exec(headers) var contentLength = lengthMatch ? parseInt(lengthMatch[1], 10) : 0 + if (!Number.isSafeInteger(contentLength) || contentLength > 1024 * 1024) { + self.cleanup('invalid_response') + return + } var messageLength = endIndex + contentLength if (blob.length < messageLength) { @@ -95,14 +104,16 @@ type ModuleLoader = { let installed = false; -export function installAirPlaySenderPatch(packageName = 'node-airtunes2'): boolean { +export function installAirPlaySenderPatch(packageName = airPlaySenderRoot): boolean { if (installed) { return true; } let rtspPath: string; + let devicePath: string; try { rtspPath = require.resolve(`${packageName}/lib/rtsp.js`); + devicePath = require.resolve(`${packageName}/lib/device_airtunes.js`); } catch { return false; } @@ -114,19 +125,20 @@ export function installAirPlaySenderPatch(packageName = 'node-airtunes2'): boole } moduleLoader._extensions['.js'] = (module, filename) => { - if (filename !== rtspPath) { + if (filename !== rtspPath && filename !== devicePath) { originalLoader(module, filename); return; } - module._compile(patchAirTunesRtspSource(readFileSync(filename, 'utf8')), filename); + const source = readFileSync(filename, 'utf8'); + module._compile(filename === rtspPath ? patchAirTunesRtspSource(source) : patchAirTunesDeviceSource(source), filename); }; installed = true; return true; } export function patchAirTunesRtspSource(source: string): string { - let patched = source; + let patched = patchQueuedControls(patchLegacyDigest(source)); if (patched.includes(socketDataHandlerMarker) && !patched.includes('Buffer.concat([blob, Buffer.from(data)])')) { patched = patched.replace(socketDataHandlerMarker, bufferedSocketDataHandler); } @@ -158,3 +170,90 @@ export function patchAirTunesRtspSource(source: string): string { return patched.replace(processDataResponseMarker, `${setupAp2DigestRetry}${processDataResponseMarker}`); } + +/** A zero starting volume is intentional, including when a session starts muted. */ +export function patchAirTunesDeviceSource(source: string): string { + return source.replaceAll('options.volume || 50', 'options.volume ?? 50').replaceAll('this.options.volume || 30', 'this.options.volume ?? 30'); +} + +function patchLegacyDigest(source: string): string { + if (source.includes('// RadioCLI legacy Digest')) return source; + return source.replace(' this.status = this.airplay2 ? PAIR_SETUP_1 : PAIR_PIN_SETUP_1\n this.sendNextRequest()', ` if (!this.airplay2 && this.mode === 0 && (this.radioCliDigest || this.status === OPTIONS)) { + if (this.radioCliDigest) { + this.radioCliDigest.password = passcode + this.radioCliDigestTried = true + this.sendNextRequest(this.radioCliDigest) + } + return + } + this.status = this.airplay2 ? PAIR_SETUP_1 : PAIR_PIN_SETUP_1 + this.sendNextRequest()`) + .replace(" var head = method + ' ' + uri + ' RTSP/1.0'", " if (!this.airplay2 && this.mode === 0) di = di || this.radioCliDigest\n var head = method + ' ' + uri + ' RTSP/1.0'") + .replace(" if (dimode == 'airplay2') {", " if (dimode == 'airplay2' || (!this.airplay2 && this.mode === 0)) {") + .replace(" diResponse = md5(ha1 + ':' + di.nonce + ':' + ha2)", " diResponse = (!this.airplay2 && this.mode === 0 ? md5norm : md5)(ha1 + ':' + di.nonce + ':' + ha2)") + .replace(processDataResponseMarker, ` // RadioCLI legacy Digest: keep password authentication separate from PIN pairing. + if (!this.airplay2 && this.mode === 0 && response.code === 401 && + (this.status === OPTIONS || /^Digest\\s/i.test(headers['WWW-Authenticate'] || headers['www-authenticate'] || ''))) { + const auth = headers['WWW-Authenticate'] || headers['www-authenticate'] + if (this.radioCliDigestTried || typeof auth !== 'string' || !/^Digest\\s/i.test(auth)) { + this.emit('pair_failed') + this.cleanup('bad_password') + return + } + const realm = parseAuthenticate(auth, 'realm') + const nonce = parseAuthenticate(auth, 'nonce') + if (!realm || !nonce || /[\\r\\n"]/.test(realm + nonce)) { + this.emit('pair_failed') + this.cleanup('bad_password') + return + } + this.radioCliDigest = {realm, nonce, username: 'iTunes', password: this.password} + if (!this.password) { + this.emit('need_password') + return + } + this.radioCliDigestTried = true + this.sendNextRequest(this.radioCliDigest) + return + } + if (response.code === 200) this.radioCliDigestTried = false +${processDataResponseMarker}`); +} + +/** The upstream sender silently drops controls while another RTSP request is in flight. */ +function patchQueuedControls(source: string): string { + if (source.includes('this.radioCliPendingVolume')) return source; + return source.replace(`Client.prototype.setVolume = function (volume, callback) { + if (this.status !== PLAYING) + return`, `Client.prototype.setVolume = function (volume, callback) { + if (this.status !== PLAYING) { + if (this.status !== CLOSED && this.status !== TEARDOWN) this.radioCliPendingVolume = [volume, callback] + return + }`) + .replace(`Client.prototype.setTrackInfo = function (name, artist, album, callback) { + if (this.status !== PLAYING) + return`, `Client.prototype.setTrackInfo = function (name, artist, album, callback) { + if (this.status !== PLAYING) { + if (this.status !== CLOSED && this.status !== TEARDOWN) this.radioCliPendingTrack = [name, artist, album, callback] + return + }`) + .replace(` this.sendNextRequest() +} + +Client.prototype.parseObject`, ` if (this.status === PLAYING && this.radioCliPendingVolume) { + const pending = this.radioCliPendingVolume + this.radioCliPendingVolume = null + this.setVolume(...pending) + return + } + if (this.status === PLAYING && this.radioCliPendingTrack) { + const pending = this.radioCliPendingTrack + this.radioCliPendingTrack = null + this.setTrackInfo(...pending) + return + } + this.sendNextRequest() +} + +Client.prototype.parseObject`); +} diff --git a/src/player/airplay-sender-source.test.ts b/src/player/airplay-sender-source.test.ts index df11308..70de063 100644 --- a/src/player/airplay-sender-source.test.ts +++ b/src/player/airplay-sender-source.test.ts @@ -1,7 +1,12 @@ import {readFileSync} from 'node:fs'; import {createRequire} from 'node:module'; -import {describe, expect, it} from 'vitest'; -import {patchAirTunesRtspSource} from './airplay-sender-patch.js'; +import {createHash} from 'node:crypto'; +import {runInNewContext} from 'node:vm'; +import type {EventEmitter} from 'node:events'; +import {describe, expect, it, vi} from 'vitest'; +import {patchAirTunesDeviceSource, patchAirTunesRtspSource} from './airplay-sender-patch.js'; +import {airPlaySenderRoot} from './airplay-sender-package.js'; +import {airPlaySenderHealth} from './airplay-sender-health.js'; const require = createRequire(import.meta.url); const rtspSourcePath = optionalAirTunesRtspPath(); @@ -14,12 +19,23 @@ const requiredMarkers = [ ' headers = response.headers', ' if (this.status != OPTIONS && this.status != OPTIONS2 && this.mode == 0) {', " this.eventPort = sa1_bplist[0]['eventPort']", - 'I assume that all responses have empty bodies.' + 'I assume that all responses have empty bodies.', + 'Client.prototype.setPasscode = async function (passcode)', + 'Client.prototype.setVolume = function (volume, callback)', + 'Client.prototype.setTrackInfo = function (name, artist, album, callback)', + 'Client.prototype.parseObject' ]; const describeIfSenderInstalled = rtspSourcePath ? describe : describe.skip; describeIfSenderInstalled('AirPlay sender source compatibility', () => { + it('retains the verified upstream protocol and native source unchanged', () => { + const provenance = JSON.parse(readFileSync(`${airPlaySenderRoot}/UPSTREAM.json`, 'utf8')) as {unmodifiedFiles: Record}; + for (const [file, digest] of Object.entries(provenance.unmodifiedFiles)) { + expect(createHash('sha256').update(readFileSync(`${airPlaySenderRoot}/${file}`)).digest('hex'), file).toBe(digest); + } + }); + it('still contains every marker the runtime patch depends on', () => { const source = readAirTunesRtspSource(); for (const marker of requiredMarkers) { @@ -35,12 +51,125 @@ describeIfSenderInstalled('AirPlay sender source compatibility', () => { expect(patched).toContain('Buffer.concat([blob, Buffer.from(data)])'); expect(patched).toContain("this.eventPort = sa1_bplist[0]['eventPort'] || this.eventPort || this.socket.remotePort || 7000"); expect(patched).not.toContain('I assume that all responses have empty bodies.'); + expect(patched).toContain('// RadioCLI legacy Digest'); + expect(patched).toContain('this.radioCliPendingVolume'); + expect(patched).toContain('this.radioCliPendingTrack'); + expect(patched).toContain('contentLength > 1024 * 1024'); + expect(patchAirTunesRtspSource(patched)).toBe(patched); + }); + + it('preserves a zero initial volume in both device construction paths', () => { + const source = readFileSync(require.resolve(`${airPlaySenderRoot}/lib/device_airtunes.js`), 'utf8'); + expect(source).toContain('options.volume || 50'); + expect(source).toContain('this.options.volume || 30'); + const patched = patchAirTunesDeviceSource(source); + expect(patched).toContain('options.volume ?? 50'); + expect(patched).toContain('this.options.volume ?? 30'); + expect(patchAirTunesDeviceSource(patched)).toBe(patched); + }); + + it('prompts for a legacy receiver password and authenticates each RTSP method', async () => { + const client = patchedClient(); + const prompt = vi.fn(); client.on('need_password', prompt); + const send = vi.fn(); client.sendNextRequest = send; + client.status = 0; // OPTIONS in the pinned sender. + respond(client, 401, 'WWW-Authenticate: Digest realm="receiver", nonce="challenge"\r\n'); + expect(prompt).toHaveBeenCalledOnce(); + expect(send).not.toHaveBeenCalled(); + await client.setPasscode('test-secret'); + expect(client.status).toBe(0); + expect(send).toHaveBeenCalledOnce(); + respond(client, 200); + expect(client.status).toBe(1); // ANNOUNCE, not PIN pairing. + const digest = (text: string) => createHash('md5').update(text).digest('hex'); + for (const [method, uri] of [['OPTIONS', '*'], ['ANNOUNCE', 'rtsp://127.0.0.1/1']]) { + const expected = digest(`${digest('iTunes:receiver:test-secret')}:challenge:${digest(`${method}:${uri}`)}`); + const head = client.makeHead(method!, uri!); + expect(head).toContain(`response="${expected}"`); + expect(head).not.toContain('test-secret'); + } + }); + + it('fails a rejected legacy password without retrying forever', async () => { + const client = patchedClient(); + client.status = 0; + client.sendNextRequest = vi.fn(); + const failure = vi.fn(); client.on('pair_failed', failure); + const cleanup = vi.fn(); client.cleanup = cleanup; + respond(client, 401, 'WWW-Authenticate: Digest realm="receiver", nonce="challenge"\r\n'); + await client.setPasscode('wrong-secret'); + respond(client, 401, 'WWW-Authenticate: Digest realm="receiver", nonce="challenge"\r\n'); + expect(failure).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledWith('bad_password'); + expect(client.sendNextRequest).toHaveBeenCalledOnce(); + }); + + it('keeps AirPlay 2 code entry on its original pairing path', async () => { + const client = patchedClient(true); + client.sendNextRequest = vi.fn(); + await client.setPasscode('1234'); + expect(client.status).toBe(18); // PAIR_SETUP_1. + expect(client.sendNextRequest).toHaveBeenCalledOnce(); + }); + + it('preserves legacy Apple TV PIN pairing when no Digest challenge is pending', async () => { + const client = patchedClient(); + client.status = 15; // PAIR_PIN_SETUP_1, distinct from a RAOP password prompt. + client.sendNextRequest = vi.fn(); + await client.setPasscode('1234'); + expect(client.status).toBe(15); + expect(client.sendNextRequest).toHaveBeenCalledOnce(); + }); + + it('delivers the latest volume and station metadata after an in-flight request', () => { + const client = patchedClient(); + client.sendNextRequest = vi.fn(); + client.status = 4; // SETVOLUME is awaiting a response. + client.setVolume(0); + client.setVolume(25); + client.setTrackInfo('New station', 'RadioCLI', 'Live radio'); + respond(client, 200); // Initial progress setup. + respond(client, 200); // PLAYING drains the queued volume. + expect(client.volume).toBe(25); + expect(client.status).toBe(4); + respond(client, 200); // Then drains station metadata. + expect(client.trackInfo).toEqual({name: 'New station', artist: 'RadioCLI', album: 'Live radio'}); + expect(client.status).toBe(8); // SETDAAP. + respond(client, 200); + expect(client.status).toBe(5); // PLAYING. }); }); +type RtspClient = EventEmitter & { + status: number; + volume: number; + trackInfo: unknown; + processData: (headers: string, raw: Buffer) => void; + sendNextRequest: (digest?: unknown) => void; + cleanup: (reason: string) => void; + setPasscode: (code: string) => Promise; + setVolume: (value: number) => void; + setTrackInfo: (name: string, artist: string, album: string) => void; + makeHead: (method: string, uri: string) => string; +}; + +function patchedClient(airplay2 = false): RtspClient { + const exports: {Client?: new (...args: unknown[]) => RtspClient} = {}; + runInNewContext(patchAirTunesRtspSource(readAirTunesRtspSource()), { + exports, require: createRequire(rtspSourcePath!), Buffer, + console: {log: () => undefined}, setTimeout, clearTimeout, setInterval, clearInterval + }, {filename: rtspSourcePath!, timeout: 2000}); + return new exports.Client!(70, null, {lastSeq: 1}, {mode: airplay2 ? 2 : 0, airplay2, debug: false}); +} + +function respond(client: RtspClient, code: number, headers = ''): void { + const text = `RTSP/1.0 ${code} ${code === 200 ? 'OK' : 'Unauthorized'}\r\nCSeq: 1\r\n${headers}\r\n`; + client.processData(text, Buffer.from(text)); +} + function optionalAirTunesRtspPath(): string | null { try { - return require.resolve('node-airtunes2/lib/rtsp.js'); + return airPlaySenderHealth().available ? require.resolve(`${airPlaySenderRoot}/lib/rtsp.js`) : null; } catch { return null; } diff --git a/src/player/airplay-setup.ts b/src/player/airplay-setup.ts new file mode 100644 index 0000000..bd94b5b --- /dev/null +++ b/src/player/airplay-setup.ts @@ -0,0 +1,53 @@ +import {statSync} from 'node:fs'; +import {packageInstallCommand, type SetupCommand, type SetupPackageManager} from '../platform/packages.js'; +import type {AirPlayReadiness} from './airplay-readiness.js'; + +const airPlaySenderInstallGuidance = 'Reinstall RadioCLI with optional dependencies enabled (npm install -g @ciphore/radiocli --include=optional), then rerun radiocli doctor. Use the same installation method and prefix as the current install.'; + +export function hasSystemdSystemManager(): boolean { + try { return process.platform === 'linux' && statSync('/run/systemd/system').isDirectory(); } catch { return false; } +} + +/** Only apt and Arch have verified Avahi package recipes here. */ +export function linuxAirPlaySetupPlan({manager, elevation, hasCommand, systemd, serviceReady, existingCommands}: { + manager: SetupPackageManager | null; + elevation: 'sudo' | 'doas' | null; + hasCommand(command: string): boolean; + systemd: boolean; + serviceReady: boolean; + existingCommands: readonly SetupCommand[]; +}): {commands: SetupCommand[]; notes: string[]} { + const commands: SetupCommand[] = []; + const notes = ['Experimental Linux AirPlay is optional. Avahi supplies shared local-network discovery; enabling its system service affects the whole machine.']; + if (!hasCommand('ffmpeg') && !existingCommands.some(command => command.component === 'ffmpeg')) { + const command = manager ? packageInstallCommand(manager, 'ffmpeg', {elevation}) : null; + if (command) commands.push({...command, component: null, label: 'AirPlay FFmpeg'}); + else notes.push('Install FFmpeg manually for AirPlay, then rerun radiocli setup --airplay.'); + } + if (!hasCommand('avahi-browse') || !serviceReady && !hasCommand('avahi-daemon')) { + const recipe = manager === 'apt' ? ['apt-get', 'install', '-y', 'avahi-daemon', 'avahi-utils'] + : manager === 'pacman' ? ['pacman', '-S', '--needed', '--noconfirm', 'avahi'] : null; + if (recipe) commands.push(setupAction('Avahi discovery', recipe, elevation)); + else notes.push('Install Avahi daemon and avahi-browse with your distribution package manager; no verified automatic Avahi recipe is available for this manager.'); + } + if (!serviceReady) { + if (systemd && hasCommand('systemctl') && (hasCommand('avahi-daemon') || manager === 'apt' || manager === 'pacman')) { + commands.push(setupAction('Enable and start shared Avahi service', ['systemctl', 'enable', '--now', 'avahi-daemon.service'], elevation)); + } else notes.push('Start the Avahi daemon with your system service manager, then rerun radiocli doctor. RadioCLI will not guess or change this service configuration.'); + } + return {commands, notes}; +} + +export function airPlaySetupVerification(readiness: AirPlayReadiness): string[] { + const ready = readiness.ready && readiness.discoveryService === 'ready'; + return [ + ready ? 'AirPlay: experimental prerequisites ready. Select a receiver to verify playback.' : `AirPlay: Setup required. ${readiness.message}`, + ...(!readiness.senderLoaded ? [airPlaySenderInstallGuidance] : []), + ...(readiness.discoveryService === 'unverified' ? ['Avahi service readiness is unverified. Install busctl or verify the running daemon with your system administrator; receiver discovery remains available explicitly.'] : []) + ]; +} + +function setupAction(label: string, invocation: string[], elevation: 'sudo' | 'doas' | null): SetupCommand { + const [program, ...args] = elevation ? [elevation, ...invocation] : invocation; + return {component: null, label, program: program!, args, display: [program, ...args].join(' ')}; +} diff --git a/src/player/airplay-worker.ts b/src/player/airplay-worker.ts index f6a2480..e6eb156 100644 --- a/src/player/airplay-worker.ts +++ b/src/player/airplay-worker.ts @@ -3,22 +3,24 @@ import {createRequire} from 'node:module'; import type {Readable} from 'node:stream'; import {airPlaySenderHealth} from './airplay-sender-health.js'; import {installAirPlaySenderPatch} from './airplay-sender-patch.js'; +import {airPlaySenderRoot} from './airplay-sender-package.js'; +import {canRetryPlainHttp, ffmpegCertificateFailure, ffmpegInputArguments, ffmpegInputFailure} from './ffmpeg-input.js'; import {parseWorkerMessage, decodeWorkerStart, maxWorkerMessageBytes, serializeWorkerMessage, type AirPlayWorkerCommand, type AirPlayWorkerEvent} from './airplay-worker-protocol.js'; const require = createRequire(import.meta.url); -// node-airtunes2 writes diagnostics with console.log; keep stdout reserved for JSON events. -console.log = (...args: unknown[]) => { - process.stderr.write(`${args.map(String).join(' ')}\n`); -}; +// The optional sender logs raw authentication exchanges, including receiver codes. +// Only our bounded protocol events may leave this isolated process. +console.log = console.info = console.debug = console.warn = console.error = () => undefined; type AirTunesClient = { add: (host: string, options: Record) => {key: string; setPasscode?: (code: string) => void}; - write: (chunk: Buffer) => void; + write: (chunk: Buffer) => boolean; reset: () => void; stopAll: (callback?: () => void) => void; setVolume: (key: string, volume: number, callback?: () => void) => void; - on: (event: 'device' | 'buffer' | 'error', listener: (...args: unknown[]) => void) => void; + setTrackInfo: (key: string, name: string, artist: string, album: string) => void; + on: (event: 'device' | 'buffer' | 'error' | 'drain', listener: (...args: unknown[]) => void) => void; }; const encodedStart = process.argv[2]; @@ -33,30 +35,38 @@ const start = decodeStartPayload(encodedStart); const airtunes = new AirTunes(); let deviceKey = ''; let ffmpeg: ChildProcessByStdio | null = null; +const decoders = new Set>(); let muted = start.muted; let volume = start.volume; +let stationName = start.stationName; let retuning = false; let stopping = false; +let finishStop: (() => void) | undefined; airtunes.on('device', (key, status) => { + if (stopping) return; if (typeof key === 'string') { deviceKey = key; } if (status === 'ready') { + publishStation(); emit({type: 'ready'}); } else if (status === 'playing' || status === 'pair_success') { emit({type: 'playing'}); } else if (status === 'need_password') { emit({type: 'password-required'}); } else if (status === 'stopped') { - emit({type: 'stopped'}); + fail('AirPlay receiver disconnected. Select the receiver again to reconnect.'); + } else if (status === 'pair_failed') { + fail('AirPlay receiver rejected the code or pairing request.'); } else if (status === 'error') { - emit({type: 'error', message: 'AirPlay receiver reported an error.'}); + fail('AirPlay receiver reported an error.'); } }); airtunes.on('buffer', status => { + if (stopping) return; const statusText = String(status); emit({type: 'buffer', status: statusText}); if (retuning && statusText === 'playing') { @@ -65,9 +75,8 @@ airtunes.on('buffer', status => { } }); -airtunes.on('error', error => { - emit({type: 'error', message: error instanceof Error ? error.message : String(error)}); -}); +airtunes.on('error', () => fail('AirPlay sender failed. Select the receiver again to reconnect.')); +airtunes.on('drain', () => { if (!stopping) ffmpeg?.stdout.resume(); }); const device = airtunes.add(start.device.host, { port: start.device.port, @@ -105,8 +114,13 @@ process.stdin.on('data', chunk => { process.once('SIGTERM', () => stop(0)); process.once('SIGINT', () => stop(0)); +process.once('SIGHUP', () => stop(0)); +process.stdin.once('end', () => stop(0)); +process.once('uncaughtException', () => fail('AirPlay sender failed.')); +process.once('unhandledRejection', () => fail('AirPlay sender failed.')); function handleCommand(command: AirPlayWorkerCommand): void { + if (stopping) return; if (command.type === 'stop') { stop(0); } else if (command.type === 'setVolume') { @@ -117,30 +131,25 @@ function handleCommand(command: AirPlayWorkerCommand): void { setAirPlayVolume(muted ? 0 : volume); } else if (command.type === 'retune') { retuning = true; + stationName = command.stationName; airtunes.reset(); startFfmpeg(command.streamUrl); + publishStation(); } else if (command.type === 'passcode') { device.setPasscode?.(command.code); } } -function startFfmpeg(streamUrl: string): void { +function startFfmpeg(streamUrl: string, plainHttp = false): void { + if (stopping) return; const previous = ffmpeg; const child = spawn(start.ffmpegPath, [ '-hide_banner', '-loglevel', 'error', - // Recover from transient source drops without tearing down the AirPlay session. - // These are input options and must precede -i. -reconnect_streamed is essential for - // non-seekable radio; with no retry cap ffmpeg keeps trying until the station returns. - '-reconnect', - '1', - '-reconnect_on_network_error', - '1', - '-reconnect_streamed', - '1', - '-reconnect_delay_max', - '2', + // Reconnect only network inputs, retaining the receiver session across + // transient source drops without breaking local file playback. + ...ffmpegInputArguments(streamUrl, {plainHttp, reconnect: true}), '-i', streamUrl, '-f', @@ -154,37 +163,45 @@ function startFfmpeg(streamUrl: string): void { stdio: ['ignore', 'pipe', 'pipe'] }); ffmpeg = child; + decoders.add(child); + child.once('close', () => { decoders.delete(child); finishStop?.(); }); - if (previous && !previous.killed) { - previous.kill('SIGTERM'); - } + if (previous) stopDecoder(previous); + let pcmStarted = false; + let diagnostic = ''; child.stdout.on('data', chunk => { if (ffmpeg === child && !stopping) { - airtunes.write(chunk as Buffer); + pcmStarted ||= (chunk as Buffer).length > 0; + if (!airtunes.write(chunk as Buffer)) child.stdout.pause(); } }); + // Inspect only a bounded tail; never forward stream URLs or credentials. child.stderr.on('data', chunk => { - if (ffmpeg === child) { - process.stderr.write(chunk); - } + diagnostic = (diagnostic + String(chunk)).slice(-8192); + const failure = ffmpegInputFailure(diagnostic) ?? ffmpegCertificateFailure(diagnostic); + if (failure && ffmpeg === child && !stopping) fail(failure); }); - child.once('error', error => { + child.stderr.resume(); + child.once('error', () => { if (ffmpeg !== child || stopping) { return; } - emit({type: 'error', message: error.message}); - stop(1); + fail('FFmpeg could not start for AirPlay playback. Run radiocli doctor.'); }); - child.once('exit', code => { + child.once('close', (code, signal) => { if (ffmpeg !== child || stopping) { return; } + if (!plainHttp && !signal && !pcmStarted && canRetryPlainHttp(streamUrl, diagnostic)) { + startFfmpeg(streamUrl, true); + return; + } ffmpeg = null; if (code !== 0) { - emit({type: 'error', message: `ffmpeg exited with code ${code}`}); + emit({type: 'error', message: ffmpegInputFailure(diagnostic) ?? `ffmpeg exited with code ${code}`}); } stop(code ?? 0); @@ -197,6 +214,24 @@ function setAirPlayVolume(nextVolume: number): void { } } +function publishStation(): void { + if (deviceKey) airtunes.setTrackInfo(deviceKey, stationName, 'RadioCLI', 'Live radio'); +} + +function fail(message: string): void { + if (stopping) return; + emit({type: 'error', message}); + stop(1); +} + +function stopDecoder(child: ChildProcessByStdio): void { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill('SIGTERM'); + const force = setTimeout(() => child.kill('SIGKILL'), 500); + force.unref(); + child.once('close', () => clearTimeout(force)); +} + function emit(event: AirPlayWorkerEvent): void { process.stdout.write(serializeWorkerMessage(event)); } @@ -210,7 +245,7 @@ function loadAirTunesSender(): new () => AirTunesClient { installAirPlaySenderPatch(); try { - return require('node-airtunes2') as new () => AirTunesClient; + return require(airPlaySenderRoot) as new () => AirTunesClient; } catch { emit({type: 'error', message: 'AirPlay sender bridge could not be loaded.'}); process.exit(1); @@ -232,18 +267,23 @@ function stop(code: number): void { } stopping = true; - if (ffmpeg && !ffmpeg.killed) { - ffmpeg.kill('SIGTERM'); - } + process.exitCode = code; + for (const child of decoders) stopDecoder(child); ffmpeg = null; - const watchdog = setTimeout(() => process.exit(code), 1500); - watchdog.unref(); - airtunes.stopAll(() => { + let senderStopped = false; + const watchdog = setTimeout(() => { + for (const child of decoders) child.kill('SIGKILL'); + process.exit(code); + }, 1500); + finishStop = () => { + if (!senderStopped || decoders.size) return; clearTimeout(watchdog); emit({type: 'stopped'}); process.exit(code); - }); + }; + try { airtunes.stopAll(() => { senderStopped = true; finishStop?.(); }); } + catch { senderStopped = true; finishStop(); } } function clampVolume(nextVolume: number): number { diff --git a/src/player/airplay.native.test.ts b/src/player/airplay.native.test.ts new file mode 100644 index 0000000..dd08fa5 --- /dev/null +++ b/src/player/airplay.native.test.ts @@ -0,0 +1,229 @@ +import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; +import {createSocket} from 'node:dgram'; +import {once} from 'node:events'; +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {createServer} from 'node:http'; +import {createServer as createTcpServer, type AddressInfo} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; +import type {AirPlayDevice} from '../types.js'; +import {discoverAirPlayDevices} from './airplay-discovery.js'; +import {encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage, type AirPlayWorkerCommand, type AirPlayWorkerEvent} from './airplay-worker-protocol.js'; + +// Requires FFmpeg, Shairport Sync with stdout/Avahi support, and a running Avahi +// daemon. Only software PCM is measured; this does not establish physical output. +const native = process.platform === 'linux' && process.env.RADIOCLI_NATIVE_AIRPLAY_TEST === '1'; + +describe.skipIf(!native)('native AirPlay sender', () => { + it.each([false, true])('streams, retunes, publishes metadata and stops (password: %s)', async password => { + await withReceiver(password, async fixture => { + await fixture.verifyDiscovery(); + const session = fixture.startWorker(true); + if (password) { + await until(() => session.events.some(event => event.type === 'password-required'), 'password prompt'); + session.send({type: 'passcode', code: 'receiver-secret'}); + } + await until(() => session.events.some(event => event.type === 'ready'), 'sender ready'); + await until(() => fixture.bytes() > 88_200, 'muted PCM'); + // Shairport applies one-bit dither even to muted PCM. + expect(fixture.peak()).toBeLessThanOrEqual(1); + await until(() => fixture.metadata().includes('Native station'), 'station metadata'); + session.send({type: 'setMuted', muted: false}); + await until(() => fixture.energy() > 30 && fixture.frequency() > 400, 'decoded 440 Hz audio'); + const initialEnergy = fixture.energy(); + session.send({type: 'setVolume', volume: 25}); + await until(() => fixture.energy() > 5 && fixture.energy() < initialEnergy * 0.8, 'receiver volume'); + session.send({type: 'setMuted', muted: true}); + await until(() => fixture.energy() < 1, 'receiver mute'); + session.send({type: 'setMuted', muted: false}); + session.send({type: 'retune', streamUrl: fixture.url(880), stationName: 'Retuned station'}); + await until(() => session.events.some(event => event.type === 'retuned'), 'retune acknowledgement'); + await until(() => fixture.frequency() > 750, 'decoded 880 Hz audio'); + await until(() => fixture.metadata().includes('Retuned station'), 'retuned metadata'); + const pids = session.decoderPids(); + expect(pids.length).toBeGreaterThan(0); + if (password) session.child.kill('SIGHUP'); + else session.send({type: 'stop'}); + expect((await waitClosed(session))[0]).toBe(0); + expect(session.events.some(event => event.type === 'error')).toBe(false); + expect(session.stderr()).not.toContain('receiver-secret'); + expect(session.stderr()).not.toContain('stream-secret'); + await until(() => pids.every(pid => !alive(pid)), 'decoder cleanup'); + }); + }, 40_000); + + it('rejects a wrong password and releases its decoder without leaking the code', async () => { + await withReceiver(true, async fixture => { + const session = fixture.startWorker(false); + await until(() => session.events.some(event => event.type === 'password-required'), 'password prompt'); + const pids = session.decoderPids(); + session.send({type: 'passcode', code: 'wrong-secret'}); + expect((await waitClosed(session))[0]).toBe(1); + expect(session.events).toContainEqual({type: 'error', message: 'AirPlay receiver rejected the code or pairing request.'}); + expect(session.stderr()).not.toContain('wrong-secret'); + expect(session.stderr()).not.toContain('stream-secret'); + await until(() => pids.every(pid => !alive(pid)), 'decoder cleanup'); + }); + }, 20_000); + + it('stops after receiver loss and can connect in a new session', async () => { + await withReceiver(false, async fixture => { + const session = fixture.startWorker(false); + await until(() => fixture.energy() > 30, 'decoded audio'); + const pids = session.decoderPids(); + await fixture.restartReceiver(); + expect((await waitClosed(session))[0]).toBe(1); + expect(session.events.some(event => event.type === 'error')).toBe(true); + await until(() => pids.every(pid => !alive(pid)), 'lost receiver decoder cleanup'); + const next = fixture.startWorker(false); + await until(() => next.events.some(event => event.type === 'ready'), 'reconnected sender'); + next.send({type: 'stop'}); + expect((await waitClosed(next))[0]).toBe(0); + }); + }, 25_000); +}); + +type Session = { + child: ChildProcessWithoutNullStreams; + closed: Promise<[number | null, NodeJS.Signals | null]>; + events: AirPlayWorkerEvent[]; + send: (command: AirPlayWorkerCommand) => void; + stderr: () => string; + decoderPids: () => number[]; +}; +type Fixture = { + startWorker: (muted: boolean) => Session; + url: (frequency: number) => string; + bytes: () => number; + energy: () => number; + frequency: () => number; + peak: () => number; + metadata: () => string; + restartReceiver: () => Promise; + verifyDiscovery: () => Promise; +}; + +async function withReceiver(password: boolean, check: (fixture: Fixture) => Promise): Promise { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-native-raop-')); + const children: ChildProcessWithoutNullStreams[] = []; + const timers = new Set(); + const metadataSocket = createSocket('udp4'); + let metadata = ''; let pcm = Buffer.alloc(0); let bytes = 0; let peak = 0; + metadataSocket.on('message', chunk => { metadata = (metadata + chunk.toString()).slice(-64 * 1024); }); + const server = createServer((request, response) => { + const tone = wave(Number(new URL(request.url!, 'http://localhost').pathname.slice(1)) || 440); + response.writeHead(200, {'content-type': 'audio/wav'}); response.write(tone.header); + const timer = setInterval(() => response.write(tone.samples), 100); + timers.add(timer); + response.on('close', () => { clearInterval(timer); timers.delete(timer); }); + }); + try { + metadataSocket.bind(0, '127.0.0.1'); await once(metadataSocket, 'listening'); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + const reserve = createTcpServer(); reserve.listen(0, '127.0.0.1'); await once(reserve, 'listening'); + const port = (reserve.address() as AddressInfo).port; + await new Promise(resolve => reserve.close(() => resolve())); + const config = join(directory, 'receiver.conf'); + writeFileSync(config, `general = { name = "RadioCLI native ${process.pid} ${port}"; port = ${port}; ${password ? 'password = "receiver-secret";' : ''} };\nmetadata = { enabled = "yes"; pipe_name = "${directory}/metadata"; socket_address = "127.0.0.1"; socket_port = ${metadataSocket.address().port}; };\n`); + const startReceiver = async () => { + const child = spawn('shairport-sync', ['-c', config, '-o', 'stdout', '-u'], {stdio: 'pipe'}); + children.push(child); + let spawnError: Error | undefined; + child.once('error', error => { spawnError = error; }); + child.stdout.on('data', (chunk: Buffer) => { + bytes += chunk.length; + for (let index = 0; index + 1 < chunk.length; index += 2) peak = Math.max(peak, Math.abs(chunk.readInt16LE(index))); + pcm = Buffer.concat([pcm, chunk]).subarray(-88_200); + }); + child.stderr.resume(); + await until(() => { + if (spawnError) throw spawnError; + if (child.exitCode !== null) throw new Error('The Shairport Sync fixture exited before listening.'); + return readFileSync('/proc/net/tcp', 'utf8').split('\n').some(line => { + const fields = line.trim().split(/\s+/); + return fields[3] === '0A' && fields[1]?.endsWith(`:${port.toString(16).toUpperCase().padStart(4, '0')}`); + }); + }, 'software receiver listener'); + return child; + }; + let receiver = await startReceiver(); + let discovered: AirPlayDevice | undefined; + const url = (frequency: number) => `http://fixture:stream-secret@127.0.0.1:${(server.address() as AddressInfo).port}/${frequency}`; + await check({ + url, bytes: () => bytes, peak: () => peak, metadata: () => metadata, + verifyDiscovery: async () => { + // A listening RTSP socket precedes Avahi's initial name-probing cycle. + const deadline = Date.now() + 6000; + do { + const devices = await discoverAirPlayDevices({platform: 'linux'}); + const receiver = devices.find(device => device.name === `RadioCLI native ${process.pid} ${port}`); + if (receiver) { expect(receiver).toMatchObject({port, local: true, requiresPassword: password, airplay2: false}); discovered = receiver; return; } + await new Promise(resolve => setTimeout(resolve, 50)); + } while (Date.now() < deadline); + throw new Error('The software receiver did not appear in Avahi discovery.'); + }, + energy: () => pcm.length ? Math.sqrt(samples(pcm).reduce((sum, value) => sum + value * value, 0) / (pcm.length / 4)) : 0, + frequency: () => { + const values = samples(pcm); let crossings = 0; + for (let index = 1; index < values.length; index += 1) if (values[index - 1]! <= 0 && values[index]! > 0) crossings += 1; + return crossings * 44_100 / Math.max(1, values.length); + }, + restartReceiver: async () => { await terminate(receiver); receiver = await startReceiver(); }, + startWorker: muted => { + const payload = encodeWorkerStart({streamUrl: url(440), stationName: 'Native station', ffmpegPath: '/usr/bin/ffmpeg', muted, volume: 40, + device: discovered ?? {id: 'software', name: 'Software receiver', host: '127.0.0.1', port, txt: ['cn=0,1', 'et=0,1', 'md=0,1,2', 'am=Shairport', `pw=${password}`], requiresPassword: password, airplay2: false}}); + const child = spawn(process.execPath, ['--import', 'tsx', fileURLToPath(new URL('./airplay-worker.ts', import.meta.url)), payload], {stdio: 'pipe'}); + children.push(child); + const closed = once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>; + void closed.catch(() => undefined); + const events: AirPlayWorkerEvent[] = []; let pending = ''; let stderr = ''; + child.stdout.on('data', chunk => { + pending += String(chunk); + let newline: number; + while ((newline = pending.indexOf('\n')) >= 0) { + const event = parseWorkerMessage(pending.slice(0, newline)); pending = pending.slice(newline + 1); + if (event) events.push(event); + } + }); + child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-16 * 1024); }); + return {child, closed, events, send: command => { child.stdin.write(serializeWorkerMessage(command)); }, stderr: () => stderr, + decoderPids: () => readFileSync(`/proc/${child.pid}/task/${child.pid}/children`, 'utf8').trim().split(/\s+/).filter(Boolean).map(Number)}; + } + }); + } finally { + for (const child of children.reverse()) await terminate(child); + for (const timer of timers) clearInterval(timer); + server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); + metadataSocket.close(); + rmSync(directory, {recursive: true, force: true}); + } +} + +async function terminate(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + const closed = once(child, 'close'); child.kill('SIGTERM'); + const force = setTimeout(() => child.kill('SIGKILL'), 2000); + try { await closed; } finally { clearTimeout(force); } +} +function alive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } +async function waitClosed(session: Session): Promise<[number | null, NodeJS.Signals | null]> { + let timer: NodeJS.Timeout | undefined; + try { return await Promise.race([session.closed, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('AirPlay worker did not exit.')), 5000); })]); } + finally { clearTimeout(timer); } +} +async function until(check: () => boolean, label: string): Promise { + const deadline = Date.now() + 12_000; + while (Date.now() < deadline) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 30)); } + throw new Error(`Timed out waiting for ${label}.`); +} +function samples(pcm: Buffer): number[] { const result: number[] = []; for (let index = 0; index + 3 < pcm.length; index += 4) result.push(pcm.readInt16LE(index)); return result; } +function wave(frequency: number): {header: Buffer; samples: Buffer} { + const data = Buffer.alloc(4410 * 4); + for (let index = 0; index < 4410; index += 1) { const value = Math.round(Math.sin(index * 2 * Math.PI * frequency / 44_100) * 5000); data.writeInt16LE(value, index * 4); data.writeInt16LE(value, index * 4 + 2); } + const header = Buffer.alloc(44); header.write('RIFF'); header.writeUInt32LE(0xfffffff7, 4); header.write('WAVEfmt ', 8); + header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(2, 22); header.writeUInt32LE(44_100, 24); header.writeUInt32LE(176_400, 28); + header.writeUInt16LE(4, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(0xffffffd3, 40); + return {header, samples: data}; +} diff --git a/src/player/backend-install.test.ts b/src/player/backend-install.test.ts index 3873ba4..a9b59a2 100644 --- a/src/player/backend-install.test.ts +++ b/src/player/backend-install.test.ts @@ -5,9 +5,12 @@ 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', () => { + it('limits experimental AirPlay to macOS and native Linux', () => { expect(isAirPlayPlatformSupported('darwin')).toBe(true); - for (const platform of ['win32', 'linux', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const) { + expect(isAirPlayPlatformSupported('linux', {}, '6.6')).toBe(true); + expect(isAirPlayPlatformSupported('linux', {WSL_INTEROP: '1'}, '6.6')).toBe(false); + expect(isAirPlayPlatformSupported('linux', {TERMUX_VERSION: '1'}, '6.6')).toBe(false); + for (const platform of ['win32', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const) { expect(isAirPlayPlatformSupported(platform)).toBe(false); } }); @@ -88,6 +91,13 @@ describe('playback backend install guidance', () => { expect(detectPlaybackBackends({platform: 'linux', hasCommand, hasAirPlaySender: () => true})).toEqual([]); }); + it('uses Avahi on Linux without loading native code or probing a service at startup', () => { + const hasCommand = (command: string) => ['mpv', 'ffmpeg', 'avahi-browse'].includes(command); + expect(detectPlaybackBackends({platform: 'linux', settings: {linuxAirPlayEnabled: true}, env: {}, release: '6.6', hasCommand, hasAirPlaySender: () => true})).toEqual(['mpv', 'airplay']); + expect(detectPlaybackBackends({platform: 'linux', settings: {linuxAirPlayEnabled: true}, env: {}, release: '6.6', hasCommand, hasAirPlaySender: () => false})).toEqual(['mpv']); + expect(detectPlaybackBackends({platform: 'linux', env: {WSL_INTEROP: '1'}, release: '6.6', hasCommand, hasAirPlaySender: () => true})).toEqual(['mpv']); + }); + it('reports AirPlay as limited output-only playback when it is the only backend', () => { expect(playbackBackendStatusLines(['airplay'], 'darwin', '', hintOptions)).toEqual([ 'playback=airplay-only', diff --git a/src/player/backend-install.ts b/src/player/backend-install.ts index 7a4e34b..37d4691 100644 --- a/src/player/backend-install.ts +++ b/src/player/backend-install.ts @@ -1,20 +1,24 @@ import {ffplayInstallCommand, mpvInstallCommand, type PackageHintOptions} from '../platform/packages.js'; -import {identifyPlatform, nativeAdapters, readLinuxOsRelease} from '../platform/runtime.js'; +import {readLinuxOsRelease} from '../platform/runtime.js'; import {commandExists} from '../platform/executables.js'; +import type {AppSettings} from '../types.js'; +import {airPlayPolicy} from './airplay-policy.js'; +export {isAirPlayPlatformSupported} from './airplay-policy.js'; import {airPlaySenderHealth} from './airplay-sender-health.js'; type PlaybackBackendDetectionOptions = { + settings?: Pick; platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + release?: string; hasCommand?: (command: string) => boolean; hasAirPlaySender?: () => boolean; }; 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 function airPlayLocalDeviceLabel(platform: NodeJS.Platform = process.platform): string { + return platform === 'darwin' ? 'this Mac' : 'this device'; } export type PlaybackBackendCapabilities = { @@ -28,6 +32,9 @@ export type PlaybackBackendCapabilities = { export function detectPlaybackBackends({ platform = process.platform, + settings, + env, + release, hasCommand = commandExists, hasAirPlaySender = hasAirPlaySenderPackage }: PlaybackBackendDetectionOptions = {}): string[] { @@ -36,7 +43,9 @@ export function detectPlaybackBackends({ backends.push('vlc'); } - if (isAirPlayPlatformSupported(platform) && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) { + // This is only a cheap candidate check. Discovery and explicit diagnostics + // separately verify the daemon and isolated native sender loadability. + if (airPlayPolicy(settings, {platform, env, release}).enabled && hasCommand('ffmpeg') && hasCommand(platform === 'linux' ? 'avahi-browse' : 'dns-sd') && hasAirPlaySender()) { backends.push('airplay'); } diff --git a/src/player/ffmpeg-input.test.ts b/src/player/ffmpeg-input.test.ts new file mode 100644 index 0000000..13c1c7b --- /dev/null +++ b/src/player/ffmpeg-input.test.ts @@ -0,0 +1,37 @@ +import {expect, it} from 'vitest'; +import {canRetryPlainHttp, ffmpegCertificateFailure, ffmpegInputArguments, ffmpegInputFailure} from './ffmpeg-input.js'; + +it.each(['https://radio.example/live', 'http://radio.example/redirect'])('verifies TLS and excludes network playlist demuxers for %s', target => { + const args = ffmpegInputArguments(target); + expect(args.slice(-2)).toEqual(['-tls_verify', '1']); + const formats = args[args.indexOf('-format_whitelist') + 1]!.split(','); + expect(formats).toEqual(expect.arrayContaining(['mp3', 'aac', 'ogg', 'flac', 'wav'])); + for (const format of ['hls', 'dash', 'imf', 'concat', 'image2']) expect(formats).not.toContain(format); +}); +it.each(['/radio/local.wav', 'file:///radio/local.wav', 'C:\\Music\\local.wav'])('allows local media formats without network access or inapplicable input options (%s)', target => { + expect(ffmpegInputArguments(target, {reconnect: true})).toEqual(['-protocol_whitelist', 'file,crypto,data']); +}); +it('blocks both TLS protocols on a plain HTTP compatibility retry', () => { + expect(ffmpegInputArguments('http://radio.example/live', {plainHttp: true})).toEqual(expect.arrayContaining(['-protocol_blacklist', 'https,tls'])); + expect(ffmpegInputArguments('https://radio.example/live', {plainHttp: true})).toEqual(expect.arrayContaining(['-tls_verify', '1'])); +}); +it('retries only an exact unused TLS-option diagnostic on original HTTP inputs', () => { + const message = 'Option tls_verify not found.\n'; + expect(canRetryPlainHttp('http://radio.example/live', message)).toBe(true); + expect(canRetryPlainHttp('http://radio.example/live', `nan M-A: 0.000 aq=0KB\r${message}`)).toBe(true); + for (const url of ['https://radio.example/live', '/radio/local.wav']) expect(canRetryPlainHttp(url, message)).toBe(false); + for (const diagnostic of ['Certificate verification failed', 'Connection reset', 'some Option tls_verify not found. suffix']) expect(canRetryPlainHttp('http://radio.example/live', diagnostic)).toBe(false); +}); +it('reports restricted formats without exposing backend diagnostic contents', () => { + expect(ffmpegInputFailure('Format not on whitelist; https://secret:password@radio.example')).toContain('select mpv'); + expect(ffmpegInputFailure('Format not on whitelist; https://secret:password@radio.example')).not.toContain('password'); + expect(ffmpegInputFailure('Connection reset')).toBeUndefined(); +}); + +it.each(['certificate verify failed', 'Peer certificate failed verification', "The certificate's owner does not match hostname", 'Unable to verify peer certificate', 'Certificate verification failed.', 'Invalid certificate chain', 'SNI or certificate check failed', 'Creating security context failed (0x80090325)', 'Creating security context failed (0x80090327)', 'Creating security context failed (0x80090328)'])('recognizes a permanent TLS failure: %s', diagnostic => { + expect(ffmpegCertificateFailure(`${diagnostic}: https://secret:password@radio.example`)).toContain('certificate could not be verified'); + expect(ffmpegCertificateFailure(`${diagnostic}: https://secret:password@radio.example`)).not.toContain('password'); +}); +it.each(['TLS handshake failed', 'I/O error', 'Creating security context failed', 'Connection reset by peer', 'Connection timed out'])('retains transient transport recovery: %s', diagnostic => { + expect(ffmpegCertificateFailure(diagnostic)).toBeUndefined(); +}); diff --git a/src/player/ffmpeg-input.ts b/src/player/ffmpeg-input.ts new file mode 100644 index 0000000..edc230b --- /dev/null +++ b/src/player/ffmpeg-input.ts @@ -0,0 +1,36 @@ +// FFmpeg releases in circulation do not propagate tls_verify to resources +// opened by HLS/DASH/IMF demuxers. Restrict network fallbacks to self-contained +// media; mpv supplies verified networking for multi-resource inputs. +const mediaFormats = 'aac,ac3,aiff,amr,ape,asf,au,avi,caf,eac3,flac,flv,live_flv,matroska,webm,mov,mp4,m4a,3gp,3g2,mj2,mp3,mpeg,mpegts,nut,ogg,w64,wav,wv'; + +export function ffmpegInputArguments(target: string, options: {plainHttp?: boolean; reconnect?: boolean} = {}): string[] { + const network = /^https?:\/\//i.test(target); + const plainHttp = options.plainHttp && /^http:\/\//i.test(target); + if (!network) return ['-protocol_whitelist', 'file,crypto,data']; + const args = ['-format_whitelist', mediaFormats]; + if (plainHttp) args.push('-protocol_blacklist', 'https,tls'); + else args.push('-tls_verify', '1'); + if (options.reconnect) args.push('-reconnect', '1', '-reconnect_on_network_error', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '2'); + return args; +} + +/** First verify any HTTP -> HTTPS redirect. FFmpeg rejects the unused TLS + * option on ordinary HTTP media; retry only that exact failure, with TLS + * protocols blocked even if the server changes its redirect. */ +export function canRetryPlainHttp(target: string, diagnostic: string): boolean { + return /^http:\/\//i.test(target) && /(?:^|[\r\n])Option tls_verify not found\.?\s*(?:[\r\n]|$)/.test(diagnostic); +} + +export function ffmpegInputFailure(diagnostic: string): string | undefined { + if (/not on whitelist|on blacklist/i.test(diagnostic)) return 'This input is unavailable with FFmpeg. Use a direct audio stream, or select mpv for local playback of HLS/DASH and other network playlists.'; + return undefined; +} + +export function ffmpegCertificateFailure(diagnostic: string): string | undefined { + // Backends use different TLS libraries. Generic handshake/I/O errors can be + // transient; only specific certificate failures are permanent here. + if (/certificate (?:verify|verification) failed|peer certificate failed verification|certificate's owner does not match hostname|unable to verify peer certificate|invalid certificate chain|SNI or certificate check failed|Creating security context failed \(0x800903(?:25|27|28)\)/i.test(diagnostic)) { + return 'The stream certificate could not be verified. Choose a valid HTTPS stream or repair the native certificate configuration.'; + } + return undefined; +} diff --git a/src/player/fixtures/airplay-legacy-crypto.json b/src/player/fixtures/airplay-legacy-crypto.json new file mode 100644 index 0000000..8701d00 --- /dev/null +++ b/src/player/fixtures/airplay-legacy-crypto.json @@ -0,0 +1,62 @@ +[ + { + "seed": "0000000000000000000000000000000000000000000000000000000000000000", + "K": "12121212121212121212121212121212121212121212121212121212121212121212121212121212", + "clientPublic": "2323232323232323232323232323232323232323232323232323232323232323", + "receiverPublic": "abababababababababababababababababababababababababababababababab", + "shared": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "receiverData": "efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + "public": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29", + "confirm": { + "epk": "4c8d88586b29dfb0c053e4a62ec8930061b87420e7a7c6a384dd57f37b8c3629", + "authTag": "1f451e5478af84b4f9b290f85cbf646f" + }, + "signed": "33AF0FE667102E0D1A69DEF5F22C76236584C69BC47BBA299316DCD78F26C53989C603B2A4D5E6167D2E7C8015FCF2539A105D850F1E0AAD7F67ED365AF07F0B", + "signature": "85949ca0957cac08f427b58538de24eef8da0eee1fd3ddff6a04c582881861ecd56c532e9a0b3efcda05cafe05372d16878567344322f1308d25ce797fe556ae" + }, + { + "seed": "0101010101010101010101010101010101010101010101010101010101010101", + "K": "12121212121212121212121212121212121212121212121212121212121212121212121212121212", + "clientPublic": "2323232323232323232323232323232323232323232323232323232323232323", + "receiverPublic": "abababababababababababababababababababababababababababababababab", + "shared": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "receiverData": "efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + "public": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "confirm": { + "epk": "fd6f4c39d1968a085fa2975b381dc301ceed68e8e7d1971e4d69975344da835c", + "authTag": "196148873cf22ebf58e04c8c1e265a25" + }, + "signed": "A352908426E6629B63287705ACD91E93704CE1E0682FB81EDC2C993D88B41961F6E605593F2B19B3907EF940E3B371EB009D587BC6CE5D4E0C47C4DC29937D07", + "signature": "156903c2d48ae09e8d661c75662b4c5eed122995b387dfc8253e80688f8abdb4aa4c55c501f5c15937554f3ef378aeae1d0862ca8af2a6d3fe05e7930c8654a2" + }, + { + "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "K": "12121212121212121212121212121212121212121212121212121212121212121212121212121212", + "clientPublic": "2323232323232323232323232323232323232323232323232323232323232323", + "receiverPublic": "abababababababababababababababababababababababababababababababab", + "shared": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "receiverData": "efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "confirm": { + "epk": "a0bd37e5272e712a77bbb2a5cdc399490a6b13a420e3a620111f053a07d2bd1a", + "authTag": "36ba179621da585f76c001e5996da941" + }, + "signed": "3F4E0B1DF3BC170FFA8CFA4231BFB64618416DB1F5E6C128FB87C28897E1DC74B5701F18BDD9846F779FF1B42B2B3A9754B89483533F92E2CE665C450270EF09", + "signature": "8975985b01d0950a14c29132fb4de48b851fa5c42e4ea6fe0295dbdd90df78a1e9da4f8483075c85d0b447ca3be0e5d2492dae321f03697f3c247f0a2765c6ac" + }, + { + "seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "K": "12121212121212121212121212121212121212121212121212121212121212121212121212121212", + "clientPublic": "2323232323232323232323232323232323232323232323232323232323232323", + "receiverPublic": "abababababababababababababababababababababababababababababababab", + "shared": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "receiverData": "efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + "public": "76a1592044a6e4f511265bca73a604d90b0529d1df602be30a19a9257660d1f5", + "confirm": { + "epk": "0146f6c4e1399f68b3d617bc77019aaa0f8f48862525aee6b404b67786b53df5", + "authTag": "e1db8c357dbb751aa9115202b985ef2c" + }, + "signed": "CEEABD4F57DA6188B9523A9298CC05E926639BEC92B85BB2E4F4B0CC22FBA4818CB966713AC2E1D352839D545998066A0F6939E820DFA18F7C3BC08235E4F90A", + "signature": "78d12e09a5b6e38d571c51e2523e5724bb3d539949103c641de6a99925c50054d01336ed041c3939f5a82b2a4953d92f12fc03596ce35a128e79e3cd10f1d0af" + } +] diff --git a/src/player/mpv-desktop-media.native.test.ts b/src/player/mpv-desktop-media.native.test.ts new file mode 100644 index 0000000..176cad6 --- /dev/null +++ b/src/player/mpv-desktop-media.native.test.ts @@ -0,0 +1,105 @@ +import {execFile, spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {createConnection} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {promisify} from 'node:util'; +import {describe, expect, it} from 'vitest'; +import {planMpvDesktopMedia} from './mpv-desktop-media.js'; + +const run = promisify(execFile); +const stockPlugin = '/usr/lib/mpv-mpris/mpris.so'; + +/** Linux CI supplies the real packaged plugin and an isolated session bus. + * Null audio verifies coexistence and IPC, not audible desktop playback. */ +describe.skipIf(process.platform !== 'linux' || !process.env.DBUS_SESSION_BUS_ADDRESS || !existsSync(stockPlugin))('native mpv media coexistence', () => { + it('suppresses only the stock media plugin while preserving user scripts and configuration', async () => { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-mpv-media-')); + const marker = join(directory, 'marker'); + const env = {...process.env, MPV_HOME: directory}; + mkdirSync(join(directory, 'scripts')); + symlinkSync(stockPlugin, join(directory, 'scripts', 'mpris.so')); + writeFileSync(join(directory, 'mpv.conf'), 'volume=37\n'); + // Publish completion only after Lua has closed the file; open("w") alone + // makes an empty marker visible to the waiting test in another process. + writeFileSync(join(directory, 'scripts', "custom space,: ' 日本語.lua"), `local f = assert(io.open(${JSON.stringify(`${marker}.tmp`)}, "w")); f:write("preserved"); f:close(); assert(os.rename(${JSON.stringify(`${marker}.tmp`)}, ${JSON.stringify(marker)}))`); + try { + const before = await names(); + await withPlayer([], env, directory, async socket => { + await until(() => existsSync(marker)); + expect(readFileSync(marker, 'utf8')).toBe('preserved'); + expect(await volume(socket)).toBe(37); + await until(async () => (await names()).some(name => !before.includes(name))); + }); + await until(async () => (await names()).every(name => before.includes(name))); + rmSync(marker); + const policy = planMpvDesktopMedia({env}); + expect(policy.status).toBe('compatible'); + expect(policy.args[0]).toBe('--load-scripts=no'); + await withPlayer(policy.args, env, directory, async socket => { + await until(() => existsSync(marker)); + expect(readFileSync(marker, 'utf8')).toBe('preserved'); + expect(await volume(socket)).toBe(37); + await delay(200); + expect(await names()).toEqual(before); + }); + } finally { rmSync(directory, {force: true, recursive: true}); } + }, 15_000); +}); + +async function withPlayer(args: string[], env: NodeJS.ProcessEnv, directory: string, check: (socket: string) => Promise): Promise { + const socket = join(directory, 'ipc'); + rmSync(socket, {force: true}); + const child = spawn('mpv', ['--idle=yes', '--ao=null', '--no-video', '--no-terminal', `--input-ipc-server=${socket}`, ...args], {env, stdio: 'ignore'}); + const closed = once(child, 'close'); + // Attach a rejection handler immediately, including failed executable starts. + void closed.catch(() => undefined); + try { + await until(() => existsSync(socket)); + await check(socket); + } finally { + child.kill('SIGTERM'); + const force = setTimeout(() => child.kill('SIGKILL'), 1000); + try { await closed; } finally { clearTimeout(force); } + } +} + +function volume(path: string): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(path); + let buffer = ''; + socket.setTimeout(2000, () => socket.destroy(new Error('mpv IPC timed out.'))); + socket.on('error', reject); + socket.on('connect', () => socket.write(`${JSON.stringify({command: ['get_property', 'volume'], request_id: 1})}\n`)); + socket.on('data', chunk => { + buffer += chunk.toString(); + if (buffer.length > 64 * 1024) { socket.destroy(new Error('mpv IPC exceeded its response limit.')); return; } + let boundary: number; + while ((boundary = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 1); + try { + const message = JSON.parse(line) as {request_id?: number; data?: number; error?: string}; + if (message.request_id === 1) { + socket.destroy(); + if (message.error !== 'success' || typeof message.data !== 'number') reject(new Error('mpv did not return its configured volume.')); + else resolve(message.data); + } + } catch (error) { socket.destroy(); reject(error); } + } + }); + }); +} + +async function names(): Promise { + const result = await run('dbus-send', ['--session', '--print-reply', '--reply-timeout=2000', '--dest=org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus.ListNames'], {timeout: 3000, maxBuffer: 64 * 1024}); + return result.stdout.split('\n').filter(line => line.includes('org.mpris.MediaPlayer2.mpv')).sort(); +} + +async function until(predicate: () => boolean | Promise): Promise { + const end = Date.now() + 4000; + while (Date.now() < end) { if (await predicate()) return; await delay(20); } + throw new Error('The native mpv condition did not become ready.'); +} + +function delay(milliseconds: number): Promise { return new Promise(resolve => setTimeout(resolve, milliseconds)); } diff --git a/src/player/mpv-desktop-media.test.ts b/src/player/mpv-desktop-media.test.ts new file mode 100644 index 0000000..9a24540 --- /dev/null +++ b/src/player/mpv-desktop-media.test.ts @@ -0,0 +1,106 @@ +import {mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {planMpvDesktopMedia} from './mpv-desktop-media.js'; + +let root: string; +let system: string; +let user: string; +let stock: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'radiocli-mpv-media-')); + system = join(root, 'system'); user = join(root, 'user'); stock = join(root, 'stock', 'mpris.so'); + mkdirSync(join(system, 'scripts'), {recursive: true}); + mkdirSync(join(user, 'scripts'), {recursive: true}); + mkdirSync(join(root, 'stock')); writeFileSync(stock, 'stock plugin fixture'); +}); +afterEach(() => rmSync(root, {recursive: true, force: true})); + +const plan = () => planMpvDesktopMedia({platform: 'linux', env: {}, configDirectories: [system, user], stockMprisPath: stock}); +function script(directory: string, name: string): string { const path = join(directory, 'scripts', name); writeFileSync(path, 'script fixture'); return path; } +// Windows symlink creation depends on Developer Mode. Real POSIX symlink +// behavior is exercised on every macOS/Linux job, never emulated on Windows. +function stockLink() { symlinkSync(stock, join(system, 'scripts', 'mpris.so')); } + +describe('per-process mpv desktop media coexistence', () => { + it.each(['darwin', 'win32', 'freebsd', 'android'] as const)('does not inspect or rewrite %s playback', platform => { + expect(planMpvDesktopMedia({platform})).toMatchObject({status: 'unsupported', args: []}); + }); + + it('keeps original mpv arguments when no media plugin is installed', () => { + script(user, 'custom.lua'); + expect(plan()).toMatchObject({status: 'compatible', args: []}); + }); + + it.skipIf(process.platform === 'win32')('omits only stock MPRIS while preserving normal script order, literal paths and config', () => { + stockLink(); + const alpha = script(system, 'a.lua'); const beta = script(system, 'B.js'); + const unicode = script(user, '日本語 space,colon:quote\".lua'); + const directoryScript = join(user, 'scripts', 'bundle'); mkdirSync(directoryScript); writeFileSync(join(directoryScript, 'main.lua'), 'bundle'); + script(user, '.hidden.lua'); + const config = 'ao=pipewire,pulse\nvolume=31\nscript-opts=custom-option=value\n'; + writeFileSync(join(user, 'mpv.conf'), config); + expect(plan()).toMatchObject({status: 'compatible', args: ['--load-scripts=no', `--script=${alpha}`, `--script=${beta}`, `--script=${directoryScript}`, `--script=${unicode}`]}); + expect(readFileSync(join(user, 'mpv.conf'), 'utf8')).toBe(config); + expect(readFileSync(stock, 'utf8')).toBe('stock plugin fixture'); + }); + + it.skipIf(process.platform === 'win32')('honors disabled autoload without enabling any user script', () => { + stockLink(); script(user, 'custom.lua'); writeFileSync(join(user, 'mpv.conf'), 'load-scripts=no # intentional\n'); + expect(plan()).toMatchObject({status: 'compatible', args: []}); + }); + + it.skipIf(process.platform === 'win32')('applies ordinary config precedence before selecting scripts', () => { + stockLink(); writeFileSync(join(system, 'mpv.conf'), 'load-scripts=no\n'); writeFileSync(join(user, 'mpv.conf'), 'load-scripts=yes\n'); + expect(plan()).toMatchObject({status: 'compatible', args: ['--load-scripts=no']}); + }); + + it.each([ + 'include=other.conf', 'script=custom.lua', 'scripts-append=other.lua', + '[custom]\nload-scripts=no', 'load-scripts="no"', + 'config=no', 'use-filedir-conf=yes' + ])('preserves ambiguous custom config %s instead of overriding it', text => { + writeFileSync(join(user, 'mpv.conf'), text); + expect(plan()).toMatchObject({status: 'unsupported', args: []}); + expect(readFileSync(join(user, 'mpv.conf'), 'utf8')).toBe(text); + }); + + it('checks legacy config and encoding profiles as well as mpv.conf', () => { + writeFileSync(join(system, 'encoding-profiles.conf'), '[custom]\nscript=mpris.so'); + expect(plan().status).toBe('unsupported'); + rmSync(join(system, 'encoding-profiles.conf')); + writeFileSync(join(user, 'config'), 'include=legacy.conf'); + expect(plan().status).toBe('unsupported'); + }); + + it('preserves ordinary profiles such as the bundled encoding profiles', () => { + writeFileSync(join(system, 'encoding-profiles.conf'), '[enc-a]\noac=opus\n[enc-file]\nprofile=enc-a\n'); + writeFileSync(join(user, 'mpv.conf'), '[quiet]\nvolume=30\n[default]\nprofile=quiet\n'); + expect(plan()).toMatchObject({status: 'compatible', args: []}); + }); + + it.each(['mpris.so', 'mpv-mpris.lua', 'mpris'])('leaves a custom media plugin %s intact', name => { + script(user, name); + expect(plan()).toMatchObject({status: 'unsupported', args: []}); + }); + + it('fails closed on excessive config and script counts', () => { + writeFileSync(join(user, 'mpv.conf'), 'x'.repeat(256 * 1024 + 1)); + expect(plan().status).toBe('unsupported'); + rmSync(join(user, 'mpv.conf')); + for (let i = 0; i < 257; i++) script(user, `${i}.lua`); + expect(plan().status).toBe('unsupported'); + }); + + it('does not guess a custom executable configuration layout', () => { + expect(planMpvDesktopMedia({platform: 'linux', executable: '/custom/bin/mpv', env: {}})).toMatchObject({status: 'unsupported', args: []}); + }); + + it.skipIf(process.platform === 'win32')('respects MPV_HOME isolation, including explicit empty config', () => { + script(user, 'custom.lua'); + expect(planMpvDesktopMedia({platform: 'linux', env: {MPV_HOME: ''}})).toMatchObject({status: 'compatible', args: []}); + expect(planMpvDesktopMedia({platform: 'linux', env: {MPV_HOME: user}})).toMatchObject({status: 'compatible', args: []}); + expect(planMpvDesktopMedia({platform: 'linux', env: {MPV_HOME: 'relative'}}).status).toBe('unsupported'); + }); +}); diff --git a/src/player/mpv-desktop-media.ts b/src/player/mpv-desktop-media.ts new file mode 100644 index 0000000..2558a7f --- /dev/null +++ b/src/player/mpv-desktop-media.ts @@ -0,0 +1,152 @@ +import {closeSync, constants, fstatSync, openSync, opendirSync, readSync, realpathSync, statSync} from 'node:fs'; +import {posix} from 'node:path'; + +type MpvMediaPlan = {status: 'compatible' | 'unsupported'; args: string[]; reason: string}; +type Options = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + executable?: string | null; + /** Low-to-high mpv search order; test fixtures never inspect host config. */ + configDirectories?: readonly string[]; + stockMprisPath?: string; +}; + +const maxConfigBytes = 256 * 1024; +const maxScripts = 256; +const unsupported = (reason: string): MpvMediaPlan => ({status: 'unsupported', args: [], reason}); +const compatible = (args: string[], reason: string): MpvMediaPlan => ({status: 'compatible', args, reason}); + +/** + * mpv-mpris 1.3 has no per-process opt-out. Replace directory autoload with its + * exact file list, omitting only the packaged plugin. mpv still reads its normal + * configuration and loads every unrelated script. Nothing is written to disk. + * + * Source contracts: mpv player/scripting.c and options/path.c; mpv-mpris Makefile. + * Configurations whose script-loading semantics cannot be preserved are left + * completely alone; the application media service must then remain disabled. + */ +export function planMpvDesktopMedia(options: Options = {}): MpvMediaPlan { + if ((options.platform ?? process.platform) !== 'linux') return unsupported('Desktop mpv media integration is available on Linux.'); + const env = options.env ?? process.env; + const executable = options.executable ?? env.RADIOCLI_MPV_PATH ?? '/usr/bin/mpv'; + if (!options.configDirectories && !['/usr/bin/mpv', '/bin/mpv'].includes(executable)) { + return unsupported('Desktop media controls require the packaged mpv configuration layout; the custom mpv executable was left unchanged.'); + } + try { + const directories = options.configDirectories ?? configDirectories(env); + let stockMprisPath = options.stockMprisPath ?? '/usr/lib/mpv-mpris/mpris.so'; + try { stockMprisPath = realpathSync(stockMprisPath); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + let autoload = true; + let ambiguous = false; + // mpv loads encoding profiles first, then config/mpv.conf in each search + // directory. A profile with script options is deliberately not reinterpreted. + const files = [ + ...directories.map(directory => posix.join(directory, 'encoding-profiles.conf')), + ...directories.flatMap(directory => ['config', 'mpv.conf'].map(file => posix.join(directory, file))) + ]; + for (const file of files) { + const contents = readConfig(file); + if (contents === null) continue; + let profile = false; + for (const raw of contents.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + if (line.startsWith('[')) { profile = !/^\[default\]\s*(?:#.*)?$/.test(line); continue; } + // Includes and script lists can change startup script order or load + // another media plugin. Ordinary profiles remain intact; script options + // inside any profile are rejected even when that profile is inactive. + if (/^(?:--)?(?:include|config(?:-dir)?|use-filedir-conf|scripts?(?:-(?:add|append|clr|del|pre|set|toggle|remove))?)\s*(?:=|$)/i.test(line)) { ambiguous = true; continue; } + if (/^(?:--)?(?:no-)?load-scripts\b/i.test(line)) { + const setting = /^(?:--)?load-scripts\s*=\s*(yes|no)\s*(?:#.*)?$/i.exec(line); + if (profile || !setting) ambiguous = true; + else autoload = setting[1]!.toLowerCase() === 'yes'; + } + } + } + if (ambiguous) return unsupported('Custom mpv script, include, or profile directives require native media handling; mpv configuration and scripts were left unchanged.'); + if (!autoload) return compatible([], 'mpv directory scripts are disabled by the existing configuration.'); + + const scripts: string[] = []; + let omitted = 0; + for (const directory of directories) { + const entries = scriptFiles(posix.join(directory, 'scripts')); + for (const file of entries) { + if (scripts.length + omitted >= maxScripts) throw new Error('Too many mpv scripts to preserve within the bounded desktop media check.'); + const actual = realpathSync(file); + if (actual === stockMprisPath) { omitted++; continue; } + if (/^(?:mpv[-_])?mpris(?:\.(?:so|lua|js))?$/i.test(posix.basename(file)) || /^(?:mpv[-_])?mpris(?:\.(?:so|lua|js))?$/i.test(posix.basename(actual))) { + return unsupported('A custom mpv MPRIS plugin needs native media handling; mpv configuration and scripts were left unchanged.'); + } + scripts.push(file); + } + } + if (!omitted) return compatible([], 'No stock mpv media plugin needs suppression.'); + return compatible(['--load-scripts=no', ...scripts.map(file => `--script=${file}`)], 'The stock mpv media plugin is omitted for this process; other scripts and configuration are preserved.'); + } catch { + return unsupported('mpv script configuration could not be safely inspected; native mpv behavior is preserved.'); + } +} + +function configDirectories(env: NodeJS.ProcessEnv): string[] { + if (env.MPV_HOME !== undefined) { + if (!env.MPV_HOME) return []; + return [absolute(env.MPV_HOME)]; + } + const result = ['/etc/mpv']; + const home = env.HOME ? absolute(env.HOME) : null; + const modern = env.XDG_CONFIG_HOME?.startsWith('/') ? posix.join(absolute(env.XDG_CONFIG_HOME), 'mpv') : home ? posix.join(home, '.config', 'mpv') : null; + const legacy = home ? posix.join(home, '.mpv') : null; + if (legacy && isDirectory(legacy)) result.push(legacy); + if (modern && (modern !== legacy) && (!legacy || !isDirectory(legacy) || isDirectory(modern))) result.push(modern); + return result; +} + +function absolute(value: string): string { + if (!posix.isAbsolute(value) || /[\0\r\n]/.test(value)) throw new Error('Ambiguous mpv configuration path.'); + return posix.normalize(value); +} + +function isDirectory(path: string): boolean { + try { return statSync(path).isDirectory(); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; } +} + +function readConfig(path: string): string | null { + let descriptor: number | undefined; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); + const info = fstatSync(descriptor); + if (!info.isFile() || info.size > maxConfigBytes) throw new Error('Unbounded mpv configuration.'); + const buffer = Buffer.alloc(maxConfigBytes + 1); + const size = readSync(descriptor, buffer, 0, buffer.length, 0); + if (size > maxConfigBytes) throw new Error('Unbounded mpv configuration.'); + return buffer.toString('utf8', 0, size); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } finally { if (descriptor !== undefined) closeSync(descriptor); } +} + +function scriptFiles(directory: string): string[] { + let handle: ReturnType; + try { handle = opendirSync(directory); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } + const files: string[] = []; + let inspected = 0; + try { + for (let entry = handle.readSync(); entry; entry = handle.readSync()) { + if (++inspected > 1024 || files.length >= maxScripts) throw new Error('Unbounded mpv script directory.'); + if (entry.name.startsWith('.')) continue; + const path = posix.join(directory, entry.name); + let info: ReturnType; + try { info = statSync(path); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; } + if (info.isFile() || info.isDirectory()) files.push(path); + } + } finally { handle.closeSync(); } + // Use stable ASCII ordering for explicit scripts, without locale collation. + // Native mpv autoload order is not a reliable script dependency contract. + const fold = (value: string) => Buffer.from(value.replace(/[A-Z]/g, letter => letter.toLowerCase())); + return files.sort((left, right) => Buffer.compare(fold(left), fold(right))); +} diff --git a/src/player/mpv-ipc-client.test.ts b/src/player/mpv-ipc-client.test.ts index 7fa6240..df5e0ce 100644 --- a/src/player/mpv-ipc-client.test.ts +++ b/src/player/mpv-ipc-client.test.ts @@ -12,6 +12,55 @@ afterEach(async () => { }); describe('MpvIpcClient', () => { + it('delivers observed events without consuming replies and restores subscriptions on reconnect', async () => { + const path = testIpcPath(); const sockets = new Set(); const observations: unknown[][] = []; + const server = createServer(socket => { + sockets.add(socket); socket.on('close', () => sockets.delete(socket)); socket.on('error', () => undefined); + let buffer = ''; + socket.on('data', chunk => { + buffer += String(chunk); + while (buffer.includes('\n')) { + const newline = buffer.indexOf('\n'); const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); + const request = JSON.parse(line) as {request_id: number; command: unknown[]}; + if (request.command[0] === 'disconnect') { socket.destroy(); return; } + if (request.command[0] === 'observe_property') { + observations.push(request.command); + socket.write(`null\n[]\n${JSON.stringify({event: 'property-change', id: 1, name: 'pause', data: true})}\n`); + } + socket.write(`${JSON.stringify({request_id: request.request_id, error: 'success', data: 'ack'})}\n`); + } + }); + }); + await listen(server, path); const client = new MpvIpcClient(path, 500); + cleanupTasks.push(() => closeTestIpc(client, server, sockets, path)); + const events: unknown[] = []; client.onEvent(() => { throw new Error('consumer failed'); }); client.onEvent(event => events.push(event)); + await client.observe(1, 'pause'); + expect(events).toEqual([{event: 'property-change', id: 1, name: 'pause', data: true}]); + // A server-side close does not establish that the client has received EOF. + await expect(client.query({command: ['disconnect']})).rejects.toThrow('mpv IPC connection closed.'); + await waitUntil(() => sockets.size === 0); + await expect(client.query({command: ['get_property', 'path']})).resolves.toBe('ack'); + await waitUntil(() => observations.length === 2 && events.length === 2); + expect(observations).toEqual([['observe_property', 1, 'pause'], ['observe_property', 1, 'pause']]); + }); + + it('bounds an unterminated IPC message and rejects pending work before reconnecting', async () => { + const path = testIpcPath(); const sockets = new Set(); let connections = 0; + const server = createServer(socket => { + const first = ++connections === 1; + sockets.add(socket); socket.on('close', () => sockets.delete(socket)); socket.on('error', () => undefined); + socket.once('data', request => { + if (first) { socket.write('x'.repeat(4 * 1024 * 1024 + 1)); return; } + const {request_id} = JSON.parse(String(request)) as {request_id: number}; + socket.write(`${JSON.stringify({request_id, error: 'success', data: 'reconnected'})}\n`); + }); + }); + await listen(server, path); const client = new MpvIpcClient(path, 5000); + cleanupTasks.push(() => closeTestIpc(client, server, sockets, path)); + await expect(client.query({command: ['get_property', 'path']})).rejects.toThrow('4 MiB'); + await expect(client.query({command: ['get_property', 'path']})).resolves.toBe('reconnected'); + }, 10_000); + it('preserves UTF-8 metadata across native socket packets', async () => { const path = testIpcPath(); const sockets = new Set(); @@ -50,6 +99,7 @@ describe('MpvIpcClient', () => { buffer = buffer.slice(newlineIndex + 1); newlineIndex = buffer.indexOf('\n'); const request = JSON.parse(line) as {request_id: number; command: [string, string, number?]}; + if (request.command[0] === 'disconnect') { socket.destroy(); return; } const delayMs = request.command[2] ?? 0; setTimeout(() => { if (!socket.destroyed) { @@ -69,9 +119,7 @@ describe('MpvIpcClient', () => { await expect(Promise.all([slow, fast])).resolves.toEqual(['slow', 'fast']); expect(connectionCount).toBe(1); - for (const socket of sockets) { - socket.destroy(); - } + await expect(client.query({command: ['disconnect']})).rejects.toThrow('mpv IPC connection closed.'); await waitUntil(() => sockets.size === 0); await expect(client.query({command: ['echo', 'reconnected']})).resolves.toBe('reconnected'); diff --git a/src/player/mpv-ipc-client.ts b/src/player/mpv-ipc-client.ts index dee76d0..ea6f31d 100644 --- a/src/player/mpv-ipc-client.ts +++ b/src/player/mpv-ipc-client.ts @@ -11,6 +11,7 @@ type MpvResponse = { error?: string; data?: unknown; }; +export type MpvEvent = {event: string; name?: string; id?: number; data?: unknown; reason?: string; error?: string}; /** * Multiplexes mpv JSON IPC requests over one reusable socket. @@ -25,14 +26,29 @@ export class MpvIpcClient { private connecting: Promise | null = null; private pending = new Map(); private buffer = ''; + private bufferBytes = 0; + private bufferScanFrom = 0; private nextRequestId = 1; private closed = false; + private connectedOnce = false; + private readonly observers = new Map(); + private readonly eventListeners = new Set<(event: MpvEvent) => void>(); constructor( private readonly path: string, private readonly timeoutMs = 1000 ) {} + onEvent(listener: (event: MpvEvent) => void): () => void { + this.eventListeners.add(listener); + return () => this.eventListeners.delete(listener); + } + + async observe(id: number, property: string): Promise { + this.observers.set(id, property); + await this.query({command: ['observe_property', id, property]}); + } + async query(payload: unknown): Promise { if (this.closed) { throw new Error('mpv IPC client is closed.'); @@ -71,6 +87,8 @@ export class MpvIpcClient { } this.closed = true; + this.eventListeners.clear(); + this.observers.clear(); this.rejectPending(reason); this.socket?.destroy(); this.connectingSocket?.destroy(); @@ -78,6 +96,8 @@ export class MpvIpcClient { this.connectingSocket = null; this.connecting = null; this.buffer = ''; + this.bufferBytes = 0; + this.bufferScanFrom = 0; } private connect(): Promise { @@ -122,11 +142,17 @@ export class MpvIpcClient { this.socket = socket; this.connectingSocket = null; this.buffer = ''; + this.bufferBytes = 0; + this.bufferScanFrom = 0; socket.setEncoding('utf8'); - socket.on('data', chunk => this.consume(String(chunk))); + socket.on('data', chunk => { if (this.socket === socket) 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); + if (this.connectedOnce) { + for (const [id, property] of this.observers) void this.query({command: ['observe_property', id, property]}).catch(() => undefined); + } + this.connectedOnce = true; }; socket.once('error', failConnection); @@ -150,10 +176,20 @@ export class MpvIpcClient { private consume(chunk: string): void { this.buffer += chunk; - let newlineIndex = this.buffer.indexOf('\n'); + this.bufferBytes += Buffer.byteLength(chunk); + if (this.bufferBytes > 4 * 1024 * 1024) { + const socket = this.socket; + if (socket) { + this.dropSocket(socket, new Error('mpv IPC message exceeds 4 MiB.')); + socket.destroy(); + } + return; + } + let newlineIndex = this.buffer.indexOf('\n', this.bufferScanFrom); while (newlineIndex !== -1) { const line = this.buffer.slice(0, newlineIndex); this.buffer = this.buffer.slice(newlineIndex + 1); + this.bufferBytes -= Buffer.byteLength(line) + 1; newlineIndex = this.buffer.indexOf('\n'); if (!line.trim()) { continue; @@ -165,8 +201,13 @@ export class MpvIpcClient { } catch { continue; } + if (!response || typeof response !== 'object' || Array.isArray(response)) continue; if (typeof response.request_id !== 'number' || !this.pending.has(response.request_id)) { + const event = response as MpvResponse & Partial; + if (typeof event.event === 'string') { + for (const listener of this.eventListeners) { try { listener(event as MpvEvent); } catch { /* Isolate event consumers. */ } } + } continue; } if (response.error && response.error !== 'success') { @@ -175,6 +216,7 @@ export class MpvIpcClient { this.settleRequest(response.request_id, pending => pending.resolve(response.data)); } } + this.bufferScanFrom = this.buffer.length; } private dropSocket(socket: Socket, error: Error): void { @@ -184,6 +226,8 @@ export class MpvIpcClient { this.socket = null; this.buffer = ''; + this.bufferBytes = 0; + this.bufferScanFrom = 0; this.rejectPending(error); } diff --git a/src/player/player-controller.test.ts b/src/player/player-controller.test.ts index ba35f12..1bf7b78 100644 --- a/src/player/player-controller.test.ts +++ b/src/player/player-controller.test.ts @@ -6,6 +6,8 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import {commandExists} from '../platform/executables.js'; import {discoverAirPlayDevices} from './airplay-discovery.js'; import {extractMpvTitle, isPlaybackOutputError, PlayerController} from './player-controller.js'; +import * as desktopMedia from '../platform/desktop-media.js'; +import {ffmpegInputArguments} from './ffmpeg-input.js'; import type {AirPlayDevice, AppSettings, Station} from '../types.js'; vi.mock('node:child_process', () => ({ @@ -65,6 +67,268 @@ describe('extractMpvTitle', () => { }); describe('PlayerController lifecycle', () => { + it.each(['https://streams.example.com/live', 'http://streams.example.com/redirect', 'file:///radio/streams.m3u'])('enforces certificate checks for %s', async url => { + commandExistsMock.mockImplementation(command => command === 'mpv'); let mpv!: FakeMpvIpc; + spawnMock.mockImplementation((_command, args) => { mpv = fakeMpvIpc(mpvIpcPath(args)); return fakeChildProcess() as never; }); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'})); + try { await controller.play(station(), url); expect(spawnMock.mock.calls[0]?.[1]).toContain('--tls-verify=yes'); } + finally { await controller.stop(); await mpv.close(); } + }); + + it.each([{provider: 'radio-browser' as const}, {provider: 'radio-garden' as const, duration: 180}, {provider: 'playlist' as const, seekable: false}])('diagnoses live EOF as recoverable (%j)', async media => { + commandExistsMock.mockImplementation(command => command === 'mpv'); let mpv!: FakeMpvIpc; + spawnMock.mockImplementation((_command, args) => { mpv = fakeMpvIpc(mpvIpcPath(args), media); return fakeChildProcess() as never; }); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'})); + try { + await controller.play(station({provider: media.provider}), 'https://streams.example.com/live'); + mpv.emitEvent({event: 'end-file', reason: 'eof'}); + await waitUntil(() => controller.getState().state === 'error'); + expect(controller.getState().ready).toBe(false); + expect(controller.recoverableFailure()).toBe(true); + } finally { await controller.stop(); await mpv.close(); } + }); + + it('does not confuse local unknown-duration audio with interrupted live radio', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); let mpv!: FakeMpvIpc; + spawnMock.mockImplementation((_command, args) => { mpv = fakeMpvIpc(mpvIpcPath(args)); return fakeChildProcess() as never; }); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'})); + try { + await controller.play(station(), 'file:///radio/recording.mp3'); + mpv.emitEvent({event: 'end-file', reason: 'eof'}); + await waitUntil(() => !controller.getState().ready); + expect(controller.getState().state).toBe('stopped'); expect(controller.recoverableFailure()).toBe(false); + } finally { await controller.stop(); await mpv.close(); } + }); + + it('does not treat a living ffplay process, headers or a nan clock as audio readiness', async () => { + vi.useFakeTimers(); commandExistsMock.mockImplementation(command => command === 'ffplay'); + const child = fakeChildProcess(false); spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'ffplay', tuneTimeoutSeconds: 3})); + const playing = controller.play(station(), 'https://streams.example.com/silent'); + const rejected = expect(playing).rejects.toThrow('Timed out'); + await vi.advanceTimersByTimeAsync(500); + child.stderr.emit('data', 'Audio: mp3, 44100 Hz\n nan M-A: nan fd= 0 aq=0\r'); + await vi.advanceTimersByTimeAsync(1000); + expect(controller.getState()).toMatchObject({state: 'loading', ready: false}); + await vi.advanceTimersByTimeAsync(2000); await rejected; + expect(child.kill).toHaveBeenCalled(); expect(controller.getState().ready).toBe(false); + await controller.stop(); + }); + + it('accepts a split ffplay audio-clock record only after the complete record arrives', async () => { + vi.useFakeTimers(); commandExistsMock.mockImplementation(command => command === 'ffplay'); + const child = fakeChildProcess(false); spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'ffplay'})); + const playing = controller.play(station(), 'https://streams.example.com/live'); + await vi.advanceTimersByTimeAsync(500); child.stderr.emit('data', ' 0.12 M-'); + await vi.advanceTimersByTimeAsync(200); expect(controller.getState().ready).toBe(false); + child.stderr.emit('data', 'A: 0.000 fd=0 aq=16KB\r'); await vi.advanceTimersByTimeAsync(200); await playing; + expect(controller.getState().ready).toBe(true); await controller.stop(); + }); + + it('preserves an explicit Stop while startup timeout is retiring the player', async () => { + vi.useFakeTimers(); commandExistsMock.mockImplementation(command => command === 'ffplay'); + const child = Object.assign(fakeChildProcess(false), {exitCode: null as number | null}); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'ffplay', tuneTimeoutSeconds: 3})); + const rejected = expect(controller.play(station(), 'https://streams.example.com/silent')).rejects.toMatchObject({name: 'AbortError'}); + await vi.advanceTimersByTimeAsync(3100); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + const stopped = controller.stop(); + child.exitCode = 0; child.emit('exit', 0, null); + await stopped; await rejected; + expect(controller.getState()).toMatchObject({state: 'stopped', ready: false}); + }); + + it('retries unused TLS options once on HTTP, after draining stderr, with HTTPS blocked', async () => { + vi.useFakeTimers(); commandExistsMock.mockImplementation(command => command === 'ffplay'); + spawnMock.mockImplementation(() => { + const first = spawnMock.mock.calls.length === 1; + const child = fakeChildProcess(!first); + if (first) setTimeout(() => { + child.emit('exit', 0, null); + setTimeout(() => { child.stderr.emit('data', 'Option tls_verify not found.\n'); child.emit('close', 0, null); }, 50); + }, 300); + return child as never; + }); + const controller = new PlayerController(() => settings({preferredBackend: 'ffplay'})); + const playing = controller.play(station(), 'http://streams.example.com/live'); + await vi.advanceTimersByTimeAsync(1000); await playing; + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[1]?.[1]).toEqual(expect.arrayContaining(['-protocol_blacklist', 'https,tls'])); + expect(spawnMock.mock.calls[1]?.[1]).not.toContain('-tls_verify'); + expect(controller.getState().ready).toBe(true); await controller.stop(); + }); + + it('does not restart an HTTP compatibility attempt after Stop', async () => { + vi.useFakeTimers(); commandExistsMock.mockImplementation(command => command === 'ffplay'); + const child = fakeChildProcess(false); spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'ffplay'})); + const rejected = expect(controller.play(station(), 'http://streams.example.com/live')).rejects.toMatchObject({name: 'AbortError'}); + await vi.advanceTimersByTimeAsync(200); child.emit('exit', 0, null); + await vi.advanceTimersByTimeAsync(150); await controller.stop(); + child.stderr.emit('data', 'Option tls_verify not found.\n'); child.emit('close', 0, null); + await vi.advanceTimersByTimeAsync(300); await rejected; + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(controller.getState()).toMatchObject({state: 'stopped', ready: false}); + }); + + it('classifies a late FFmpeg format restriction as a backend limitation', async () => { + vi.useFakeTimers(); commandExistsMock.mockImplementation(command => command === 'ffplay'); + const child = fakeChildProcess(false); spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'ffplay'})); + const playing = controller.play(station(), 'https://streams.example.com/live.m3u8'); + const rejected = expect(playing).rejects.toSatisfy(error => isPlaybackOutputError(error) && /select mpv/.test(error.message)); + await vi.advanceTimersByTimeAsync(200); child.emit('exit', 1, null); + await vi.advanceTimersByTimeAsync(150); + child.stderr.emit('data', 'Format not on whitelist\n'); child.emit('close', 1, null); + await vi.advanceTimersByTimeAsync(300); await rejected; + expect(spawnMock).toHaveBeenCalledTimes(1); await controller.stop(); + }); + + it('waits for both VLC audio output initialization and released decoder buffering', async () => { + vi.useFakeTimers(); commandExistsMock.mockImplementation(command => command === 'vlc'); + const child = fakeChildProcess(false); spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'vlc'})); + const playing = controller.play(station(), 'https://streams.example.com/live'); + await vi.advanceTimersByTimeAsync(500); + child.stderr.emit('data', "main audio output debug: output 'f32l' 48000 Hz Stereo\n"); + await vi.advanceTimersByTimeAsync(500); expect(controller.getState().ready).toBe(false); + child.stderr.emit('data', '[000001] main input debug: Decoder wait '); + await vi.advanceTimersByTimeAsync(500); expect(controller.getState().ready).toBe(false); + child.stderr.emit('data', 'done in 1 ms\n'); + await vi.advanceTimersByTimeAsync(200); await playing; + expect(controller.getState().ready).toBe(true); await controller.stop(); + }); + + it('rejects a VLC process that never initializes an audio output', async () => { + vi.useFakeTimers(); commandExistsMock.mockImplementation(command => command === 'vlc'); + const child = fakeChildProcess(false); spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'vlc', tuneTimeoutSeconds: 3})); + const rejected = expect(controller.play(station(), 'https://streams.example.com/silent')).rejects.toThrow('Timed out'); + await vi.advanceTimersByTimeAsync(500); + child.stderr.emit('data', 'main input debug: Decoder wait done in 1 ms\n'); + await vi.advanceTimersByTimeAsync(3000); await rejected; + expect(controller.getState().ready).toBe(false); await controller.stop(); + }); + + it('tracks an advancing audio clock when mpv has no time-pos property', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); let mpv!: FakeMpvIpc; + let progress = 12; + spawnMock.mockImplementation((_command, args) => { + mpv = fakeMpvIpc(mpvIpcPath(args), {timePosUnavailable: true, audioProgress: () => progress}); + return fakeChildProcess() as never; + }); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'})); + try { + await controller.play(station(), 'https://streams.example.com/live'); + await waitUntil(() => controller.getState().elapsedSeconds === 12); + progress = 15; + await new Promise(resolve => setTimeout(resolve, 2600)); + expect(controller.getState()).toMatchObject({state: 'playing', ready: true, elapsedSeconds: 15}); + } finally { await controller.stop(); await mpv.close(); } + }); + + it('keeps a live-edge reload non-audible until the new stream decodes audio', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); let mpv!: FakeMpvIpc; + spawnMock.mockImplementation((_command, args) => { + mpv = fakeMpvIpc(mpvIpcPath(args)); mpv.setPaused(true); return fakeChildProcess() as never; + }); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'})); + const states: string[] = []; const unsubscribe = controller.onChange(state => states.push(state.state)); + try { + await controller.play(station(), 'https://streams.example.com/live.mp3', {startPaused: true}); + mpv.setAudioStarted(false); states.length = 0; + const resume = controller.resume(); + await waitUntil(() => mpv.loadedUrls().length === 1 && !mpv.paused()); + mpv.setPaused(false); + await new Promise(resolve => setTimeout(resolve, 40)); + expect(controller.getState()).toMatchObject({state: 'paused'}); + expect(states).not.toContain('playing'); + mpv.setAudioStarted(true); + await expect(resume).resolves.toMatchObject({ok: true}); + expect(controller.getState()).toMatchObject({state: 'playing', ready: true}); + } finally { unsubscribe(); await controller.stop(); await mpv.close(); } + }); + + it('does not report a failed live-edge reload as resumed playback', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); let mpv!: FakeMpvIpc; + spawnMock.mockImplementation((_command, args) => { + mpv = fakeMpvIpc(mpvIpcPath(args), {reloadError: 'loading failed'}); mpv.setPaused(true); return fakeChildProcess() as never; + }); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'})); + try { + await controller.play(station(), 'https://streams.example.com/live.mp3', {startPaused: true}); + await expect(controller.resume()).resolves.toMatchObject({ok: false}); + expect(controller.getState()).toMatchObject({ready: false}); + } finally { await controller.stop(); await mpv.close(); } + }); + + it('closes a finite recording when idle mpv reports EOF without exiting', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + let mpv!: FakeMpvIpc; const child = fakeChildProcess(); + spawnMock.mockImplementation((_command, args) => { mpv = fakeMpvIpc(mpvIpcPath(args), {duration: 60}); return child as never; }); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'})); + try { + await controller.play(station({provider: 'playlist'}), 'https://streams.example.com/live.mp3'); + mpv.emitEvent({event: 'end-file', reason: 'eof'}); + await waitUntil(() => !controller.getState().ready); + expect(controller.getState()).toMatchObject({state: 'stopped', ready: false, buffering: false}); + expect(controller.recoverableFailure()).toBe(false); + } finally { await controller.stop(); await mpv.close(); } + }); + + it('starts a paused queue selection silently and resumes it at the live edge', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const child = fakeChildProcess(); + const mpv = {current: null as FakeMpvIpc | null}; + spawnMock.mockImplementation((_command, args) => { + mpv.current = fakeMpvIpc(mpvIpcPath(args)); + mpv.current.setPaused(args?.includes('--pause=yes') ?? false); + return child as never; + }); + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'})); + const url = 'https://streams.example.com/live.mp3'; + await controller.play(station(), url, {startPaused: true}); + const server = expectFakeMpv(mpv.current); + try { + expect(spawnMock.mock.calls[0]?.[1]).toContain('--pause=yes'); + expect(server.paused()).toBe(true); + expect(controller.getState()).toMatchObject({state: 'paused', ready: true}); + await expect(controller.resume()).resolves.toMatchObject({ok: true}); + expect(server.loadedUrls()).toEqual([url]); + expect(server.paused()).toBe(false); + } finally { await controller.stop(); await server.close(); } + }); + + it('applies desktop filtering to only its owned mpv process and disables the bridge before an incompatible process starts', async () => { + commandExistsMock.mockImplementation(command => command === 'mpv'); + const policy = vi.spyOn(desktopMedia, 'desktopMediaPolicy').mockReturnValue({enabled: true, preference: 'on', reason: 'compatible', mpvArgs: ['--load-scripts=no', '--script=/tmp/retained.lua']}); + const servers: FakeMpvIpc[] = []; + const controller = new PlayerController(() => settings({preferredBackend: 'mpv'}), {platform: 'linux', arch: 'x64', env: {}}); + spawnMock.mockImplementation((_command, args) => { + servers.push(fakeMpvIpc(mpvIpcPath(args))); + return fakeChildProcess() as never; + }); + try { + await controller.play(station(), 'https://streams.example.com/live.mp3'); + expect(spawnMock.mock.calls[0]?.[1]).toEqual(expect.arrayContaining(['--load-scripts=no', '--script=/tmp/retained.lua'])); + policy.mockReturnValue({enabled: false, preference: 'on', reason: 'custom scripts', mpvArgs: []}); + const events: boolean[] = []; + const remove = controller.onChange(() => events.push(controller.desktopMediaCompatible())); + await controller.play(station(), 'https://streams.example.com/second.mp3'); + expect(events).toContain(false); + expect(spawnMock.mock.calls[1]?.[1]).not.toContain('--load-scripts=no'); + remove(); + } finally { await controller.stop(); for (const server of servers) await server.close(); policy.mockRestore(); } + }); + + it.each(['ffplay', 'vlc', 'airplay'])('rejects an impossible paused startup on %s before spawning', async backend => { + const controller = new PlayerController(() => settings({preferredBackend: backend as AppSettings['preferredBackend']})); + setDetectedBackends(controller, [backend]); + await expect(controller.play(station(), 'https://streams.example.com/live.mp3', {startPaused: true})).rejects.toThrow('cannot start a station paused'); + expect(spawnMock).not.toHaveBeenCalled(); + }); it('throws before spawning when no playback backend is available', async () => { commandExistsMock.mockReturnValue(false); const controller = new PlayerController(() => settings()); @@ -89,8 +353,8 @@ 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', + it.each(['win32', 'freebsd', 'openbsd', 'netbsd', 'android', 'haiku', 'sunos', 'aix'] as const)( + 'explains unsupported AirPlay environments on %s', async platform => { const controller = new PlayerController( () => settings({preferredBackend: 'airplay'}), @@ -98,7 +362,7 @@ describe('PlayerController lifecycle', () => { ); 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.' + 'AirPlay output is experimental on macOS and native Linux and is not supported in this environment.' ); expect(spawnMock).not.toHaveBeenCalled(); } @@ -131,7 +395,7 @@ describe('PlayerController lifecycle', () => { expect(spawnMock).toHaveBeenCalledWith( 'ffplay', - ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', '35', '-autoexit', 'https://streams.example.com/live.mp3'], + ['-nodisp', '-hide_banner', '-loglevel', 'error', '-stats', ...ffmpegInputArguments('https://streams.example.com/live.mp3'), '-volume', '35', '-autoexit', 'https://streams.example.com/live.mp3'], {stdio: ['pipe', 'pipe', 'pipe']} ); expect(controller.getState()).toMatchObject({ @@ -177,7 +441,7 @@ describe('PlayerController lifecycle', () => { expect(spawnMock).toHaveBeenCalledWith( 'cvlc', - ['--intf', 'dummy', '--no-video', '--quiet', '--play-and-exit', '--gain=0.80', 'https://streams.example.com/live.mp3'], + ['--intf', 'dummy', '--no-video', '--no-quiet', '--verbose=2', '--no-color', '--play-and-exit', '--gain=0.80', 'https://streams.example.com/live.mp3'], {stdio: ['pipe', 'pipe', 'pipe']} ); expect(controller.getState()).toMatchObject({backend: 'vlc', state: 'playing', ready: true}); @@ -210,12 +474,25 @@ describe('PlayerController lifecycle', () => { expect(controller.getState()).toMatchObject({state: 'stopped', ready: false}); }); + it('cannot resurrect AirPlay playback when Stop interrupts receiver discovery', async () => { + let finish!: (devices: AirPlayDevice[]) => void; + discoverAirPlayDevicesMock.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'}), {platform: 'darwin', arch: 'arm64', env: {}}); + setDetectedBackends(controller, ['airplay']); + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => Boolean(finish)); + await controller.stop(); finish([airPlayDevice()]); + await expect(playing).rejects.toMatchObject({name: 'AbortError'}); + expect(spawnMock).not.toHaveBeenCalled(); + expect(controller.getState()).toMatchObject({state: 'stopped', ready: false}); + }); + it('starts the AirPlay worker and forwards passcodes', async () => { commandExistsMock.mockImplementation(command => ['ffmpeg', 'dns-sd'].includes(command)); discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); const child = fakeChildProcess(); spawnMock.mockReturnValue(child as never); - const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'})); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'}), {platform: 'darwin', arch: 'arm64', env: {}}); setDetectedBackends(controller, ['airplay']); const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); @@ -236,7 +513,7 @@ describe('PlayerController lifecycle', () => { discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); const child = fakeChildProcess(); spawnMock.mockReturnValue(child as never); - const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'})); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'}), {platform: 'darwin', arch: 'arm64', env: {}}); setDetectedBackends(controller, ['airplay']); const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); @@ -280,13 +557,51 @@ describe('PlayerController lifecycle', () => { }); }); + it.each([ + 'The receiver rejected the AirPlay code or password.', + 'The AirPlay receiver disconnected. Select the receiver again to retry.' + ])('retains an actionable worker failure after its cleanup exit: %s', async message => { + discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: airPlayDevice().id}), {platform: 'darwin', arch: 'arm64', env: {}}); + setDetectedBackends(controller, ['airplay']); + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.stdout.emit('data', '{"type":"ready"}\n'); + await playing; + + child.stdout.emit('data', `${JSON.stringify({type: 'error', message})}\n`); + expect(controller.getState()).toMatchObject({state: 'error', message, ready: false}); + child.emit('exit', 1, null); + + expect(controller.getState()).toMatchObject({backend: 'airplay', state: 'error', message, ready: false}); + expect(controller.diagnostics().active).toBe(false); + expect((await controller.submitAirPlayPasscode('1234')).ok).toBe(false); + }); + + it('reports an unexpected AirPlay exit when no worker error was provided', async () => { + discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: airPlayDevice().id}), {platform: 'darwin', arch: 'arm64', env: {}}); + setDetectedBackends(controller, ['airplay']); + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.stdout.emit('data', '{"type":"ready"}\n'); + await playing; + child.emit('exit', 7, null); + + expect(controller.getState()).toMatchObject({backend: 'airplay', state: 'error', message: 'player exited with code 7', ready: false}); + }); + it('reuses a successful AirPlay passcode for later sessions in memory', async () => { commandExistsMock.mockImplementation(command => ['ffmpeg', 'dns-sd'].includes(command)); discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); const firstChild = fakeChildProcess(); const secondChild = fakeChildProcess(); spawnMock.mockReturnValueOnce(firstChild as never).mockReturnValueOnce(secondChild as never); - const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'})); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'}), {platform: 'darwin', arch: 'arm64', env: {}}); setDetectedBackends(controller, ['airplay']); const firstPlay = controller.play(station(), 'https://streams.example.com/live.mp3'); @@ -310,8 +625,8 @@ describe('PlayerController lifecycle', () => { expect(controller.getState()).toMatchObject({backend: 'airplay', state: 'playing', ready: true}); }); - it('reports when an AirPlay passcode is submitted without active AirPlay playback', () => { - const controller = new PlayerController(() => settings()); + it.each(['darwin', 'linux'] as const)('reports an inactive code prompt on enabled %s AirPlay', platform => { + const controller = new PlayerController(() => settings({linuxAirPlayEnabled: platform === 'linux'}), {platform, arch: 'x64', env: {}}); expect(controller.submitAirPlayPasscode('1234')).toMatchObject({ ok: false, @@ -322,7 +637,7 @@ describe('PlayerController lifecycle', () => { it('requires an explicit AirPlay receiver before spawning the worker', async () => { commandExistsMock.mockImplementation(command => ['ffmpeg', 'dns-sd'].includes(command)); discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); - const controller = new PlayerController(() => settings({preferredBackend: 'airplay'})); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay'}), {platform: 'darwin', arch: 'arm64', env: {}}); setDetectedBackends(controller, ['airplay']); await controller.play(station(), 'https://streams.example.com/live.mp3').then( @@ -341,7 +656,7 @@ describe('PlayerController lifecycle', () => { it('fails clearly when the saved AirPlay receiver is not visible', async () => { commandExistsMock.mockImplementation(command => ['ffmpeg', 'dns-sd'].includes(command)); discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); - const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: 'missing'})); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: 'missing'}), {platform: 'darwin', arch: 'arm64', env: {}}); setDetectedBackends(controller, ['airplay']); await controller.play(station(), 'https://streams.example.com/live.mp3').then( @@ -360,7 +675,7 @@ describe('PlayerController lifecycle', () => { it('does not try to AirPlay to the same Mac', async () => { commandExistsMock.mockImplementation(command => ['ffmpeg', 'dns-sd'].includes(command)); discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice({local: true, name: 'Neal’s MacBook Pro'})]); - const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'})); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'}), {platform: 'darwin', arch: 'arm64', env: {}}); setDetectedBackends(controller, ['airplay']); await expect(controller.play(station(), 'https://streams.example.com/live.mp3')).rejects.toThrow( @@ -375,7 +690,7 @@ describe('PlayerController lifecycle', () => { discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); const child = fakeChildProcess(); spawnMock.mockReturnValue(child as never); - const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'})); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'}), {platform: 'darwin', arch: 'arm64', env: {}}); setDetectedBackends(controller, ['airplay']); const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); @@ -402,7 +717,7 @@ describe('PlayerController lifecycle', () => { discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); const child = fakeChildProcess(); spawnMock.mockReturnValue(child as never); - const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office', tuneTimeoutSeconds: 0.01})); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office', tuneTimeoutSeconds: 0.01}), {platform: 'darwin', arch: 'arm64', env: {}}); setDetectedBackends(controller, ['airplay']); const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); @@ -772,6 +1087,7 @@ type FakeMpvIpc = { volume: () => number; muted: () => boolean; volumeCommands: () => number[]; + emitEvent: (event: object) => void; }; function expectFakeMpv(mpv: FakeMpvIpc | null): FakeMpvIpc { @@ -809,7 +1125,7 @@ function setDetectedBackends(controller: PlayerController, backends: string[]): (controller as unknown as {availableBackends: string[]}).availableBackends = backends; } -function fakeChildProcess(): EventEmitter & { +function fakeChildProcess(audioStarted = true): EventEmitter & { stdin: {write: ReturnType}; stdout: EventEmitter; stderr: EventEmitter; @@ -826,6 +1142,7 @@ function fakeChildProcess(): EventEmitter & { child.stdin = {write: vi.fn()}; child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); + if (audioStarted) setTimeout(() => child.stderr.emit('data', Buffer.from(" 0.01 M-A: 0.000 fd= 0 aq= 16KB vq= 0KB sq= 0B\r\nmain audio output debug: output 's16l' 44100 Hz Stereo\nmain input debug: Decoder wait done in 0 ms\n")), 0); child.killed = false; child.kill = vi.fn(() => { child.killed = true; @@ -849,7 +1166,7 @@ function mpvIpcPath(args: unknown): string { function fakeMpvIpc( path: string, - options: {audioStarted?: boolean; volumeError?: string; volumeResponseDelayMs?: number; muted?: boolean} = {} + options: {audioStarted?: boolean; timePosUnavailable?: boolean; audioProgress?: () => number; duration?: number; seekable?: boolean; volumeError?: string; volumeResponseDelayMs?: number; muted?: boolean; reloadError?: string} = {} ): FakeMpvIpc { if (existsSync(path)) { unlinkSync(path); @@ -862,8 +1179,15 @@ function fakeMpvIpc( let connectionCount = 0; const volumeCommands: number[] = []; const loadedUrls: string[] = []; + const observed = new Map>(); + const publishPause = () => { + for (const [socket, properties] of observed) for (const [id, name] of properties) if (name === 'pause' && !socket.destroyed) socket.write(`${JSON.stringify({event: 'property-change', id, name, data: paused})}\n`); + }; const server = createServer(socket => { connectionCount += 1; + const properties = new Map(); + observed.set(socket, properties); + socket.once('close', () => observed.delete(socket)); let buffer = ''; socket.on('error', () => undefined); socket.on('data', chunk => { @@ -882,20 +1206,28 @@ function fakeMpvIpc( let data: unknown = null; let error = 'success'; let responseDelayMs = 0; - if (command[0] === 'get_property' && command[1] === 'path') { + if (command[0] === 'observe_property') { + properties.set(Number(command[1]), String(command[2])); + socket.write(`${JSON.stringify({event: 'property-change', id: command[1], name: command[2], data: command[2] === 'pause' ? paused : command[2] === 'metadata' ? {} : false})}\n`); + } else if (command[0] === 'get_property' && command[1] === 'path') { data = 'https://streams.example.com/live.mp3'; } else if (command[0] === 'get_property' && command[1] === 'pause') { data = paused; } else if (command[0] === 'get_property' && command[1] === 'metadata') { data = {}; + } else if (command[0] === 'get_property' && command[1] === 'duration') { + data = options.duration ?? null; + } else if (command[0] === 'get_property' && command[1] === 'seekable') { + data = options.seekable ?? null; } else if (command[0] === 'get_property' && command[1] === 'time-pos') { - data = audioStarted ? 12 : null; + data = audioStarted && !options.timePosUnavailable ? options.audioProgress?.() ?? 12 : null; } else if (command[0] === 'get_property' && command[1] === 'audio-pts') { - data = audioStarted ? 12 : null; + data = audioStarted ? options.audioProgress?.() ?? 12 : null; } else if (command[0] === 'cycle' && command[1] === 'pause') { paused = !paused; } else if (command[0] === 'loadfile' && typeof command[1] === 'string') { loadedUrls.push(command[1]); + if (options.reloadError) { audioStarted = false; socket.write(`${JSON.stringify({event: 'end-file', reason: 'error', error: options.reloadError})}\n`); } } else if (command[0] === 'set_property' && command[1] === 'pause') { paused = Boolean(command[2]); } else if (command[0] === 'set_property' && command[1] === 'volume') { @@ -932,6 +1264,7 @@ function fakeMpvIpc( paused: () => paused, setPaused: next => { paused = next; + publishPause(); }, setAudioStarted: next => { audioStarted = next; @@ -939,7 +1272,8 @@ function fakeMpvIpc( loadedUrls: () => [...loadedUrls], volume: () => volume, muted: () => muted, - volumeCommands: () => [...volumeCommands] + volumeCommands: () => [...volumeCommands], + emitEvent: event => { for (const socket of observed.keys()) if (!socket.destroyed) socket.write(`${JSON.stringify(event)}\n`); } }; } @@ -967,3 +1301,83 @@ async function waitUntil(predicate: () => boolean): Promise { throw new Error('Timed out waiting for condition.'); } + +describe('Linux AirPlay per-session output consent', () => { + it('keeps persisted AirPlay output local until a receiver is explicitly selected', async () => { + vi.useFakeTimers(); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({linuxAirPlayEnabled: true, preferredBackend: 'airplay', preferredAirPlayDevice: airPlayDevice().id}), {platform: 'linux', arch: 'x64', env: {}}); + setDetectedBackends(controller, ['ffplay', 'airplay']); + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await vi.advanceTimersByTimeAsync(1000); + await playing; + expect(controller.getState()).toMatchObject({backend: 'ffplay', state: 'playing'}); + expect(controller.diagnostics().preferredBackend).toBe('auto'); + expect(discoverAirPlayDevicesMock).not.toHaveBeenCalled(); + expect(spawnMock.mock.calls[0]?.[1]).toContain('-nodisp'); + await controller.stop(); + }); + + it('requires explicit discovered identity and supports passcodes, retuning, and local output', async () => { + const saved = settings({linuxAirPlayEnabled: true, preferredBackend: 'airplay', preferredAirPlayDevice: airPlayDevice().id}); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); + const controller = new PlayerController(() => saved, {platform: 'linux', arch: 'x64', env: {}}); + setDetectedBackends(controller, ['airplay']); + expect(controller.selectAirPlayDevice(airPlayDevice().id)).toBe(false); + await controller.refreshAirPlayDevices(); + expect(controller.airPlaySelectionRequired()).toBe(true); + expect(controller.selectAirPlayDevice(airPlayDevice().id)).toBe(true); + expect(controller.airPlaySelectionRequired()).toBe(false); + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.stdout.emit('data', '{"type":"password-required"}\n'); + await playing; + expect(controller.submitAirPlayPasscode('1234')).toMatchObject({ok: true}); + child.stdout.emit('data', '{"type":"ready"}\n'); + const retuned = controller.play({...station(), name: 'Next'}, 'https://streams.example.com/next.mp3'); + await waitUntil(() => child.stdin.write.mock.calls.some(call => String(call[0]).includes('"retune"'))); + child.stdout.emit('data', '{"type":"retuned"}\n'); + await retuned; + expect(controller.getState()).toMatchObject({backend: 'airplay', state: 'playing', stationName: 'Next'}); + expect(saved).not.toHaveProperty('passcode'); + await controller.stop(); + controller.selectAirPlayDevice(null); + expect(controller.airPlaySelectionRequired()).toBe(true); + expect(controller.diagnostics().preferredBackend).toBe('auto'); + const nextProcess = new PlayerController(() => saved, {platform: 'linux', arch: 'x64', env: {}}); + expect(nextProcess.airPlaySelectionRequired()).toBe(true); + }); + + it('rejects Linux discovery failure without starting a worker', async () => { + const controller = new PlayerController(() => settings({linuxAirPlayEnabled: true, preferredBackend: 'airplay', preferredAirPlayDevice: airPlayDevice().id}), {platform: 'linux', arch: 'x64', env: {}}); + discoverAirPlayDevicesMock.mockResolvedValueOnce([airPlayDevice()]).mockRejectedValueOnce(new Error('The Avahi service is not ready.')); + setDetectedBackends(controller, ['airplay']); + await controller.refreshAirPlayDevices(); + controller.selectAirPlayDevice(airPlayDevice().id); + await expect(controller.play(station(), 'https://streams.example.com/live.mp3')).rejects.toThrow('Avahi service is not ready'); + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); + +describe('Linux AirPlay install consent', () => { + it.each([undefined, false])('blocks discovery, cached selection and codes before opt-in=%s, preserving local output', async linuxAirPlayEnabled => { + vi.useFakeTimers(); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({linuxAirPlayEnabled, preferredBackend: 'airplay', preferredAirPlayDevice: airPlayDevice().id}), {platform: 'linux', arch: 'x64', env: {}}); + setDetectedBackends(controller, ['ffplay', 'airplay']); + expect(controller.detectedBackends()).toEqual(['ffplay']); + await expect(controller.refreshAirPlayDevices()).rejects.toThrow('setup --airplay'); + expect(controller.selectAirPlayDevice(airPlayDevice().id)).toBe(false); + expect(controller.submitAirPlayPasscode('1234')).toMatchObject({ok: false, message: expect.stringContaining('disabled')}); + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await vi.advanceTimersByTimeAsync(1000); await playing; + expect(controller.getState().backend).toBe('ffplay'); + expect(discoverAirPlayDevicesMock).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledOnce(); + await controller.stop(); + }); +}); diff --git a/src/player/player-controller.ts b/src/player/player-controller.ts index 68d301c..8b4012f 100644 --- a/src/player/player-controller.ts +++ b/src/player/player-controller.ts @@ -3,14 +3,17 @@ import {existsSync, unlinkSync} from 'node:fs'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; import type {AirPlayDevice, AppSettings, IcyNowPlaying, PlaybackDiagnostics, PlaybackState, Station} from '../types.js'; -import {airPlayMacOSOnlyMessage, detectPlaybackBackends, ffplayLimitedControlsMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, vlcLimitedControlsMessage} from './backend-install.js'; +import {airPlayLocalDeviceLabel, detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage} from './backend-install.js'; import {resolveCommand} from '../platform/executables.js'; +import {airPlayPolicy, airPlaySetupRequiredMessage} from './airplay-policy.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'; +import {desktopMediaPolicy} from '../platform/desktop-media.js'; +import {canRetryPlainHttp, ffmpegCertificateFailure, ffmpegInputArguments, ffmpegInputFailure} from './ffmpeg-input.js'; export type PlayerEvent = (state: PlaybackState) => void; export type MetadataEvent = (metadata: IcyNowPlaying) => void; @@ -34,6 +37,7 @@ type PlayerExit = { signal: NodeJS.Signals | null; diagnostic: string; spawnError?: boolean; + transportFailure?: boolean; }; export class PlaybackOutputError extends Error { @@ -68,11 +72,13 @@ export class PlayerController { private airPlayPasscodes = new Map(); private currentAirPlayDevice: AirPlayDevice | null = null; private currentAirPlayDeviceId: string | null = null; + private selectedAirPlayDeviceId: string | null = null; private pendingAirPlayPasscode: string | null = null; private currentMpvMediaTitle: string | null = null; private metadataPollInFlight = false; private playbackStatePollInFlight = false; private mpvLiveRetuneInFlight = false; + private mpvRetuneFailure: string | undefined; private pendingMpvVolume: number | null = null; private mpvVolumeFlush: Promise | null = null; private confirmedMpvVolume = 70; @@ -80,6 +86,15 @@ export class PlayerController { private playbackSessionId = 0; private stopPromise: Promise | null = null; private lastPlayerExit: PlayerExit | null = null; + private activePlayerDiagnostic = ''; + private compatibleDesktopMedia = true; + private playRequestId = 0; + private mpvObserved = false; + private stationIsRadio = false; + private mpvMediaIsLive = false; + private mpvProgress: number | null = null; + private mpvStallTimer: NodeJS.Timeout | undefined; + private fallbackAudioStarted = false; constructor( private readonly getSettings: () => AppSettings, @@ -101,11 +116,24 @@ export class PlayerController { return {...this.state}; } + desktopMediaCompatible(): boolean { return this.compatibleDesktopMedia; } + + /** Retry diagnosed transport failures and interrupted live radio, never + * completed recordings, authentication or device/decoder initialization. */ + recoverableFailure(): boolean { + const exit = this.lastPlayerExit; + if (!exit || exit.spawnError || exit.signal || exit.code === 0 || !/^https?:\/\//i.test(this.state.streamUrl ?? '')) return false; + const detail = `${exit.diagnostic} ${this.state.message ?? ''}`; + if (ffmpegCertificateFailure(detail) || ffmpegInputFailure(detail)) return false; + return (exit.transportFailure === true || /timed? out|connection (?:reset|refused|closed)|network (?:is )?unreachable|temporary failure|unexpected (?:end|eof)|http (?:error )?50[234]/i.test(detail)) + && !/password|passcode|authentication|unauthori[sz]ed|forbidden|certificate|tls.*verif|audio output|audio device|decoder|unknown option/i.test(detail); + } + diagnostics(): PlaybackDiagnostics { return { backend: this.state.backend, availableBackends: this.detectedBackends(), - preferredBackend: this.getSettings().preferredBackend, + preferredBackend: this.effectivePreferredBackend(), active: Boolean(this.process), streamUrl: this.state.streamUrl, stationName: this.state.stationName, @@ -117,24 +145,39 @@ export class PlayerController { } detectedBackends(): string[] { - return [...(this.availableBackends ?? [])]; + return [...(this.availableBackends ?? [])].filter(backend => backend !== 'airplay' || this.airPlayPolicy().enabled); } refreshDetectedBackends(): string[] { - this.availableBackends = detectPlaybackBackends({platform: this.runtime.platform}); + this.availableBackends = detectPlaybackBackends({settings: this.getSettings(), platform: this.runtime.platform, env: this.runtime.env}); return this.detectedBackends(); } - async play(station: Station, url: string): Promise { + async play(station: Station, url: string, options: {startPaused?: boolean; signal?: AbortSignal} = {}): Promise { + const requestId = ++this.playRequestId; + const assertCurrent = () => { + options.signal?.throwIfAborted(); + if (requestId !== this.playRequestId) throw new DOMException('Playback request was superseded.', 'AbortError'); + }; + assertCurrent(); + const abort = () => { if (requestId === this.playRequestId) void this.stop().catch(() => undefined); }; + options.signal?.addEventListener('abort', abort, {once: true}); + try { await this.playTarget(station, url, options, assertCurrent); assertCurrent(); } + finally { options.signal?.removeEventListener('abort', abort); } + } + + private async playTarget(station: Station, url: string, options: {startPaused?: boolean}, assertCurrent: () => void): Promise { const target = safeMediaTarget(url); if (!target) { throw new Error(`Station ${station.name} returned an unsupported stream URL.`); } url = target; + if (this.stopPromise) { await this.stopPromise; assertCurrent(); } const backend = this.selectBackend(); + if (options.startPaused && backend !== 'mpv') throw new PlaybackOutputError('This output cannot start a station paused.'); if (!backend) { - await this.stop(); + await this.stopPlayback(); throw new PlaybackOutputError(this.playbackUnavailableMessage()); } @@ -142,13 +185,16 @@ export class PlayerController { const activeDevice = this.activeAirPlayDeviceForRetune(); if (activeDevice) { await this.retuneAirPlay(url, station.name, activeDevice); + assertCurrent(); return; } let device: AirPlayDevice; try { device = await this.resolveAirPlayDevice(); + assertCurrent(); } catch (error) { + assertCurrent(); const message = error instanceof Error ? error.message : 'Could not resolve AirPlay receiver.'; this.setState({...this.state, backend, state: 'error', message, ready: false}); throw error; @@ -156,10 +202,12 @@ export class PlayerController { if (this.canRetuneAirPlay(device)) { await this.retuneAirPlay(url, station.name, device); + assertCurrent(); return; } - await this.stop(); + await this.stopPlayback(); + assertCurrent(); this.backend = backend; this.currentAirPlayDevice = device; this.currentAirPlayDeviceId = device.id; @@ -175,6 +223,7 @@ export class PlayerController { }); this.setState({...this.state, airPlayDeviceName: device.name}); const result = await this.playWithAirPlay(url, station.name, device); + assertCurrent(); if (result === 'password-required') { if (this.pendingAirPlayPasscode) { return; @@ -206,8 +255,12 @@ export class PlayerController { return; } - await this.stop(); + await this.stopPlayback(); + assertCurrent(); this.backend = backend; + this.stationIsRadio = station.provider !== 'playlist'; + this.mpvMediaIsLive = false; + this.mpvProgress = null; this.setState({ backend, state: 'loading', @@ -219,12 +272,15 @@ export class PlayerController { ready: false }); if (backend === 'mpv') { - this.playWithMpv(url, station.name); + this.playWithMpv(url, station.name, undefined, options.startPaused); try { await this.waitForReady(backend); + assertCurrent(); } catch (error) { + assertCurrent(); if (!this.shouldRetryMpvWithAlsa(error)) throw error; - await this.stop(); + await this.stopPlayback(); + assertCurrent(); this.backend = backend; this.setState({ backend, @@ -236,20 +292,39 @@ export class PlayerController { streamUrl: url, ready: false }); - this.playWithMpv(url, station.name, 'alsa,'); + this.playWithMpv(url, station.name, 'alsa,', options.startPaused); await this.waitForReady(backend); + assertCurrent(); } } else if (backend === 'ffplay') { - this.playWithFfplay(url); - await this.waitForReady(backend); + const closed = this.playWithFfplay(url); + try { await this.waitForReady(backend); } + catch (error) { + assertCurrent(); + if (this.process || this.lastPlayerExit?.spawnError || this.lastPlayerExit?.signal) throw error; + // close follows stderr drain; exit can precede the diagnostic that + // distinguishes an unused TLS option from an actual network failure. + await Promise.race([closed, delay(250)]); + assertCurrent(); + const inputFailure = ffmpegInputFailure(this.lastPlayerExit?.diagnostic ?? ''); + if (inputFailure) throw new PlaybackOutputError(inputFailure); + if (this.fallbackAudioStarted || !canRetryPlainHttp(url, this.lastPlayerExit?.diagnostic ?? '')) throw error; + await this.stopPlayback(); + assertCurrent(); + this.setState({...this.state, state: 'loading', ready: false, message: `Opening ${station.name}`}); + this.playWithFfplay(url, true); + await this.waitForReady(backend); + } + assertCurrent(); } else if (backend === 'vlc') { this.playWithVlc(url); await this.waitForReady(backend); + assertCurrent(); } this.setState({ backend, - state: 'playing', + state: options.startPaused ? 'paused' : 'playing', message: station.name, volume: this.getSettings().volume, muted: false, @@ -314,26 +389,45 @@ export class PlayerController { if (this.mpvLiveRetuneInFlight) return {ok: true, message: 'Reconnecting at the live broadcast.'}; const url = this.state.streamUrl ? safeMediaTarget(this.state.streamUrl) : null; if (!url) return {ok: false, message: 'The paused station no longer has a usable stream URL.'}; + const client = this.mpvIpcClient; + const session = this.mpvSessionId; + if (!client) return {ok: false, message: 'mpv is no longer connected.'}; this.mpvLiveRetuneInFlight = true; + this.mpvRetuneFailure = undefined; try { // Reloading the same URL discards mpv's delayed live buffer and opens a // fresh stream connection. This keeps radio pause/play semantics at the // live edge instead of behaving like time-shifted on-demand audio. - await this.sendMpv({command: ['loadfile', url, 'replace']}); - await this.sendMpv({command: ['set_property', 'pause', false]}); + await client.query({command: ['loadfile', url, 'replace']}); + if (session !== this.mpvSessionId) return {ok: false, message: 'Playback changed during resume.'}; + await client.query({command: ['set_property', 'pause', false]}); + if (session !== this.mpvSessionId) return {ok: false, message: 'Playback changed during resume.'}; + if (this.mpvRetuneFailure) throw new Error(this.mpvRetuneFailure); + await this.waitForReady('mpv'); + const idle = await client.query({command: ['get_property', 'idle-active']}).catch(() => null); + if (this.mpvRetuneFailure || idle === true) throw new Error(this.mpvRetuneFailure ?? 'The station ended while reconnecting.'); } catch { + if (session === this.mpvSessionId) { + const terminal: PlaybackState = {...this.state, state: 'error', ready: false, buffering: false, message: this.mpvRetuneFailure ?? 'mpv could not reconnect the paused station at the live edge.'}; + void this.retireMpvStream(terminal).catch(() => undefined); + } return {ok: false, message: 'mpv could not reconnect the paused station at the live edge.'}; } finally { - this.mpvLiveRetuneInFlight = false; + if (session === this.mpvSessionId) this.mpvLiveRetuneInFlight = false; } + if (session !== this.mpvSessionId) return {ok: false, message: 'Playback changed during resume.'}; this.currentMpvMediaTitle = cleanMediaTitle(this.state.stationName ?? '') ?? 'RadioCLI'; this.emitMetadata({updatedAt: new Date().toISOString()}); this.setState({...this.state, state: 'playing', ready: true, startedAt: new Date().toISOString()}); return {ok: true, message: 'Reconnected at the live broadcast.'}; } - setVolume(volume: number): Promise { + setVolume(volume: number, options: {allowStopped?: boolean} = {}): Promise { const clamped = clampVolume(volume); + if (options.allowStopped && !this.process && ['idle', 'stopped'].includes(this.state.state)) { + this.setState({...this.state, volume: clamped}); + return Promise.resolve({ok: true}); + } const unsupported = this.unsupportedFfplayControl(); if (unsupported) { return Promise.resolve(unsupported); @@ -378,7 +472,11 @@ export class PlayerController { return {ok: true}; } - async setMuted(muted: boolean): Promise { + async setMuted(muted: boolean, options: {allowStopped?: boolean} = {}): Promise { + if (options.allowStopped && !this.process && ['idle', 'stopped'].includes(this.state.state)) { + this.setState({...this.state, muted}); + return {ok: true}; + } const unsupported = this.unsupportedFfplayControl(); if (unsupported) return this.state.muted === muted ? {ok: true} : unsupported; @@ -404,6 +502,11 @@ export class PlayerController { } stop(): Promise { + this.playRequestId++; + return this.stopPlayback(); + } + + private stopPlayback(): Promise { if (this.stopPromise) return this.stopPromise; const operation = this.performStop(); this.stopPromise = operation.finally(() => { @@ -414,6 +517,7 @@ export class PlayerController { private async performStop(): Promise { const child = this.process; + this.mpvMediaIsLive = false; const startupInProgress = this.state.state === 'loading' && !this.state.ready; this.stopMpvPolling(); this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.')); @@ -448,7 +552,11 @@ export class PlayerController { }); } - submitAirPlayPasscode(code: string): PlaybackControlResult { + airPlayPolicy() { return airPlayPolicy(this.getSettings(), this.runtime); } + + submitAirPlayPasscode(code: string): PlaybackControlResult | Promise { + const policy = this.airPlayPolicy(); + if (!policy.enabled) return {ok: false, message: policy.message}; if (this.backend !== 'airplay') { return {ok: false, message: 'No active AirPlay playback is waiting for a code.'}; } @@ -471,7 +579,9 @@ export class PlayerController { } async refreshAirPlayDevices(): Promise { - this.availableAirPlayDevices = await discoverAirPlayDevices({platform: this.runtime.platform}); + const policy = this.airPlayPolicy(); + if (!policy.enabled) throw new PlaybackOutputError(policy.message); + this.availableAirPlayDevices = await discoverAirPlayDevices({platform: this.runtime.platform, env: this.runtime.env}); return [...this.availableAirPlayDevices]; } @@ -479,9 +589,26 @@ export class PlayerController { return [...this.availableAirPlayDevices]; } + selectAirPlayDevice(deviceId: string | null): boolean | Promise { + if (deviceId !== null && !this.airPlayPolicy().enabled) return false; + if (deviceId !== null && !this.availableAirPlayDevices.some(device => device.id === deviceId && !device.local)) return false; + this.selectedAirPlayDeviceId = deviceId; + return true; + } + + airPlaySelectionRequired(): boolean { + return this.runtime.platform === 'linux' && (!this.selectedAirPlayDeviceId || this.selectedAirPlayDeviceId !== this.getSettings().preferredAirPlayDevice); + } + + private effectivePreferredBackend(): AppSettings['preferredBackend'] { + // A persisted receiver is a convenience for the picker, never consent to + // open a Linux network output again in a new process. + return this.getSettings().preferredBackend === 'airplay' && (this.runtime.platform === 'linux' && !this.airPlayPolicy().enabled || this.airPlaySelectionRequired()) ? 'auto' : this.getSettings().preferredBackend; + } + private selectBackend(): 'mpv' | 'ffplay' | 'vlc' | 'airplay' | null { - const preferred = this.getSettings().preferredBackend; - const backends = this.availableBackends ?? this.refreshDetectedBackends(); + const preferred = this.effectivePreferredBackend(); + const backends = this.availableBackends ? this.detectedBackends() : this.refreshDetectedBackends(); if (preferred === 'mpv') { return backends.includes('mpv') ? 'mpv' : null; } @@ -516,10 +643,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}`; + const policy = this.airPlayPolicy(); + if (!policy.enabled) return policy.message; + return `${this.runtime.platform === 'linux' ? airPlaySetupRequiredMessage : 'AirPlay is not ready on this install. Run radiocli doctor.'} ${airPlaySenderHealth().message}`; } if (preferred === 'mpv' || preferred === 'ffplay') { @@ -529,7 +655,7 @@ export class PlayerController { return `No playback backend found. ${playbackBackendInstallHint()}`; } - private playWithMpv(url: string, initialTitle: string, recoveredAudioOutput?: string): void { + private playWithMpv(url: string, initialTitle: string, recoveredAudioOutput?: string, startPaused = false): void { this.playbackSessionId += 1; this.ipcPath = mpvIpcPath(); this.mpvIpcClient = new MpvIpcClient(this.ipcPath); @@ -537,12 +663,22 @@ export class PlayerController { this.confirmedMpvVolume = clampVolume(this.getSettings().volume); this.pendingMpvVolume = null; this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI'; + const desktopMedia = desktopMediaPolicy(this.getSettings(), this.runtime); + this.compatibleDesktopMedia = desktopMedia.enabled; + // Release the application service before an incompatible mpv process can + // autoload its own service, including config changes made between tunes. + this.setState({...this.state}); this.process = spawn( resolveCommand('mpv') ?? 'mpv', [ '--no-video', '--msg-level=all=warn', '--force-window=no', + // Several supported mpv/FFmpeg builds default to accepting any certificate. + // Apply to every input, including HTTP redirects and nested playlists. + '--tls-verify=yes', + ...(startPaused ? ['--pause=yes'] : []), + ...desktopMedia.mpvArgs, ...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT ? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] : []), @@ -558,12 +694,13 @@ export class PlayerController { this.wireProcess(); } - private playWithFfplay(url: string): void { + private playWithFfplay(url: string, plainHttp = false): Promise { this.playbackSessionId += 1; - this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], { + this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-stats', ...ffmpegInputArguments(url, {plainHttp}), '-volume', String(this.getSettings().volume), '-autoexit', url], { stdio: ['pipe', 'pipe', 'pipe'] }); this.wireProcess(); + return new Promise(resolve => this.process!.once('close', resolve)); } private playWithVlc(url: string): void { @@ -574,7 +711,8 @@ export class PlayerController { const gain = (clampVolume(this.getSettings().volume) / 100).toFixed(2); this.process = spawn( binary, - ['--intf', 'dummy', '--no-video', '--quiet', '--play-and-exit', `--gain=${gain}`, url], + ['--intf', 'dummy', '--no-video', '--no-quiet', '--verbose=2', '--no-color', '--play-and-exit', + ...(this.runtime.env.RADIOCLI_VLC_AUDIO_OUTPUT ? [`--aout=${this.runtime.env.RADIOCLI_VLC_AUDIO_OUTPUT}`] : []), `--gain=${gain}`, url], {stdio: ['pipe', 'pipe', 'pipe']} ); this.wireProcess(); @@ -670,7 +808,7 @@ export class PlayerController { throw new PlaybackOutputError('Selected AirPlay receiver was not found. Refresh AirPlay receivers in Settings.'); } if (device.local) { - throw new PlaybackOutputError(`${device.name} is this Mac. Use Audio output: This device instead of AirPlay.`); + throw new PlaybackOutputError(`${device.name} is ${airPlayLocalDeviceLabel(this.runtime.platform)}. Use Audio output: This device instead of AirPlay.`); } return device; @@ -828,12 +966,36 @@ export class PlayerController { } this.lastPlayerExit = null; + this.activePlayerDiagnostic = ''; + this.fallbackAudioStarted = false; + let progressBuffer = ''; + let vlcAudioOutput = false; + let vlcDecoderStarted = false; let diagnostic = ''; + let exitRecord: PlayerExit | undefined; child.stdout.on('data', chunk => { diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk)); + if (this.process === child) this.activePlayerDiagnostic = diagnostic; }); child.stderr.on('data', chunk => { diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk)); + if (this.process === child) this.activePlayerDiagnostic = diagnostic; + if (backend === 'ffplay' && this.process === child && !this.fallbackAudioStarted) { + progressBuffer = appendPlayerDiagnostic(progressBuffer, String(chunk)); + const lines = progressBuffer.split(/[\r\n]/); + progressBuffer = lines.pop() ?? ''; + // ffplay's audio-master clock is nan until audio reaches its output. + // Explicit -stats keeps this signal even with error-only diagnostics. + this.fallbackAudioStarted = lines.some(line => /^\s*-?\d+(?:\.\d+)?\s+M-A:\s*-?\d+(?:\.\d+)?\s+/.test(line)); + } else if (backend === 'vlc' && this.process === child && !this.fallbackAudioStarted) { + // Keep the portable dummy interface. RC stdin opens a separate console + // on Windows, and a loopback RC port would expose unauthenticated control. + // Both output initialization and released decoder buffering are needed; + // input headers or a surviving process alone never establish readiness. + vlcAudioOutput ||= /main audio output debug: output '[^'\r\n]+' \d+ Hz/.test(diagnostic); + vlcDecoderStarted ||= /main input debug: Decoder wait done in \d+ ms/.test(diagnostic); + this.fallbackAudioStarted = vlcAudioOutput && vlcDecoderStarted; + } }); // Local players can write diagnostics indefinitely. Drain both pipes so a @@ -841,6 +1003,10 @@ export class PlayerController { child.stdout.resume?.(); child.stderr.resume?.(); + child.once('close', () => { + if (exitRecord && this.lastPlayerExit === exitRecord) exitRecord.diagnostic = diagnostic; + }); + child.on('error', error => { diagnostic = appendPlayerDiagnostic(diagnostic, error.message); if (this.process !== child) return; @@ -867,7 +1033,11 @@ export class PlayerController { child.on('exit', (code, signal) => { if (this.process === child) { - this.lastPlayerExit = {backend, code, signal, diagnostic}; + // Worker failures carry receiver/pairing guidance before cleanup exits. + // Keep that reason while still clearing the exited process and session. + const workerError = backend === 'airplay' && this.state.state === 'error' ? this.state.message || undefined : undefined; + const liveEof = backend === 'mpv' && this.mpvMediaIsLive && this.state.ready && code === 0 && !signal; + this.lastPlayerExit = exitRecord = {backend, code: liveEof ? 1 : code, signal, diagnostic, transportFailure: liveEof}; this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.')); if (this.backend === 'airplay') { this.currentAirPlayDevice = null; @@ -882,8 +1052,9 @@ export class PlayerController { this.setState({ ...this.state, backend: this.backend ?? 'none', - state: code === 0 || code === null ? 'stopped' : 'error', - message: code === 0 || code === null ? undefined : `player exited with code ${code}`, + state: workerError || liveEof || (code !== 0 && code !== null) ? 'error' : 'stopped', + message: workerError ?? (liveEof ? 'Live stream connection ended unexpectedly.' : code === 0 || code === null ? undefined : `player exited with code ${code}`), + buffering: false, ready: false }); } @@ -1009,6 +1180,7 @@ export class PlayerController { const isCurrent = () => !wasSuperseded() && this.process === child; while (Date.now() - started < timeoutMs) { + if (backend === 'mpv' && this.mpvLiveRetuneInFlight && this.mpvRetuneFailure) throw new Error(this.mpvRetuneFailure); if (wasSuperseded()) { throw new Error(`${backend} playback request was superseded.`); } @@ -1016,16 +1188,7 @@ export class PlayerController { throw this.playerExitedBeforeReady(backend); } - if (backend === 'ffplay' || backend === 'vlc') { - await waitForStartupWindow( - () => this.process === child ? child : null, - Math.min(500, timeoutMs), - () => this.playerExitedBeforeReady(backend) - ); - if (wasSuperseded()) throw new Error(`${backend} playback request was superseded.`); - if (!isCurrent()) throw this.playerExitedBeforeReady(backend); - return; - } + if ((backend === 'ffplay' || backend === 'vlc') && this.fallbackAudioStarted) return; if (mpvClient) { try { @@ -1045,8 +1208,15 @@ export class PlayerController { throw new Error(`${backend} playback request was superseded.`); } if (this.process !== child) throw this.playerExitedBeforeReady(backend); - await this.stop(); - throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`); + const requestId = this.playRequestId; + const diagnostic = this.activePlayerDiagnostic; + await this.stopPlayback(); + if (requestId !== this.playRequestId || wasSuperseded()) throw new DOMException('Playback request was superseded.', 'AbortError'); + const detail = concisePlayerDiagnostic(diagnostic); + const message = `Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.${detail ? ` ${detail}` : ''}`; + this.lastPlayerExit = {backend, code: 1, signal: null, diagnostic}; + this.setState({...this.state, state: 'error', ready: false, buffering: false, message}); + throw new Error(message); } private shouldRetryMpvWithAlsa(error: unknown): boolean { @@ -1063,6 +1233,8 @@ export class PlayerController { ? 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 inputFailure = backend === 'ffplay' ? ffmpegInputFailure(exit?.diagnostic ?? '') : undefined; + if (inputFailure) return new PlaybackOutputError(inputFailure); const message = `${backend} exited before the stream became ready (${reason})${diagnostic ? `: ${diagnostic}` : '.'}`; return isBackendInitializationFailure(backend, exit, diagnostic) ? new PlaybackOutputError(message) @@ -1075,11 +1247,57 @@ export class PlayerController { client.query({command: ['get_property', 'audio-pts']}).catch(() => null) ]); - return typeof timePos === 'number' || typeof audioPts === 'number'; + if (!Number.isFinite(timePos) && !Number.isFinite(audioPts)) return false; + const [duration, seekable] = await Promise.all([ + client.query({command: ['get_property', 'duration']}).catch(() => null), + client.query({command: ['get_property', 'seekable']}).catch(() => null) + ]); + if (client === this.mpvIpcClient) { + // Directory stations are live even when HLS exposes a finite window. + // For imported URLs, a known duration denotes a recording; nonseekable + // inputs without a duration can still be live radio. + this.mpvMediaIsLive = /^https?:\/\//i.test(this.state.streamUrl ?? '') && + (this.stationIsRadio || seekable === false && !(typeof duration === 'number' && Number.isFinite(duration) && duration > 0)); + } + return true; } private startMpvMetadataPolling(): void { this.stopMpvPolling(); + const client = this.mpvIpcClient; + const session = this.mpvSessionId; + this.mpvObserved = false; + client?.onEvent(event => { + if (client !== this.mpvIpcClient || session !== this.mpvSessionId) return; + if (event.event === 'end-file' && this.state.ready && ['eof', 'error'].includes(event.reason ?? '')) { + if (this.mpvLiveRetuneInFlight) { + this.mpvRetuneFailure = event.error ?? 'The station ended while reconnecting.'; + return; + } + const liveEof = event.reason === 'eof' && this.mpvMediaIsLive; + const terminal: PlaybackState = {...this.state, state: event.reason === 'error' || liveEof ? 'error' : 'stopped', ready: false, buffering: false, + message: event.reason === 'error' ? event.error ?? 'mpv could not continue the stream.' : liveEof ? 'Live stream connection ended unexpectedly.' : 'Stream ended.'}; + // User mpv configuration may keep an idle process alive after EOF. + // Retire it through the same bounded lifecycle as an explicit Stop. + void this.retireMpvStream(terminal, liveEof).catch(() => undefined); + return; + } + if (event.event !== 'property-change') return; + if (event.name === 'pause' && typeof event.data === 'boolean' && this.state.ready) { + void this.applyMpvPause(event.data).catch(() => undefined); + } else if (event.name === 'metadata' && event.data && typeof event.data === 'object') { + void this.applyMpvMetadata(event.data as Record, client, session); + } else if (event.name === 'paused-for-cache' && typeof event.data === 'boolean' && this.state.buffering !== event.data) { + this.setState({...this.state, buffering: event.data}); + } + }); + if (client) void Promise.all(['pause', 'metadata', 'paused-for-cache'].map((property, id) => client.observe(id + 1, property))) + .then(() => { + if (client !== this.mpvIpcClient || session !== this.mpvSessionId) return; + this.mpvObserved = true; + if (this.playbackStateTimer) clearInterval(this.playbackStateTimer); + this.playbackStateTimer = setInterval(() => { if (!this.playbackStatePollInFlight) void this.syncMpvPlaybackState(); }, 5000); + }).catch(() => undefined); this.metadataTimer = setInterval(() => { if (!this.metadataPollInFlight) void this.pollMpvMetadata(); }, 2500); @@ -1088,9 +1306,12 @@ export class PlayerController { }, 500); void this.pollMpvMetadata(); void this.syncMpvPlaybackState(); + this.updateMpvStallTimer(); } private stopMpvPolling(): void { + clearTimeout(this.mpvStallTimer); + this.mpvStallTimer = undefined; if (this.metadataTimer) { clearInterval(this.metadataTimer); this.metadataTimer = null; @@ -1102,29 +1323,53 @@ export class PlayerController { } } + private async retireMpvStream(terminal: PlaybackState, transportFailure = false): Promise { + const request = this.playRequestId; + const failure: PlayerExit | null = terminal.state === 'error' ? {backend: 'mpv', code: 1, signal: null, diagnostic: terminal.message ?? '', transportFailure} : null; + this.lastPlayerExit = failure; + this.stopMpvPolling(); + this.setState(terminal); + await this.stopPlayback(); + if (request === this.playRequestId) { this.lastPlayerExit = failure; this.setState(terminal); } + } + private async pollMpvMetadata(): Promise { if (this.backend !== 'mpv' || !this.process) { return; } + const client = this.mpvIpcClient; + const session = this.mpvSessionId; + if (!client) return; this.metadataPollInFlight = true; try { - const metadata = await this.queryMpv>({command: ['get_property', 'metadata']}).catch(() => null); - const elapsed = await this.queryMpv({command: ['get_property', 'time-pos']}).catch(() => null); - if (typeof elapsed === 'number') { + const metadata = this.mpvObserved ? null : await client.query>({command: ['get_property', 'metadata']}).catch(() => null); + let elapsed = await client.query({command: ['get_property', 'time-pos']}).catch(() => null); + if (!Number.isFinite(elapsed)) elapsed = await client.query({command: ['get_property', 'audio-pts']}).catch(() => null); + if (client !== this.mpvIpcClient || session !== this.mpvSessionId) return; + if (typeof elapsed === 'number' && Number.isFinite(elapsed)) { + if (elapsed !== this.mpvProgress) { + this.mpvProgress = elapsed; + clearTimeout(this.mpvStallTimer); + this.mpvStallTimer = undefined; + } this.setState({...this.state, elapsedSeconds: Math.floor(elapsed)}); } - const title = extractMpvTitle(metadata); - if (title) { - await this.setMpvMediaTitle(title); - this.emitMetadata({title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString()}); - } + if (metadata) await this.applyMpvMetadata(metadata, client, session); } finally { - this.metadataPollInFlight = false; + if (session === this.mpvSessionId) this.metadataPollInFlight = false; } } + private async applyMpvMetadata(metadata: Record, client: MpvIpcClient, session: number): Promise { + const title = extractMpvTitle(metadata); + if (!title) return; + await this.setMpvMediaTitle(title); + if (client !== this.mpvIpcClient || session !== this.mpvSessionId) return; + this.emitMetadata({title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString()}); + } + private async setMpvMediaTitle(title: string): Promise { const cleaned = cleanMediaTitle(title); if (!cleaned || cleaned === this.currentMpvMediaTitle) { @@ -1141,28 +1386,31 @@ export class PlayerController { } if (this.playbackStatePollInFlight) return false; + const client = this.mpvIpcClient; + const session = this.mpvSessionId; + if (!client) return false; this.playbackStatePollInFlight = true; try { - const paused = await this.queryMpv({command: ['get_property', 'pause']}).catch(() => null); - if (typeof paused !== 'boolean') { + const paused = await client.query({command: ['get_property', 'pause']}).catch(() => null); + if (typeof paused !== 'boolean' || session !== this.mpvSessionId || !this.state.ready) { return false; } - - if (!paused && this.state.state === 'paused' && !this.mpvLiveRetuneInFlight) { - return (await this.resumeMpvAtLiveEdge()).ok; - } - - const state = paused ? 'paused' : 'playing'; - if (this.state.state !== state) { - this.setState({...this.state, state}); - } - - return true; + return this.applyMpvPause(paused); } finally { - this.playbackStatePollInFlight = false; + if (session === this.mpvSessionId) this.playbackStatePollInFlight = false; } } + private async applyMpvPause(paused: boolean): Promise { + // Unpausing the decoder is only the first step of a live-edge reload. + // Publish audible playback after the new stream passes readiness checks. + if (this.mpvLiveRetuneInFlight) return true; + if (!paused && this.state.state === 'paused' && !this.mpvLiveRetuneInFlight) return (await this.resumeMpvAtLiveEdge()).ok; + const state = paused ? 'paused' : 'playing'; + if (this.state.state !== state) this.setState({...this.state, state}); + return true; + } + private emitMetadata(metadata: IcyNowPlaying): void { for (const listener of this.metadataListeners) { try { @@ -1179,6 +1427,11 @@ export class PlayerController { this.mpvIpcClient = null; this.pendingMpvVolume = null; this.mpvVolumeFlush = null; + this.metadataPollInFlight = false; + this.playbackStatePollInFlight = false; + this.mpvLiveRetuneInFlight = false; + this.mpvRetuneFailure = undefined; + this.mpvObserved = false; if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) { try { unlinkSync(this.ipcPath); @@ -1193,10 +1446,26 @@ export class PlayerController { private setState(state: PlaybackState): void { this.state = state; + this.updateMpvStallTimer(); for (const listener of this.listeners) { listener(state); } } + + private updateMpvStallTimer(): void { + if (!this.mpvMediaIsLive || this.backend !== 'mpv' || !this.state.ready || this.state.state !== 'playing' || this.mpvLiveRetuneInFlight) { + clearTimeout(this.mpvStallTimer); this.mpvStallTimer = undefined; return; + } + if (this.mpvStallTimer) return; + const session = this.mpvSessionId; + this.mpvStallTimer = setTimeout(() => { + this.mpvStallTimer = undefined; + if (session !== this.mpvSessionId || !this.mpvMediaIsLive || !this.state.ready || this.state.state !== 'playing') return; + void this.retireMpvStream({...this.state, state: 'error', ready: false, buffering: false, + message: 'Live stream timed out without audio progress.'}, true).catch(() => undefined); + }, Math.max(15_000, this.getSettings().tuneTimeoutSeconds * 1000)); + this.mpvStallTimer.unref(); + } } function airPlayWorkerPath(): string { @@ -1270,21 +1539,6 @@ export function extractMpvTitle(metadata: Record | null): string return undefined; } -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 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 diff --git a/src/player/radio-engine.test.ts b/src/player/radio-engine.test.ts new file mode 100644 index 0000000..d0979fa --- /dev/null +++ b/src/player/radio-engine.test.ts @@ -0,0 +1,145 @@ +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import type {IcyNowPlaying, PlaybackState, Station} from '../types.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import {PlayerController} from './player-controller.js'; +import {RadioEngine} from './radio-engine.js'; + +const roots: string[] = []; +const engines: RadioEngine[] = []; +afterEach(async () => { + for (const engine of engines.splice(0)) await engine.close(); + vi.useRealTimers(); + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); +const first: Station = {id: 'first', provider: 'playlist', name: 'First', tags: [], streamUrl: 'https://example.test/first'}; +const second: Station = {...first, id: 'second', name: 'Second', streamUrl: 'https://example.test/second'}; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'radiocli-engine-')); roots.push(root); + const store = new JsonLibraryStore(join(root, 'library.json')); + let state: PlaybackState = {backend: 'mpv', state: 'idle', volume: 70, muted: false, ready: false}; + const changes = new Set<(state: PlaybackState) => void>(); + const metadata = new Set<(metadata: IcyNowPlaying) => void>(); + const change = (updates: Partial) => { state = {...state, ...updates}; for (const listener of changes) listener(state); }; + const emitMetadata = (title: string) => { for (const listener of metadata) listener({title, updatedAt: new Date().toISOString()}); }; + const player = { + getState: () => ({...state}), + onChange: (listener: (state: PlaybackState) => void) => { changes.add(listener); listener(state); return () => changes.delete(listener); }, + onMetadata: (listener: (metadata: IcyNowPlaying) => void) => { metadata.add(listener); return () => metadata.delete(listener); }, + stop: vi.fn(async () => change({state: 'stopped', ready: false})), + play: vi.fn(async (station: Station, url: string, options?: {startPaused?: boolean; signal?: AbortSignal}) => { + change({state: options?.startPaused ? 'paused' : 'playing', ready: true, buffering: false, stationName: station.name, streamUrl: url}); + emitMetadata(`Track for ${station.name}`); + }), + pause: async () => { change({state: 'paused'}); return {ok: true}; }, + resume: async () => { change({state: 'playing'}); return {ok: true}; }, + recoverableFailure: vi.fn(() => false) + }; + const providers = {resolve: vi.fn(async (station: Station, _options?: {signal?: AbortSignal}) => ({url: station.streamUrl!}))}; + const engine = new RadioEngine(player as unknown as PlayerController, providers, store, () => store.settings()); engines.push(engine); + return {engine, store, player, providers, change, emitMetadata}; +} + +describe('shared radio engine', () => { + it('attributes metadata emitted during startup to the candidate, and ends history on passive EOF', async () => { + vi.useFakeTimers(); + const {engine, store, change} = fixture(); + await engine.tune(first); + await vi.advanceTimersByTimeAsync(1000); + await engine.tune(second); + const track = store.snapshot().trackHistory[0]; + expect(track).toMatchObject({stationKey: 'playlist:second', title: 'Track for Second'}); + await vi.advanceTimersByTimeAsync(1000); + change({state: 'stopped', ready: false}); + await engine.flushHistory(); + const finished = store.snapshot().activity.sessions[0]!; + expect(finished.endedAt).toBeDefined(); + expect(finished.listenedSeconds).toBe(1); + await vi.advanceTimersByTimeAsync(3000); + await engine.close(); + expect(store.snapshot().activity.sessions[0]?.listenedSeconds).toBe(1); + }); + + it('never lets a late resolution become audible after Stop or a newer tune', async () => { + const {engine, player, providers} = fixture(); + let release!: (value: {url: string}) => void; + providers.resolve.mockImplementationOnce(() => new Promise(resolve => { release = resolve; })); + const pending = engine.tune(first); + const rejected = expect(pending).rejects.toMatchObject({name: 'AbortError'}); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + await engine.stop(); + await engine.tune(second); + release({url: first.streamUrl!}); + await rejected; + expect(player.play).toHaveBeenCalledTimes(1); + expect(engine.snapshot().station).toEqual(second); + }); + + it('does not count buffering or pause as listening and resumes a separate session', async () => { + vi.useFakeTimers(); + const {engine, store, change} = fixture(); + await engine.tune(first); + await vi.advanceTimersByTimeAsync(2000); + change({buffering: true}); + await engine.flushHistory(); + await vi.advanceTimersByTimeAsync(4000); + change({buffering: false}); + await engine.flushHistory(); + await vi.advanceTimersByTimeAsync(1000); + await engine.pause(); + expect(store.snapshot().activity.sessions.reduce((sum, session) => sum + session.listenedSeconds, 0)).toBe(3); + }); + + it('keeps a ready AirPlay receiver connected through retunes', async () => { + const {engine, player, change} = fixture(); + await engine.tune(first); + change({backend: 'airplay'}); + player.stop.mockClear(); + await engine.tune(second); + expect(player.stop).not.toHaveBeenCalled(); + expect(engine.snapshot().station).toEqual(second); + }); + + it('cancels an auto-skip delay immediately and bounds transient recovery', async () => { + vi.useFakeTimers(); + const {engine, providers, player, change} = fixture(); + providers.resolve.mockRejectedValueOnce(new Error('Unavailable.')); + const tune = engine.tune(first, {queue: [first, second]}); + const rejected = expect(tune).rejects.toMatchObject({name: 'AbortError'}); + await vi.advanceTimersByTimeAsync(0); + expect(engine.snapshot().message).toContain('Skipping to Second'); + await engine.stop(); await rejected; + await vi.advanceTimersByTimeAsync(1500); + expect(player.play).not.toHaveBeenCalled(); + await engine.tune(first); + player.recoverableFailure.mockReturnValue(true); + for (const delay of [1000, 2000, 4000]) { change({state: 'error', ready: false}); await vi.advanceTimersByTimeAsync(delay); await engine.flushHistory(); } + expect(player.play).toHaveBeenCalledTimes(4); + change({state: 'error', ready: false}); await vi.advanceTimersByTimeAsync(10_000); + expect(player.play).toHaveBeenCalledTimes(4); + }); + + it('cancels a pending reconnect on Stop or a new station', async () => { + vi.useFakeTimers(); const {engine, player, change} = fixture(); + await engine.tune(first); player.recoverableFailure.mockReturnValue(true); + change({state: 'error', ready: false}); await engine.stop(); + await vi.advanceTimersByTimeAsync(8000); expect(player.play).toHaveBeenCalledTimes(1); + await engine.tune(first); change({state: 'error', ready: false}); await engine.tune(second); + await vi.advanceTimersByTimeAsync(8000); expect(player.play).toHaveBeenCalledTimes(3); + expect(engine.snapshot().station?.id).toBe(second.id); + }); + + it('restores the retry budget only after sustained healthy playback', async () => { + vi.useFakeTimers(); const {engine, player, change} = fixture(); + await engine.tune(first); player.recoverableFailure.mockReturnValue(true); + for (const wait of [1000, 2000, 4000]) { change({state: 'error', ready: false}); await vi.advanceTimersByTimeAsync(wait); } + await vi.advanceTimersByTimeAsync(30_000); change({buffering: true}); + await vi.advanceTimersByTimeAsync(31_000); change({buffering: false}); + await vi.advanceTimersByTimeAsync(60_000); + change({state: 'error', ready: false}); await vi.advanceTimersByTimeAsync(1000); + expect(player.play).toHaveBeenCalledTimes(5); + }); +}); diff --git a/src/player/radio-engine.ts b/src/player/radio-engine.ts new file mode 100644 index 0000000..d2c11b1 --- /dev/null +++ b/src/player/radio-engine.ts @@ -0,0 +1,246 @@ +import type {AppSettings, IcyNowPlaying, LibraryState, PlaybackState, ResolvedStream, Station} from '../types.js'; +import {stationKey} from '../storage/store.js'; +import {PlayerController, PlaybackOutputError} from './player-controller.js'; + +type HistoryStore = { + addRecent(station: Station): LibraryState | Promise; + recordTrack(station: Station, title: string): LibraryState | Promise; + startListeningSession(station: Station, at?: Date): LibraryState | Promise; + finishActiveListeningSession(at?: Date): LibraryState | Promise; + checkpointActiveListeningSession(at?: Date): LibraryState | Promise; +}; +export type RadioEngineState = { + station: Station | null; tuningStation: Station | null; queue: Station[]; + playback: PlaybackState; nowPlaying: IcyNowPlaying | null; warning?: string; message?: string; +}; +type TuneOptions = {queue?: Station[]; startPaused?: boolean; signal?: AbortSignal; skipBroken?: boolean; recoveryAttempt?: number}; + +/** Product playback rules shared by interactive and headless owners. Screen + * navigation and native alarm ownership deliberately remain in their adapters. */ +export class RadioEngine { + private state: RadioEngineState; + private readonly listeners = new Set<(state: RadioEngineState) => void>(); + private readonly unsubscribe: Array<() => void>; + private generation = 0; + private tuneAbort: AbortController | undefined; + private candidate: {station: Station; url: string; generation: number} | undefined; + private listeningKey: string | undefined; + private lastTrack: string | undefined; + private history: Promise = Promise.resolve(); + private readonly checkpoint: NodeJS.Timeout; + private closed = false; + private recoveryTimer: NodeJS.Timeout | undefined; + private recoveryAttempts = 0; + private healthyPlaybackTimer: NodeJS.Timeout | undefined; + private closing: Promise | undefined; + + constructor(readonly player: PlayerController, + private readonly providers: {resolve(station: Station, options?: {signal?: AbortSignal}): Promise}, + private readonly store: HistoryStore, + private readonly settings: () => AppSettings, + private readonly onSaved?: (state: LibraryState) => void) { + this.state = {station: null, tuningStation: null, queue: [], playback: player.getState(), nowPlaying: null}; + this.unsubscribe = [ + player.onChange(playback => this.onPlayback(playback)), + player.onMetadata(metadata => this.onMetadata(metadata)) + ]; + this.checkpoint = setInterval(() => { + if (this.listeningKey && this.state.playback.state === 'playing' && this.state.playback.ready && !this.state.playback.buffering) { + const at = new Date(); this.persist(() => store.checkpointActiveListeningSession(at)); + } + }, 30_000); + this.checkpoint.unref(); + } + + snapshot(): RadioEngineState { return {...this.state, playback: {...this.state.playback}, queue: [...this.state.queue]}; } + onChange(listener: (state: RadioEngineState) => void): () => void { + this.listeners.add(listener); listener(this.snapshot()); return () => this.listeners.delete(listener); + } + clearWarning(): void { if (this.state.warning) { this.state.warning = undefined; this.emit(); } } + cancel(): void { + this.generation++; + clearTimeout(this.recoveryTimer); + this.recoveryTimer = undefined; + clearTimeout(this.healthyPlaybackTimer); + this.healthyPlaybackTimer = undefined; + this.tuneAbort?.abort(new DOMException('Tuning canceled.', 'AbortError')); + this.tuneAbort = undefined; + if (this.candidate && this.state.playback.ready) this.candidate.generation = this.generation; + this.state.tuningStation = null; + this.state.message = undefined; + this.emit(); + } + + async tune(station: Station, options: TuneOptions = {}): Promise { + if (this.closed) throw new Error('RadioCLI playback engine is closed.'); + options.signal?.throwIfAborted(); + this.cancel(); + this.recoveryAttempts = options.recoveryAttempt ?? 0; + const generation = this.generation; + const controller = new AbortController(); + const deadline = setTimeout(() => controller.abort(new DOMException('Tuning exceeded 90 seconds.', 'TimeoutError')), 90_000); + deadline.unref(); + this.tuneAbort = controller; + const abort = () => controller.abort(options.signal?.reason); + options.signal?.addEventListener('abort', abort, {once: true}); + const queue = options.queue?.length ? options.queue : [station]; + let next = station; + try { + while (true) { + controller.signal.throwIfAborted(); + this.state.tuningStation = next; + this.state.nowPlaying = null; + this.emit(); + try { + // A ready receiver can keep its connection while resolving a new URL. + // Pending startup and local processes are canceled before replacement. + const playback = this.player.getState(); + if (!(playback.backend === 'airplay' && playback.ready && playback.state === 'playing')) await this.player.stop(); + controller.signal.throwIfAborted(); + const resolved = await this.providers.resolve(next, {signal: controller.signal}); + controller.signal.throwIfAborted(); + if (generation !== this.generation) throw new DOMException('Tuning canceled.', 'AbortError'); + this.candidate = {station: next, url: resolved.url, generation}; + this.lastTrack = undefined; + await this.player.play(next, resolved.url, {startPaused: options.startPaused, signal: controller.signal}); + controller.signal.throwIfAborted(); + this.onPlayback(this.player.getState()); + this.state.station = next; + this.state.queue = [...queue]; + this.state.tuningStation = null; + this.state.message = undefined; + const tuned = next; + this.persist(() => this.store.addRecent(tuned)); + this.emit(); + await this.flushHistory(); + controller.signal.throwIfAborted(); + if (generation !== this.generation) throw new DOMException('Tuning canceled.', 'AbortError'); + return next; + } catch (error) { + if (controller.signal.aborted || generation !== this.generation) throw controller.signal.reason ?? error; + const index = queue.findIndex(item => stationKey(item) === stationKey(next)); + const following = index >= 0 ? queue[index + 1] : undefined; + if (!following || options.startPaused || options.skipBroken === false || !this.settings().skipBrokenStreams || error instanceof PlaybackOutputError) throw error; + this.state.message = `${error instanceof Error ? error.message : 'Could not tune station.'} Skipping to ${following.name}.`; + this.state.tuningStation = following; + this.emit(); + await abortableDelay(1000, controller.signal); + next = following; + } + } + } finally { + clearTimeout(deadline); + options.signal?.removeEventListener('abort', abort); + if (generation === this.generation) { + this.tuneAbort = undefined; + this.state.tuningStation = null; + this.emit(); + } + } + } + + async stop(options: {retainSelection?: boolean} = {}): Promise { + this.cancel(); + this.candidate = undefined; + this.finishListening(); + this.state.nowPlaying = null; + if (!options.retainSelection) { this.state.station = null; this.state.queue = []; } + await this.player.stop(); + this.onPlayback(this.player.getState()); + this.emit(); + await this.flushHistory(); + } + async pause() { const result = await this.player.pause(); this.onPlayback(this.player.getState()); await this.flushHistory(); return result; } + async resume() { const result = await this.player.resume(); this.onPlayback(this.player.getState()); await this.flushHistory(); return result; } + select(station: Station, queue = this.state.queue): void { this.state.station = station; this.state.queue = [...queue]; this.emit(); } + async flushHistory(): Promise { await this.history; } + close(): Promise { + return this.closing ??= (async () => { + this.closed = true; + clearInterval(this.checkpoint); + try { await this.stop(); } + finally { for (const unsubscribe of this.unsubscribe) unsubscribe(); this.listeners.clear(); } + })(); + } + + private onPlayback(playback: PlaybackState): void { + this.state.playback = playback; + const active = this.candidate; + const belongs = active && active.generation === this.generation && (!playback.streamUrl || playback.streamUrl === active.url); + if (belongs && playback.ready && playback.state === 'playing' && !playback.buffering) { + if (this.recoveryAttempts > 0 && !this.healthyPlaybackTimer) { + const generation = this.generation; + this.healthyPlaybackTimer = setTimeout(() => { + this.healthyPlaybackTimer = undefined; + if (generation === this.generation && !this.closed) this.recoveryAttempts = 0; + }, 60_000); + this.healthyPlaybackTimer.unref(); + } + const key = stationKey(active.station); + if (this.listeningKey !== key) { + this.finishListening(); + this.listeningKey = key; + const at = new Date(); this.persist(() => this.store.startListeningSession(active.station, at)); + } + } else { + clearTimeout(this.healthyPlaybackTimer); this.healthyPlaybackTimer = undefined; + if (!playback.ready || playback.state !== 'playing' || playback.buffering) this.finishListening(); + } + if (belongs && playback.state === 'error' && !this.state.tuningStation && !this.closed && this.player.recoverableFailure()) this.scheduleRecovery(active.station); + this.emit(); + } + private onMetadata(metadata: IcyNowPlaying): void { + const active = this.candidate; + if (!active || active.generation !== this.generation || this.state.playback.streamUrl && this.state.playback.streamUrl !== active.url) return; + this.state.nowPlaying = metadata; + const key = `${stationKey(active.station)}:${metadata.title}`; + if (metadata.title && key !== this.lastTrack) { + this.lastTrack = key; + this.persist(() => this.store.recordTrack(active.station, metadata.title!)); + } + this.emit(); + } + private finishListening(): void { + if (!this.listeningKey) return; + this.listeningKey = undefined; + const at = new Date(); this.persist(() => this.store.finishActiveListeningSession(at)); + } + private scheduleRecovery(station: Station): void { + if (this.recoveryTimer) return; + if (this.recoveryAttempts >= 3) { this.state.message = 'Reconnection stopped after 3 attempts. Select the station to try again.'; return; } + const attempt = ++this.recoveryAttempts; + const generation = this.generation; + this.state.message = `Stream connection interrupted. Reconnecting ${station.name} (${attempt}/3).`; + this.recoveryTimer = setTimeout(() => { + this.recoveryTimer = undefined; + if (this.closed || generation !== this.generation) return; + void this.tune(station, {queue: this.state.queue, recoveryAttempt: attempt, skipBroken: false}).catch(error => { + if (this.closed || (error as Error).name === 'AbortError') return; + if (this.player.recoverableFailure() && this.recoveryAttempts < 3) this.scheduleRecovery(station); + else { this.state.message = `Could not reconnect ${station.name}: ${error instanceof Error ? error.message : String(error)}`; this.emit(); } + }); + }, 1000 * 2 ** (attempt - 1)); + this.recoveryTimer.unref(); + } + private persist(write: () => LibraryState | Promise): void { + this.history = this.history.then(async () => { + try { const saved = await write(); this.onSaved?.(saved); } + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + this.state.warning = `Listening history was not saved${code ? ` (${code})` : ''}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`; + this.emit(); + } + }); + } + private emit(): void { for (const listener of this.listeners) { try { listener(this.snapshot()); } catch { /* Isolate adapters. */ } } } +} + +function abortableDelay(ms: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const finish = () => { signal.removeEventListener('abort', abort); resolve(); }; + const timer = setTimeout(finish, ms); + const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(signal.reason); }; + signal.addEventListener('abort', abort, {once: true}); + }); +} diff --git a/src/playlists/playlist-windows.test.ts b/src/playlists/playlist-windows.test.ts new file mode 100644 index 0000000..0defee1 --- /dev/null +++ b/src/playlists/playlist-windows.test.ts @@ -0,0 +1,24 @@ +import {afterEach, expect, it, vi} from 'vitest'; +import {parsePlaylistFile} from './playlist.js'; + +const files = vi.hoisted(() => new Map()); +// Exercise actual Windows path semantics on every CI host as well as the +// native filesystem fixtures in playlist.test.ts on Windows runners. +vi.mock('node:path', async original => ({...await original(), ...(await original()).win32})); +vi.mock('node:fs', () => ({existsSync: (path: string) => files.has(path), readFileSync: (path: string) => files.get(path), writeFileSync: vi.fn()})); +afterEach(() => files.clear()); + +it.each(['m3u', 'pls'])('imports Windows drive, UNC, relative and file URL entries in %s', extension => { + const targets = ['C:\\Music\\音楽.wav', '\\\\server\\radio\\live.mp3', 'relative.mp3', 'file:///C:/Music/local.wav']; + const path = `C:\\Playlists\\radio.${extension}`; + files.set(path, extension === 'm3u' ? targets.join('\r\n') : targets.map((target, index) => `File${index + 1}=${target}`).join('\r\n')); + files.set(targets[0]!, ''); files.set(targets[1]!, ''); files.set('C:\\Playlists\\relative.mp3', ''); + expect(parsePlaylistFile(path).map(station => station.streamUrl)).toEqual([targets[0], targets[1], 'C:\\Playlists\\relative.mp3', targets[3]]); +}); + +it('resolves nested Windows playlists without treating drive letters as schemes', () => { + files.set('C:\\Playlists\\radio.m3u', 'D:\\Other\\nested.pls'); + files.set('D:\\Other\\nested.pls', 'File1=D:\\Music\\recording.mp3'); + files.set('D:\\Music\\recording.mp3', ''); + expect(parsePlaylistFile('C:\\Playlists\\radio.m3u')[0]?.streamUrl).toBe('D:\\Music\\recording.mp3'); +}); diff --git a/src/playlists/playlist.test.ts b/src/playlists/playlist.test.ts index c0a484d..30b904a 100644 --- a/src/playlists/playlist.test.ts +++ b/src/playlists/playlist.test.ts @@ -34,6 +34,31 @@ describe('playlist import/export', () => { expect(parsePlaylistFile(input)[0]?.name).toBe('PLS FM'); }); + it.each(['m3u', 'pls'])('imports native absolute and relative media paths from %s', format => { + const root = mkdtempSync(join(tmpdir(), 'radiocli playlist café ')); roots.push(root); + const audio = join(root, 'my recording.mp3'); writeFileSync(audio, 'fixture'); + const input = join(root, `stations.${format}`); + writeFileSync(input, format === 'm3u' ? `#EXTM3U\n${audio}\nmy recording.mp3\nftp://unsupported.test/audio\n` : `[playlist]\nFile1=${audio}\nFile2=my recording.mp3\nFile3=ftp://unsupported.test/audio\n`); + expect(parsePlaylistFile(input).map(station => station.streamUrl)).toEqual([audio]); + }); + + it('imports XSPF titles and escaped stream parameters without duplicating streams', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli xspf café ')); + roots.push(root); + const input = join(root, 'stations.xspf'); + writeFileSync(input, ` + Café & 日本https://example.com/live?a=1&b=2 + Duplicatehttps://example.com/live?a=1&b=2 + ftp://example.com/unsupported + `); + const stations = parsePlaylistFile(input); + expect(stations).toHaveLength(1); + expect(stations[0]).toMatchObject({name: 'Café & 日本', streamUrl: 'https://example.com/live?a=1&b=2'}); + const output = join(root, 'round trip.m3u'); + writeM3u(output, stations); + expect(parsePlaylistFile(output)).toEqual(stations); + }); + it('dedupes streams and follows nested local playlists', () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-playlist-')); roots.push(root); @@ -48,4 +73,18 @@ describe('playlist import/export', () => { expect(stations).toHaveLength(1); expect(stations[0]?.name).toBe('Nested FM'); }); + + it('accepts XSPF CDATA and XML character references without double decoding', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-xspf-')); + roots.push(root); + const input = join(root, 'stations.xspf'); + writeFileSync(input, ` + <![CDATA[Rock & Roll]]> + Café 日本 'FM' &lt;https://example.com/other?a=1&b=2 + `); + expect(parsePlaylistFile(input).map(item => [item.name, item.streamUrl])).toEqual([ + ['Rock & Roll', 'https://example.com/live?a=1&b=2'], + ["Café 日本 'FM' <", 'https://example.com/other?a=1&b=2'] + ]); + }); }); diff --git a/src/playlists/playlist.ts b/src/playlists/playlist.ts index 0504a8d..8fb2931 100644 --- a/src/playlists/playlist.ts +++ b/src/playlists/playlist.ts @@ -1,6 +1,7 @@ import {existsSync, readFileSync, writeFileSync} from 'node:fs'; import {basename, dirname, isAbsolute, join, resolve} from 'node:path'; import type {Station} from '../types.js'; +import {customStationId} from '../storage/station-identity.js'; export function parsePlaylistFile(filePath: string, depth = 0): Station[] { if (!existsSync(filePath)) { @@ -39,7 +40,7 @@ export function writeM3u(filePath: string, stations: Station[]): void { export function stationFromUrl(url: string, name = url): Station { const cleanedName = cleanName(name) || url; return { - id: stableId(url), + id: customStationId(url), provider: 'playlist', name: cleanedName, tags: ['custom'], @@ -130,22 +131,18 @@ function firstTag(text: string, tag: string): string | undefined { } function decodeXml(value?: string): string | undefined { - return value - ?.replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .trim(); -} - -function stableId(value: string): string { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) >>> 0; - } - - return `custom-${hash.toString(16)}`; + // Decode in one pass: CDATA is literal text, and &lt; represents <, + // not a second reference to expand. No DTD or external entity is evaluated. + const entities: Record = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'"}; + return value?.replace(/|&(amp|lt|gt|quot|apos|#\d+|#x[\da-fA-F]+);/g, + (reference: string, cdata: string | undefined, entity: string | undefined) => { + if (cdata !== undefined) return cdata; + if (!entity) return reference; + if (!entity.startsWith('#')) return entities[entity] ?? reference; + const point = entity.startsWith('#x') ? Number.parseInt(entity.slice(2), 16) : Number(entity.slice(1)); + return Number.isInteger(point) && point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff) + ? String.fromCodePoint(point) : reference; + }).trim(); } function normalizePlaylistTarget(target: string, root: string): string | null { @@ -154,7 +151,8 @@ function normalizePlaylistTarget(target: string, root: string): string | null { return trimmed; } - if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + // On Windows a native drive path has a colon but is not a URL scheme. + if (isAbsolute(trimmed) || !/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { const filePath = isAbsolute(trimmed) ? trimmed : resolve(root, trimmed); if (existsSync(filePath)) { return filePath; diff --git a/src/providers/cache.test.ts b/src/providers/cache.test.ts index 91ef7be..7d20d03 100644 --- a/src/providers/cache.test.ts +++ b/src/providers/cache.test.ts @@ -1,18 +1,96 @@ -import {mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import {mkdtempSync, mkdirSync, 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 {dirname, join} from 'node:path'; +import {setImmediate as nextEventLoopTurn} from 'node:timers/promises'; +import * as workerThreads from 'node:worker_threads'; +import {EventEmitter} from 'node:events'; +import {afterEach, describe, expect, it, vi} from 'vitest'; import {ProviderCache} from './cache.js'; +vi.mock('node:worker_threads', async importOriginal => ({...await importOriginal()})); + const temporaryRoots: string[] = []; afterEach(() => { + vi.restoreAllMocks(); for (const root of temporaryRoots.splice(0)) { rmSync(root, {recursive: true, force: true}); } }); describe('ProviderCache', () => { + it('bounds memory and shard markers together and reloads evicted values from disk', async () => { + const filePath = temporaryCachePath(); + const cache = new ProviderCache(filePath, {maxMemoryEntries: 2, maxMemoryBytes: 4096}); + for (let index = 0; index < 6; index += 1) await cache.setAsync(`key-${index}`, {index}); + const internals = cache as unknown as {cache: {entries: Record}; checkedShards: Set; memoryBytes: number}; + expect(Object.keys(internals.cache.entries)).toEqual(['key-4', 'key-5']); + expect(internals.checkedShards.size).toBe(2); + expect(internals.memoryBytes).toBeLessThanOrEqual(4096); + expect(await cache.getAsync('key-0', 60_000)).toEqual({index: 0}); + expect(internals.checkedShards.size).toBe(2); + expect(Object.keys(internals.cache.entries)).toHaveLength(2); + }); + + it('keeps values larger than the memory budget usable without retaining them', async () => { + const filePath = temporaryCachePath(); + const cache = new ProviderCache(filePath, {maxMemoryBytes: 128}); + const value = '東京'.repeat(1000); + await cache.setAsync('atlas', value); + expect(await cache.getAsync('atlas', 60_000)).toBe(value); + expect(cache.get('atlas', 60_000)).toBe(value); + const internals = cache as unknown as {cache: {entries: Record}; checkedShards: Set; memoryBytes: number}; + expect(Object.keys(internals.cache.entries)).toHaveLength(0); + expect(internals.checkedShards.size).toBe(0); + expect(internals.memoryBytes).toBe(0); + }); + + it('enforces disk count and byte budgets after completed asynchronous writes', async () => { + const filePath = temporaryCachePath(); + const cache = new ProviderCache(filePath, {maxDiskEntries: 3, maxDiskBytes: 900}); + for (let index = 0; index < 8; index += 1) await cache.setAsync(`key-${index}`, 'x'.repeat(200)); + const files = readdirSync(`${filePath}.d`).map(name => readFileSync(join(`${filePath}.d`, name))); + expect(files.length).toBeLessThanOrEqual(3); + expect(files.reduce((sum, contents) => sum + contents.byteLength, 0)).toBeLessThanOrEqual(900); + }); + + it('does not let a delayed shard read replace a newer in-process write', async () => { + const filePath = temporaryCachePath(); + const oldRows = Array.from({length: 2000}, (_, index) => ({index, name: 'old'.repeat(60)})); + new ProviderCache(filePath).set('atlas', oldRows); + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + vi.spyOn(workerThreads, 'Worker').mockImplementation(function (_source, options) { + const worker = new EventEmitter() as workerThreads.Worker; + worker.terminate = async () => 0; + started(); + void gate.then(() => { + worker.emit('message', {value: JSON.parse(options!.workerData.value as string)}); + worker.emit('exit', 0); + }); + return worker; + }); + const cache = new ProviderCache(filePath); + const reading = cache.getAsync('atlas', 60_000); + await ready; + await cache.setAsync('atlas', [{id: 'new'}]); + release(); + expect(await reading).toEqual([{id: 'new'}]); + expect(cache.get('atlas', 60_000)).toEqual([{id: 'new'}]); + }); + + it('bounds pending writes and rejects a synchronous write that would overtake them', async () => { + const cache = new ProviderCache(temporaryCachePath(), {maxPendingOperations: 1}); + const writing = cache.setAsync('one', ['value']); + await expect(cache.setAsync('two', ['value'])).rejects.toThrow(/capacity/); + expect(() => cache.set('one', ['later'])).toThrow(/setAsync/); + await writing; + expect(await cache.getAsync('one', 60_000)).toEqual(['value']); + await cache.setAsync('two', ['value']); + }); + it('persists independent shards and restores them in a new instance', () => { const filePath = temporaryCachePath(); const cache = new ProviderCache(filePath); @@ -36,6 +114,150 @@ describe('ProviderCache', () => { expect(restored.get('valid', 60_000)).toEqual({ok: true}); expect(readdirSync(shardDirectory)).toHaveLength(2); }); + + it.each(['sync', 'async'] as const)('prefers a newer shard over an expired legacy entry with %s reads', async mode => { + const filePath = temporaryCachePath(); + writeFileSync(filePath, JSON.stringify({version: 1, entries: {atlas: {createdAt: Date.now() - 41 * 86400000, value: ['old atlas']}}})); + const cache = new ProviderCache(filePath); + await cache.setAsync('atlas', ['new atlas']); + const restored = new ProviderCache(filePath); + const fresh = mode === 'async' ? await restored.getAsync('atlas', 60_000) : restored.get('atlas', 60_000); + const stale = mode === 'async' ? await restored.getStaleAsync('atlas') : restored.getStale('atlas'); + expect(fresh).toEqual(['new atlas']); + expect(stale).toEqual(['new atlas']); + }); + + it.each(['sync', 'async'] as const)('retains newer legacy data over an older shard with %s reads', async mode => { + const filePath = temporaryCachePath(); + const cache = new ProviderCache(filePath); + cache.set('atlas', ['old shard']); + writeFileSync(filePath, JSON.stringify({version: 1, entries: {atlas: {createdAt: Date.now() + 1, value: ['new legacy']}}})); + const restored = new ProviderCache(filePath); + expect(mode === 'async' ? await restored.getAsync('atlas', 60_000) : restored.get('atlas', 60_000)).toEqual(['new legacy']); + }); + + it.each(['sync', 'async'] as const)('expires future-dated memory, shard and legacy entries after a clock correction (%s)', async mode => { + const now = 1_800_000_000_000; + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 86_400_000); + const filePath = temporaryCachePath(); + const writer = new ProviderCache(filePath); + await writer.setAsync('atlas', ['saved before clock correction']); + clock.mockReturnValue(now); + const legacyPath = temporaryCachePath(); + writeFileSync(legacyPath, JSON.stringify({version: 1, entries: {atlas: {createdAt: now + 86_400_000, value: ['future legacy']}}})); + for (const cache of [writer, new ProviderCache(filePath), new ProviderCache(legacyPath)]) { + expect(mode === 'sync' ? cache.get('atlas', 1000) : await cache.getAsync('atlas', 1000)).toBeNull(); + expect(mode === 'sync' ? cache.getStale('atlas') : await cache.getStaleAsync('atlas')).toBeNull(); + } + await writer.setAsync('atlas', ['refreshed after correction']); + expect(mode === 'sync' ? writer.get('atlas', 1000) : await writer.getAsync('atlas', 1000)).toEqual(['refreshed after correction']); + clock.mockReturnValue(now + 1001); + expect(mode === 'sync' ? writer.get('atlas', 1000) : await writer.getAsync('atlas', 1000)).toBeNull(); + }); + + it.each(['sync', 'async'] as const)('does not let a future legacy timestamp hide a valid shard (%s)', async mode => { + const now = 1_800_000_000_000; + vi.spyOn(Date, 'now').mockReturnValue(now); + const filePath = temporaryCachePath(); + await new ProviderCache(filePath).setAsync('atlas', ['valid shard']); + writeFileSync(filePath, JSON.stringify({version: 1, entries: {atlas: {createdAt: now + 86_400_000, value: ['future legacy']}}})); + const cache = new ProviderCache(filePath); + expect(mode === 'sync' ? cache.get('atlas', 1000) : await cache.getAsync('atlas', 1000)).toEqual(['valid shard']); + }); + + it.each(['sync', 'async'] as const)('does not let a future shard hide valid legacy data (%s)', async mode => { + const now = 1_800_000_000_000; + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 86_400_000); + const filePath = temporaryCachePath(); + await new ProviderCache(filePath).setAsync('atlas', ['future shard']); + clock.mockReturnValue(now); + writeFileSync(filePath, JSON.stringify({version: 1, entries: {atlas: {createdAt: now, value: ['valid legacy']}}})); + const cache = new ProviderCache(filePath); + expect(mode === 'sync' ? cache.get('atlas', 1000) : await cache.getAsync('atlas', 1000)).toEqual(['valid legacy']); + }); + + it.each(['sync', 'async'] as const)('tolerates at most one second of future clock skew (%s)', async mode => { + const now = 1_800_000_000_000; + const clock = vi.spyOn(Date, 'now').mockReturnValue(now); + const filePath = temporaryCachePath(); + const cache = new ProviderCache(filePath); + cache.set('atlas', ['retained']); + clock.mockReturnValue(now - 1000); + expect(mode === 'sync' ? cache.get('atlas', 1000) : await cache.getAsync('atlas', 1000)).toEqual(['retained']); + clock.mockReturnValue(now - 1001); + expect(mode === 'sync' ? cache.get('atlas', 1000) : await cache.getAsync('atlas', 1000)).toBeNull(); + }); + + it('prefers migrated shards when legacy and shard timestamps are equal', async () => { + const filePath = temporaryCachePath(); + const cache = new ProviderCache(filePath); + cache.set('atlas', ['new shard']); + const shard = JSON.parse(readFileSync(join(`${filePath}.d`, readdirSync(`${filePath}.d`)[0]!), 'utf8')) as {createdAt: number}; + writeFileSync(filePath, JSON.stringify({version: 1, entries: {atlas: {createdAt: shard.createdAt, value: ['old legacy']}}})); + expect(await new ProviderCache(filePath).getAsync('atlas', 60_000)).toEqual(['new shard']); + expect(new ProviderCache(filePath).get('atlas', 60_000)).toEqual(['new shard']); + }); + + it('round-trips a large Unicode atlas while leaving the event loop available', async () => { + const filePath = temporaryCachePath(); + const rows = Array.from({length: 2000}, (_, index) => ({id: index, name: '東京 · Rádio · 👩🏽‍💻', description: 'radio'.repeat(50)})); + const cache = new ProviderCache(filePath); + await cache.setAsync('atlas', rows); + const restored = new ProviderCache(filePath); + const events: string[] = []; + const reading = restored.getAsync('atlas', 60_000).then(result => {events.push('loaded'); return result;}); + await nextEventLoopTurn(); + events.push('input'); + expect(await reading).toEqual(rows); + expect(events).toEqual(['input', 'loaded']); + }); + + it('retains large-cache reads and writes when the environment forbids workers', async () => { + vi.spyOn(workerThreads, 'Worker').mockImplementation(function () {throw new Error('Worker creation disabled.');}); + const filePath = temporaryCachePath(); + const rows = Array.from({length: 2000}, (_, index) => ({id: index, name: 'Radio'.repeat(50)})); + await new ProviderCache(filePath).setAsync('atlas', rows); + expect(await new ProviderCache(filePath).getAsync('atlas', 60_000)).toEqual(rows); + expect(workerThreads.Worker).toHaveBeenCalled(); + }); + + it('preserves the last value when a large write overlaps a smaller refresh', async () => { + const filePath = temporaryCachePath(); + const cache = new ProviderCache(filePath); + await Promise.all([ + cache.setAsync('atlas', Array.from({length: 2000}, (_, index) => ({id: index}))), + cache.setAsync('atlas', [{id: 'newer'}]) + ]); + expect(await new ProviderCache(filePath).getAsync('atlas', 60_000)).toEqual([{id: 'newer'}]); + }); + + it('keeps cached data available when its shard is corrupt', async () => { + const filePath = temporaryCachePath(); + const cache = new ProviderCache(filePath); + cache.set('atlas', ['valid']); + writeFileSync(filePath, JSON.stringify({version: 1, entries: {atlas: {createdAt: Date.now(), value: ['legacy fallback']}}})); + writeFileSync(join(`${filePath}.d`, readdirSync(`${filePath}.d`)[0]!), '{invalid'); + expect(await new ProviderCache(filePath).getAsync('atlas', 60_000)).toEqual(['legacy fallback']); + }); + + it.each(['sync', 'async'] as const)('discovers a shard created after a %s cache miss', async mode => { + const filePath = temporaryCachePath(); + const cache = new ProviderCache(filePath); + expect(mode === 'async' ? await cache.getAsync('atlas', 60_000) : cache.get('atlas', 60_000)).toBeNull(); + await new ProviderCache(filePath).setAsync('atlas', ['created by another process']); + expect(mode === 'async' ? await cache.getAsync('atlas', 60_000) : cache.get('atlas', 60_000)).toEqual(['created by another process']); + }); + + it('backs up corrupt large legacy data while keeping a valid shard usable', async () => { + const filePath = temporaryCachePath(); + const invalid = '{invalid'.repeat(40_000); + writeFileSync(filePath, invalid); + new ProviderCache(filePath).set('atlas', ['valid shard']); + expect(await new ProviderCache(filePath).getAsync('atlas', 60_000)).toEqual(['valid shard']); + const backup = readdirSync(dirname(filePath)).find(name => name.startsWith('cache.json.bad-')); + expect(backup).toBeDefined(); + expect(readFileSync(join(dirname(filePath), backup!), 'utf8')).toBe(invalid); + }); }); function temporaryCachePath(): string { diff --git a/src/providers/cache.ts b/src/providers/cache.ts index e2788aa..e8aeb18 100644 --- a/src/providers/cache.ts +++ b/src/providers/cache.ts @@ -1,148 +1,302 @@ -import {chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync} from 'node:fs'; -import {createHash} from 'node:crypto'; +import {existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync} from 'node:fs'; +import {createHash, randomUUID} from 'node:crypto'; +import {mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile} from 'node:fs/promises'; import {platformPaths} from '../platform/paths.js'; +import {parseJsonAsync, stringifyJsonAsync} from '../platform/json-worker.js'; import {join} from 'node:path'; -type CacheEnvelope = { - version: 1; - entries: Record; -}; - -type ShardedCacheEntry = {version: 1; key: string; createdAt: number; value: unknown}; +type CacheEntry = {createdAt: number; value: unknown}; +type CacheEnvelope = {version: 1; entries: Record}; +type ShardedCacheEntry = CacheEntry & {version: 1; key: string}; +type CacheOptions = {maxMemoryEntries?: number; maxMemoryBytes?: number; maxDiskEntries?: number; maxDiskBytes?: number; maxPendingOperations?: number}; +type PendingRead = {invalidated: boolean; promise: Promise}; +type DiskEntry = {path: string; bytes: number; modified: number; inode: number}; const maxCacheEntries = 256; const maxCacheBytes = 128 * 1024 * 1024; +// Permit small timestamp skew between writers, but never extend freshness by +// hours or days after a system clock correction. +const maxFutureClockSkewMs = 1000; export class ProviderCache { - private cache: CacheEnvelope; - private writesSincePrune = 0; + private cache: CacheEnvelope = {version: 1, entries: Object.create(null) as Record}; + // This set is tied to retained entries; eviction must allow disk reads again. + private readonly checkedShards = new Set(); + private readonly entrySizes = new Map(); + private memoryBytes = 0; + private legacyAbsent = false; + private legacyLoadPromise: Promise | null = null; + private readonly pendingShardReads = new Map(); + private readonly pendingWrites = new Map>(); + private pendingWriteCount = 0; + private prunePromise = Promise.resolve(); - constructor(readonly filePath = defaultProviderCachePath()) { - this.cache = this.read(); - } + constructor(readonly filePath = defaultProviderCachePath(), private readonly options: CacheOptions = {}) {} get(key: string, maxAgeMs: number): T | null { - const entry = this.cache.entries[key] ?? this.readShard(key); - if (!entry) { - return null; - } + const entry = this.loadEntry(key); + return entry && withinAge(entry, maxAgeMs) ? entry.value as T : null; + } - if (Date.now() - entry.createdAt > maxAgeMs) { - return null; - } + getStale(key: string, maxAgeMs = 30 * 24 * 60 * 60 * 1000): T | null { + return this.get(key, maxAgeMs); + } - return entry.value as T; + async getAsync(key: string, maxAgeMs: number): Promise { + // A caller reading during a refresh observes the completed newest write. + await this.pendingWrites.get(key)?.catch(() => undefined); + const entry = this.memoryEntry(key) ?? await this.loadEntryAsync(key); + return entry && withinAge(entry, maxAgeMs) ? entry.value as T : null; } - getStale(key: string, maxAgeMs = 30 * 24 * 60 * 60 * 1000): T | null { - const entry = this.cache.entries[key] ?? this.readShard(key); - return entry && Date.now() - entry.createdAt <= maxAgeMs ? (entry.value as T) : null; + getStaleAsync(key: string, maxAgeMs = 30 * 24 * 60 * 60 * 1000): Promise { + return this.getAsync(key, maxAgeMs); } set(key: string, value: unknown): void { - this.cache.entries[key] = {createdAt: Date.now(), value}; - this.writeEntry(key, this.cache.entries[key]!); + if (this.pendingWrites.has(key)) throw new Error('Provider cache is refreshing this entry. Use setAsync to keep writes ordered.'); + const read = this.pendingShardReads.get(key); + if (read) read.invalidated = true; + const entry = {createdAt: Date.now(), value}; + const serialized = JSON.stringify({version: 1, key, ...entry} satisfies ShardedCacheEntry); + this.remember(key, entry, retainedBytes(serialized)); + const directory = this.shardDirectory(); + mkdirSync(directory, {recursive: true, mode: 0o700}); + writeAtomically(this.shardPath(key), serialized); + this.pruneShards(directory); } - private read(): CacheEnvelope { - if (!existsSync(this.filePath)) { - return {version: 1, entries: {}}; + async setAsync(key: string, value: unknown): Promise { + if (this.pendingWriteCount >= (this.options.maxPendingOperations ?? 16)) throw new Error('Provider cache write capacity reached.'); + const entry = {createdAt: Date.now(), value}; + const read = this.pendingShardReads.get(key); + if (read) read.invalidated = true; + this.pendingWriteCount += 1; + const previous = this.pendingWrites.get(key) ?? Promise.resolve(); + const pending = previous.catch(() => undefined).then(async () => { + const serialized = await stringifyJsonAsync({version: 1, key, ...entry} satisfies ShardedCacheEntry, isLargeValue(value)); + this.remember(key, entry, retainedBytes(serialized)); + const directory = this.shardDirectory(); + await mkdir(directory, {recursive: true, mode: 0o700}); + const path = this.shardPath(key); + const tempPath = `${path}.tmp-${process.pid}-${randomUUID()}`; + try { + await writeFile(tempPath, `${serialized}\n`, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); + await rename(tempPath, path); + } catch (error) { + await rm(tempPath, {force: true}).catch(() => undefined); + throw error; + } + const prune = this.prunePromise.catch(() => undefined).then(() => this.pruneShardsAsync(directory)); + this.prunePromise = prune; + await prune; + }); + this.pendingWrites.set(key, pending); + try { await pending; } + finally { + this.pendingWriteCount -= 1; + if (this.pendingWrites.get(key) === pending) this.pendingWrites.delete(key); } + } - try { - const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as CacheEnvelope; - return parsed.version === 1 && parsed.entries ? parsed : {version: 1 as const, entries: {}}; - } catch { - backupBadFile(this.filePath); - return {version: 1, entries: {}}; + private memoryEntry(key: string): CacheEntry | undefined { + const entry = this.cache.entries[key]; + if (entry) { + const bytes = this.entrySizes.get(key)!; + this.entrySizes.delete(key); + this.entrySizes.set(key, bytes); } + return entry; } - private readShard(key: string): CacheEnvelope['entries'][string] | undefined { - const path = join(this.shardDirectory(), `${createHash('sha256').update(key).digest('hex')}.json`); - if (!existsSync(path)) return undefined; - try { - const entry = JSON.parse(readFileSync(path, 'utf8')) as ShardedCacheEntry; - if (entry.version !== 1 || entry.key !== key || !Number.isFinite(entry.createdAt)) return undefined; - const cached = {createdAt: entry.createdAt, value: entry.value}; - this.cache.entries[key] = cached; - return cached; - } catch { - return undefined; + private remember(key: string, entry: CacheEntry, bytes = retainedBytes(JSON.stringify(entry))): void { + this.forget(key); + if (bytes > (this.options.maxMemoryBytes ?? 64 * 1024 * 1024)) return; + this.cache.entries[key] = entry; + this.entrySizes.set(key, bytes); + this.checkedShards.add(key); + this.memoryBytes += bytes; + while (this.entrySizes.size > (this.options.maxMemoryEntries ?? maxCacheEntries) || this.memoryBytes > (this.options.maxMemoryBytes ?? 64 * 1024 * 1024)) { + const oldest = this.entrySizes.keys().next().value as string | undefined; + if (oldest === undefined) break; + this.forget(oldest); } } - private writeEntry(key: string, entry: {createdAt: number; value: unknown}): void { - const directory = this.shardDirectory(); - mkdirSync(directory, {recursive: true, mode: 0o700}); - const path = join(directory, `${createHash('sha256').update(key).digest('hex')}.json`); - writeJsonAtomically(path, {version: 1, key, ...entry} satisfies ShardedCacheEntry); - this.writesSincePrune += 1; - if (this.writesSincePrune >= 16) { - this.writesSincePrune = 0; - this.pruneShards(directory); - } + private forget(key: string): void { + this.memoryBytes -= this.entrySizes.get(key) ?? 0; + this.entrySizes.delete(key); + this.checkedShards.delete(key); + delete this.cache.entries[key]; } - private pruneShards(directory: string): void { - const files = readdirSync(directory) - .filter(name => name.endsWith('.json')) - .flatMap(name => { - try { - const path = join(directory, name); - const stat = statSync(path); - return [{path, bytes: stat.size, modified: stat.mtimeMs}]; - } catch { - return []; + private loadEntry(key: string): CacheEntry | undefined { + const memory = this.memoryEntry(key); + if (memory) return memory; + const legacy = this.readLegacy().entries[key]; + const shard = this.readShard(key); + const entry = newerEntry(legacy, shard); + if (entry) this.remember(key, entry); + return entry; + } + + private async loadEntryAsync(key: string): Promise { + const pending = this.pendingShardReads.get(key); + if (pending) return pending.promise; + // Cache misses can proceed to the directory instead of growing an unbounded + // queue of disk reads. No negative entry prevents a later shard discovery. + if (this.pendingShardReads.size >= (this.options.maxPendingOperations ?? 16)) return undefined; + const record: PendingRead = {invalidated: false, promise: Promise.resolve(undefined)}; + const work = async (): Promise => { + do { + record.invalidated = false; + const [legacy, shard] = await Promise.all([this.readLegacyAsync(), this.readShardAsync(key)]); + if (!record.invalidated) { + const entry = newerEntry(legacy.entries[key], shard); + if (entry) { + const serialized = await stringifyJsonAsync(entry, isLargeValue(entry.value)); + if (record.invalidated) { + await this.pendingWrites.get(key)?.catch(() => undefined); + const current = this.memoryEntry(key); + if (current) return current; + continue; + } + this.remember(key, entry, retainedBytes(serialized)); + } + return entry; } - }) - .sort((left, right) => right.modified - left.modified); - let totalBytes = files.reduce((total, file) => total + file.bytes, 0); - while (files.length > maxCacheEntries || totalBytes > maxCacheBytes) { + await this.pendingWrites.get(key)?.catch(() => undefined); + const current = this.memoryEntry(key); + if (current) return current; + } while (true); + }; + record.promise = work().finally(() => { if (this.pendingShardReads.get(key) === record) this.pendingShardReads.delete(key); }); + this.pendingShardReads.set(key, record); + return record.promise; + } + + private readLegacy(): CacheEnvelope { + if (this.legacyAbsent) return emptyEnvelope(); + try { + if (statSync(this.filePath).size > (this.options.maxDiskBytes ?? maxCacheBytes)) return emptyEnvelope(); + return validEnvelope(JSON.parse(readFileSync(this.filePath, 'utf8'))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') this.legacyAbsent = true; + else if (error instanceof SyntaxError) backupBadFile(this.filePath); + return emptyEnvelope(); + } + } + + private readLegacyAsync(): Promise { + if (this.legacyAbsent) return Promise.resolve(emptyEnvelope()); + if (this.legacyLoadPromise) return this.legacyLoadPromise; + const reading = (async () => { + try { + if ((await stat(this.filePath)).size > (this.options.maxDiskBytes ?? maxCacheBytes)) return emptyEnvelope(); + return validEnvelope(await parseJsonAsync(await readFile(this.filePath, 'utf8'))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') this.legacyAbsent = true; + else if (error instanceof SyntaxError) await rename(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`).catch(() => undefined); + return emptyEnvelope(); + } + })(); + const tracked = reading.finally(() => { if (this.legacyLoadPromise === tracked) this.legacyLoadPromise = null; }); + this.legacyLoadPromise = tracked; + return tracked; + } + + private readShard(key: string): CacheEntry | undefined { + try { + const path = this.shardPath(key); + if (statSync(path).size > (this.options.maxDiskBytes ?? maxCacheBytes)) return undefined; + return validShard(JSON.parse(readFileSync(path, 'utf8')), key); + } catch { return undefined; } + } + + private async readShardAsync(key: string): Promise { + try { + const path = this.shardPath(key); + if ((await stat(path)).size > (this.options.maxDiskBytes ?? maxCacheBytes)) return undefined; + return validShard(await parseJsonAsync(await readFile(path, 'utf8')), key); + } catch { return undefined; } + } + + private async pruneShardsAsync(directory: string): Promise { + const names = (await readdir(directory)).filter(name => name.endsWith('.json')); + const files: DiskEntry[] = []; + for (let index = 0; index < names.length; index += 32) { + const entries = await Promise.all(names.slice(index, index + 32).map(async name => { + const path = join(directory, name); + try { const info = await stat(path); return {path, bytes: info.size, modified: info.mtimeMs, inode: info.ino}; } + catch { return null; } + })); + files.push(...entries.filter((entry): entry is DiskEntry => entry !== null)); + } + files.sort((left, right) => right.modified - left.modified); + let totalBytes = files.reduce((sum, file) => sum + file.bytes, 0); + while (files.length > (this.options.maxDiskEntries ?? maxCacheEntries) || totalBytes > (this.options.maxDiskBytes ?? maxCacheBytes)) { const file = files.pop(); if (!file) break; try { - unlinkSync(file.path); + const current = await stat(file.path); + if (current.ino !== file.inode || current.mtimeMs !== file.modified || current.size !== file.bytes) continue; + await unlink(file.path); totalBytes -= file.bytes; - } catch { - // Cache eviction is best-effort. - } + } catch { /* Another process may have refreshed or evicted the shard. */ } } } - private shardDirectory(): string { - return `${this.filePath}.d`; + private pruneShards(directory: string): void { + const files = readdirSync(directory).filter(name => name.endsWith('.json')).flatMap(name => { + const path = join(directory, name); + try { const info = statSync(path); return [{path, bytes: info.size, modified: info.mtimeMs}]; } + catch { return []; } + }).sort((left, right) => right.modified - left.modified); + let totalBytes = files.reduce((sum, file) => sum + file.bytes, 0); + while (files.length > (this.options.maxDiskEntries ?? maxCacheEntries) || totalBytes > (this.options.maxDiskBytes ?? maxCacheBytes)) { + const file = files.pop(); + if (!file) break; + try { unlinkSync(file.path); totalBytes -= file.bytes; } catch { /* Cache eviction is best-effort. */ } + } } + + private shardDirectory(): string { return `${this.filePath}.d`; } + private shardPath(key: string): string { return join(this.shardDirectory(), `${createHash('sha256').update(key).digest('hex')}.json`); } } +function retainedBytes(serialized: string): number { return serialized.length * 2 + 128; } +function isLargeValue(value: unknown): boolean { return Array.isArray(value) && value.length >= 1000 || typeof value === 'string' && value.length >= 256 * 1024; } +function emptyEnvelope(): CacheEnvelope { return {version: 1, entries: Object.create(null) as Record}; } +function validEnvelope(value: unknown): CacheEnvelope { + const envelope = value as Partial | null; + if (envelope?.version !== 1 || !envelope.entries || typeof envelope.entries !== 'object' || Array.isArray(envelope.entries)) return emptyEnvelope(); + const entries: Record = Object.create(null) as Record; + for (const [key, entry] of Object.entries(envelope.entries)) if (entry && Number.isFinite(entry.createdAt) && 'value' in entry) entries[key] = entry; + return {version: 1, entries}; +} +function validShard(value: unknown, key: string): CacheEntry | undefined { + const entry = value as Partial | null; + return entry?.version === 1 && entry.key === key && Number.isFinite(entry.createdAt) && 'value' in entry ? {createdAt: entry.createdAt!, value: entry.value} : undefined; +} +function newerEntry(legacy: CacheEntry | undefined, shard: CacheEntry | undefined): CacheEntry | undefined { + const now = Date.now(); + if (legacy && !withinAge(legacy, Infinity, now)) legacy = undefined; + if (shard && !withinAge(shard, Infinity, now)) shard = undefined; + return shard && (!legacy || shard.createdAt >= legacy.createdAt) ? shard : legacy; +} +function withinAge(entry: CacheEntry, maxAgeMs: number, now = Date.now()): boolean { + const age = now - entry.createdAt; + return age >= -maxFutureClockSkewMs && Math.max(0, age) <= maxAgeMs; +} function defaultProviderCachePath(): string { const {cache, legacyCache} = platformPaths(); return existsSync(cache) || !existsSync(legacyCache) ? cache : legacyCache; } - -function writeJsonAtomically(filePath: string, value: unknown): void { - const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; - try { - writeFileSync(tempPath, `${JSON.stringify(value)}\n`, {encoding: 'utf8', mode: 0o600}); - renameSync(tempPath, filePath); - if (process.platform !== 'win32') { - chmodSync(filePath, 0o600); - } - } catch (error) { - rmSync(tempPath, {force: true}); - throw error; - } +function writeAtomically(path: string, serialized: string): void { + const temp = `${path}.tmp-${process.pid}-${randomUUID()}`; + try { writeFileSync(temp, `${serialized}\n`, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); renameSync(temp, path); } + catch (error) { try { rmSync(temp, {force: true}); } catch {} throw error; } } - -function backupBadFile(filePath: string): void { - if (!existsSync(filePath)) { - return; - } - - const backupPath = `${filePath}.bad-${new Date().toISOString().replace(/[:.]/g, '-')}`; - try { - renameSync(filePath, backupPath); - } catch { - // If backup fails, leave the original in place and continue with defaults in memory. - } +function backupBadFile(path: string): void { + try { renameSync(path, `${path}.bad-${Date.now()}-${randomUUID()}`); } catch { /* Cache data is recoverable. */ } } diff --git a/src/providers/discovery-session.test.ts b/src/providers/discovery-session.test.ts new file mode 100644 index 0000000..0bb4626 --- /dev/null +++ b/src/providers/discovery-session.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, it} from 'vitest'; +import type {DiscoveryPage} from '../types.js'; +import {DiscoverySessions} from './discovery-session.js'; + +type State = {index: number}; +const create = (): State => ({index: 0}); +const produce = async (state: State): Promise => ({items: [{id: String(state.index++), provider: 'playlist', name: 'Station', tags: []}], exhausted: false, sources: []}); + +describe('bounded discovery sessions', () => { + it('deduplicates overlapping calls for a cursor and protects stored replay data', async () => { + const sessions = new DiscoverySessions(() => 64); + const first = await sessions.page('query', undefined, create, produce); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let calls = 0; + const delayed = async (state: State) => { calls += 1; await gate; return produce(state); }; + const left = sessions.page('query', first.nextCursor, create, delayed); + const right = sessions.page('query', first.nextCursor, create, delayed); + release(); + const [a, b] = await Promise.all([left, right]); + expect(calls).toBe(1); + expect(a.items[0]?.id).toBe('1'); + a.items[0]!.name = 'Changed by caller'; + expect(b.items[0]?.name).toBe('Station'); + expect((await sessions.page('query', first.nextCursor, create, produce)).items[0]?.name).toBe('Station'); + }); + + it('expires idle sessions and evicts the least recently used session by count', async () => { + let now = 0; + const sessions = new DiscoverySessions(() => 64, {maxSessions: 2, ttlMs: 100, now: () => now}); + const first = await sessions.page('first', undefined, create, produce); + now = 1; + const second = await sessions.page('second', undefined, create, produce); + now = 2; + await sessions.page('third', undefined, create, produce); + await expect(sessions.page('first', first.nextCursor, create, produce)).rejects.toThrow(/expired/); + now = 101; + await expect(sessions.page('second', second.nextCursor, create, produce)).rejects.toThrow(/expired/); + }); + + it('rejects over-budget state without committing its mutation', async () => { + const sessions = new DiscoverySessions(state => state.index > 1 ? 10_000 : 64, {maxBytes: 1000}); + const first = await sessions.page('query', undefined, create, produce); + await expect(sessions.page('query', first.nextCursor, create, produce)).rejects.toThrow(/capacity/); + const retried = await sessions.page('query', first.nextCursor, create, async state => ({items: [], exhausted: state.index === 1, sources: []})); + expect(retried.exhausted).toBe(true); + }); +}); diff --git a/src/providers/discovery-session.ts b/src/providers/discovery-session.ts new file mode 100644 index 0000000..bec1b3c --- /dev/null +++ b/src/providers/discovery-session.ts @@ -0,0 +1,122 @@ +import {randomUUID} from 'node:crypto'; +import type {DiscoveryPage} from '../types.js'; + +type CursorSession = { + id: string; + fingerprint: string; + state: State; + nextPage: number; + touchedAt: number; + bytes: number; + last?: {index: number; page: DiscoveryPage}; + pending?: {index: number; promise: Promise}; +}; + +/** Cursor state is private, bounded, and committed only after a complete page. + * One-page replay makes retries safe without retaining every returned station. */ +export class DiscoverySessions { + private readonly sessions = new Map>(); + + constructor( + private readonly sizeOf: (state: State) => number, + private readonly options: {maxSessions?: number; maxBytes?: number; ttlMs?: number; now?: () => number} = {} + ) {} + + async page( + fingerprint: string, + cursor: string | undefined, + create: () => State, + produce: (state: State) => Promise, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted(); + const now = this.options.now?.() ?? Date.now(); + this.prune(now); + let session: CursorSession; + let index = 0; + if (cursor) { + const match = /^([a-f0-9-]{36}):(\d+)$/.exec(cursor); + const found = match ? this.sessions.get(match[1]!) : undefined; + index = Number(match?.[2]); + if (!found || !Number.isSafeInteger(index)) throw cursorError(); + if (found.fingerprint !== fingerprint) throw new Error('Discovery cursor does not match this query or its filters. Start the search again.'); + session = found; + } else { + this.makeRoom(1, 0); + const state = create(); + session = {id: randomUUID(), fingerprint, state, nextPage: 0, touchedAt: now, bytes: this.sizeOf(state)}; + this.sessions.set(session.id, session); + } + session.touchedAt = now; + if (session.last?.index === index) return structuredClone(session.last.page); + if (session.pending?.index === index) return structuredClone(await abortable(session.pending.promise, signal)); + if (index !== session.nextPage || session.pending) throw cursorError(); + + // Keep offsets and overflow intact if a request fails or its caller cancels. + const draft = structuredClone(session.state); + const work = (async () => { + const page = await produce(draft); + signal?.throwIfAborted(); + const bytes = this.sizeOf(draft) + JSON.stringify(page).length * 2; + this.makeRoom(0, bytes - session.bytes, session.id); + const result: DiscoveryPage = { + ...page, + nextCursor: page.exhausted ? undefined : `${session.id}:${index + 1}` + }; + session.state = draft; + session.bytes = bytes; + session.nextPage = index + 1; + session.last = {index, page: result}; + return result; + })(); + session.pending = {index, promise: work}; + try { + return structuredClone(await work); + } catch (error) { + if (session.nextPage === 0) this.sessions.delete(session.id); + throw error; + } finally { + if (session.pending?.promise === work) session.pending = undefined; + } + } + + private prune(now: number): void { + const ttl = this.options.ttlMs ?? 10 * 60_000; + for (const [id, session] of this.sessions) { + if (!session.pending && (now < session.touchedAt || now - session.touchedAt >= ttl)) this.sessions.delete(id); + } + } + + private makeRoom(additionalCount: number, additionalBytes: number, protectedId?: string): void { + const maxSessions = this.options.maxSessions ?? 16; + const maxBytes = this.options.maxBytes ?? 32 * 1024 * 1024; + let bytes = [...this.sessions.values()].reduce((sum, entry) => sum + entry.bytes, 0) + additionalBytes; + const candidates = [...this.sessions.values()] + .filter(entry => entry.id !== protectedId && !entry.pending) + .sort((left, right) => left.touchedAt - right.touchedAt); + while (this.sessions.size + additionalCount > maxSessions || bytes > maxBytes) { + const oldest = candidates.shift(); + if (!oldest) throw new Error('Discovery session capacity reached. Finish another search or start a narrower query.'); + this.sessions.delete(oldest.id); + bytes -= oldest.bytes; + } + } +} + +/** Abort the wait even when an injected provider or DNS resolver ignores signal. */ +export function abortable(work: Promise, signal?: AbortSignal): Promise { + if (!signal) return work; + if (signal.aborted) { + void work.catch(() => undefined); + return Promise.reject(signal.reason); + } + return new Promise((resolve, reject) => { + const aborted = () => reject(signal.reason); + signal.addEventListener('abort', aborted, {once: true}); + work.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted)); + }); +} + +function cursorError(): Error { + return new Error('Discovery cursor expired or is no longer available. Start the search again.'); +} diff --git a/src/providers/provider-manager.test.ts b/src/providers/provider-manager.test.ts new file mode 100644 index 0000000..794d1ae --- /dev/null +++ b/src/providers/provider-manager.test.ts @@ -0,0 +1,132 @@ +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import type {AppSettings, DiscoveryPage} from '../types.js'; +import {ProviderCache} from './cache.js'; +import {ProviderManager} from './provider-manager.js'; +import {RadioBrowserProvider} from './radio-browser.js'; +import {RadioGardenProvider} from './radio-garden.js'; + +const roots: string[] = []; +const settings = {enableRadioGarden: true} as AppSettings; +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); + +describe('federated discovery cursors', () => { + it('retains Browser overflow after a mixed page instead of skipping rows 60–89', async () => { + const fetch = catalog(180, 30); + const providers = manager(); + const first = await providers.searchPage('Jazz', settings, {limit: 90}); + expect(first.items.filter(station => station.provider === 'radio-browser')).toHaveLength(60); + const second = await providers.searchPage('Jazz', settings, {limit: 90, cursor: first.nextCursor}); + expect(second.items[0]?.id).toBe('s60'); + expect(second.items.map(item => item.id)).toEqual(Array.from({length: 90}, (_, index) => `s${index + 60}`)); + const third = await providers.searchPage('Jazz', settings, {limit: 90, cursor: second.nextCursor}); + expect(third.items.map(item => item.id)).toEqual(Array.from({length: 30}, (_, index) => `s${index + 150}`)); + expect(third.exhausted).toBe(true); + const all = [first, second, third].flatMap(page => page.items); + expect(all).toHaveLength(210); + expect(new Set(all.map(station => `${station.provider}:${station.id}`)).size).toBe(210); + expect(fetch.mock.calls.filter(([url]) => String(url).startsWith('https://garden.example'))).toHaveLength(1); + expect(third.sources.find(source => source.provider === 'radio-garden')).toMatchObject({source: 'search-snapshot', consumed: 30, exhausted: true}); + }); + + it('drains all Garden response hits beyond the first page without refetching the snapshot', async () => { + const fetch = catalog(12, 100); + const providers = manager(); + const pages: DiscoveryPage[] = []; + let cursor: string | undefined; + do { + const page = await providers.searchPage('Jazz', settings, {limit: 10, cursor}); + pages.push(page); + cursor = page.nextCursor; + if (pages.length > 15) throw new Error('Pagination did not terminate.'); + } while (cursor); + const all = pages.flatMap(page => page.items); + expect(all.filter(station => station.provider === 'radio-browser')).toHaveLength(12); + expect(all.filter(station => station.provider === 'radio-garden')).toHaveLength(100); + expect(new Set(all.map(station => `${station.provider}:${station.id}`)).size).toBe(112); + expect(fetch.mock.calls.filter(([url]) => String(url).startsWith('https://garden.example'))).toHaveLength(1); + }); + + it('keeps legacy displayed offsets correct when experimental results are enabled', async () => { + catalog(180, 30); + const providers = manager(); + const first = await providers.search('Jazz', settings, {limit: 90}); + const second = await providers.search('Jazz', settings, {limit: 90, offset: 90}); + expect(first).toHaveLength(90); + expect(second.map(item => item.id)).toEqual(Array.from({length: 90}, (_, index) => `s${index + 60}`)); + }); + + it('does not admit unverified Garden stations through explicit bitrate, codec, language or country filters', async () => { + catalog(12, 10); + const providers = manager(); + for (const filters of [{minBitrate: 128}, {codec: 'MP3'}, {language: 'English'}, {countryCode: 'US'}]) { + const page = await providers.searchPage('Jazz', settings, {limit: 4, ...filters}); + expect(page.items).toHaveLength(4); + expect(page.items.every(station => station.provider === 'radio-browser')).toBe(true); + } + }); + + it('retries a failed optional source without losing the primary continuation', async () => { + let failGarden = true; + catalog(6, 2, () => { if (failGarden) throw new Error('Garden temporarily unavailable'); }); + const providers = manager(); + const first = await providers.searchPage('Jazz', settings, {limit: 4}); + expect(first.items.map(station => station.id)).toEqual(['s0', 's1', 's2', 's3']); + expect(first.sources.find(source => source.provider === 'radio-garden')).toMatchObject({status: 'unavailable', exhausted: false}); + failGarden = false; + const second = await providers.searchPage('Jazz', settings, {limit: 4, cursor: first.nextCursor}); + expect(new Set(second.items.map(station => station.id))).toEqual(new Set(['s4', 's5', 'g0', 'g1'])); + expect(second.exhausted).toBe(true); + }); + + it('cancels both provider requests rather than waiting for their full deadlines', async () => { + const signals: AbortSignal[] = []; + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + vi.stubGlobal('fetch', vi.fn((_url: unknown, init?: RequestInit) => { + signals.push(init!.signal!); + if (signals.length === 3) started(); + return new Promise(() => undefined); + })); + const controller = new AbortController(); + const request = manager().searchPage('Jazz', settings, {limit: 4, signal: controller.signal}); + await ready; + controller.abort(); + await expect(request).rejects.toMatchObject({name: 'AbortError'}); + expect(signals).toHaveLength(3); + expect(signals.every(signal => signal.aborted)).toBe(true); + }); +}); + +function manager(): ProviderManager { + const root = mkdtempSync(join(tmpdir(), 'radiocli-federation-')); + roots.push(root); + return new ProviderManager( + new RadioBrowserProvider(['https://directory.example'], new ProviderCache(join(root, 'cache.json'))), + new RadioGardenProvider('https://garden.example/api') + ); +} + +function catalog(browserCount: number, gardenCount: number, beforeGarden?: () => void) { + const rows = Array.from({length: browserCount}, (_, index) => ({stationuuid: `s${index}`, name: `Jazz ${index}`, clickcount: 1000 - index, codec: 'MP3', language: 'English', countrycode: 'US', bitrate: 192})); + const hits = Array.from({length: gardenCount}, (_, index) => ({_source: {type: 'channel', title: `Garden ${index}`, url: `/listen/jazz/g${index}`}})); + const fetch = vi.fn((input: string | URL) => { + const url = new URL(String(input)); + if (url.hostname === 'garden.example') { + beforeGarden?.(); + return Response.json({hits}); + } + const offset = Number(url.searchParams.get('offset')); + const limit = Number(url.searchParams.get('limit')); + return Response.json(rows.slice(offset, offset + limit)); + }); + vi.stubGlobal('fetch', fetch); + return fetch; +} diff --git a/src/providers/provider-manager.ts b/src/providers/provider-manager.ts index dfc33d3..7acdd5e 100644 --- a/src/providers/provider-manager.ts +++ b/src/providers/provider-manager.ts @@ -1,8 +1,24 @@ -import {RadioBrowserProvider, dedupeStations} from './radio-browser.js'; +import {RadioBrowserProvider} from './radio-browser.js'; import {RadioGardenProvider} from './radio-garden.js'; -import type {AppSettings, Country, LocationGuess, ResolvedStream, SearchOptions, Station} from '../types.js'; +import {abortable, DiscoverySessions} from './discovery-session.js'; +import type {AppSettings, Country, DiscoveryPage, DiscoveryPageOptions, DiscoverySourceStatus, LocationGuess, ResolvedStream, SearchOptions, Station} from '../types.js'; + +type FederatedSearch = { + browserCursor?: string; + browserExhausted: boolean; + browser: Station[]; + browserSources: DiscoverySourceStatus[]; + gardenLoaded: boolean; + garden: Station[]; + gardenSource: DiscoverySourceStatus; + seen: Set; + primaryTurn: boolean; +}; export class ProviderManager { + private readonly discovery = new DiscoverySessions(state => + JSON.stringify([state.browser, state.garden, state.browserSources, state.gardenSource, [...state.seen]]).length * 2 + state.seen.size * 128 + ); constructor( private readonly radioBrowser = new RadioBrowserProvider(), private readonly radioGarden = new RadioGardenProvider() @@ -20,44 +36,119 @@ export class ProviderManager { return this.radioBrowser.byCountry(countryCode, limit, offset); } + byCountryPage(countryCode: string, options: Pick = {}): Promise { + return this.radioBrowser.byCountryPage(countryCode, options); + } + byId(provider: Station['provider'], id: string): Promise { if (provider !== 'radio-browser') return Promise.resolve(null); return this.radioBrowser.byId(id); } - nearby(location: LocationGuess, limit?: number): Promise { - return this.radioBrowser.nearby(location, limit); + nearby(location: LocationGuess, limit?: number, options: {signal?: AbortSignal} = {}): Promise { + return this.radioBrowser.nearby(location, limit, options); } - detectLocation(): Promise { - return this.radioBrowser.detectLocation(); + detectLocation(options: {signal?: AbortSignal} = {}): Promise { + return this.radioBrowser.detectLocation(options); } async search(query: string, settings: AppSettings, options: SearchOptions = {}): Promise { - const includeGarden = (settings.enableRadioGarden || options.includeExperimental) && (options.offset ?? 0) === 0; + const includeGarden = settings.enableRadioGarden || options.includeExperimental; if (!includeGarden) { return this.radioBrowser.search(query, options); } + // Legacy array callers may still pass a displayed offset. Traverse the + // merged cursor so those offsets cannot skip Browser rows after Garden. + // Interactive clients keep nextCursor and avoid this repeated traversal. + let skip = Math.max(0, options.offset ?? 0); + const limit = options.limit ?? 80; + const items: Station[] = []; + let cursor: string | undefined; + do { + const page = await this.searchPage(query, settings, {...options, limit, cursor}); + const start = Math.min(skip, page.items.length); + skip -= start; + items.push(...page.items.slice(start, start + limit - items.length)); + cursor = page.nextCursor; + } while (items.length < limit && cursor); + return items; + } + + searchPage(query: string, settings: AppSettings, options: DiscoveryPageOptions = {}): Promise { + const includeGarden = settings.enableRadioGarden || options.includeExperimental; + if (!includeGarden) return this.radioBrowser.searchPage(query, options); + const limit = options.limit ?? 80; + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error('Discovery page size must be between 1 and 1000.'); + const fingerprint = JSON.stringify([query.trim(), options.codec, options.language, options.countryCode?.toUpperCase(), options.minBitrate, limit]); + return this.discovery.page(fingerprint, options.cursor, () => ({ + browserExhausted: false, browser: [], browserSources: [], + gardenLoaded: false, garden: [], + gardenSource: {provider: 'radio-garden', source: 'search-snapshot', consumed: 0, exhausted: false, status: 'available'}, + seen: new Set(), primaryTurn: true + }), state => this.federatedPage(state, query, limit, options), options.signal); + } + + private async federatedPage(state: FederatedSearch, query: string, limit: number, options: DiscoveryPageOptions): Promise { + const fetchBrowser = !state.browserExhausted && state.browser.length < limit; + const fetchGarden = !state.gardenLoaded; const [browserResult, gardenResult] = await Promise.allSettled([ - this.radioBrowser.search(query, options), - this.radioGarden.search(query, options) + fetchBrowser ? abortable(this.radioBrowser.searchPage(query, {...options, limit, cursor: state.browserCursor}), options.signal) : Promise.resolve(null), + // Garden currently exposes one finite search response, without a known + // offset contract. Retain all returned hits and page that snapshot. + fetchGarden ? abortable(this.radioGarden.search(query, {...options, limit: Number.MAX_SAFE_INTEGER}), options.signal) : Promise.resolve(null) ]); - const radioBrowser = browserResult.status === 'fulfilled' ? browserResult.value : []; - const radioGarden = gardenResult.status === 'fulfilled' ? gardenResult.value : []; - if (radioBrowser.length === 0 && radioGarden.length === 0 && browserResult.status === 'rejected') { - throw browserResult.reason; + options.signal?.throwIfAborted(); + if (browserResult.status === 'fulfilled' && browserResult.value) { + const page = browserResult.value; + state.browserCursor = page.nextCursor; + state.browserExhausted = page.exhausted; + state.browserSources = page.sources; + state.browser.push(...page.items); + } else if (browserResult.status === 'rejected') { + state.browserSources = state.browserSources.length + ? state.browserSources.map(source => source.exhausted ? source : {...source, status: 'unavailable', message: errorText(browserResult.reason)}) + : [{provider: 'radio-browser', source: 'search', consumed: 0, exhausted: false, status: 'unavailable', message: errorText(browserResult.reason)}]; + } + if (gardenResult.status === 'fulfilled' && gardenResult.value) { + state.gardenLoaded = true; + state.gardenSource = {...state.gardenSource, status: 'available', message: undefined, consumed: gardenResult.value.length, exhausted: true}; + state.garden = gardenResult.value.filter(station => matchesFilters(station, options)); + } else if (gardenResult.status === 'rejected') { + state.gardenSource = {...state.gardenSource, status: 'unavailable', message: errorText(gardenResult.reason)}; } - return dedupeStations(interleave(radioBrowser, radioGarden)).slice(0, options.limit ?? 80); + const items: Station[] = []; + while (items.length < limit && (state.browser.length || state.garden.length)) { + const primary = state.primaryTurn ? state.browser : state.garden; + const secondary = state.primaryTurn ? state.garden : state.browser; + const station = primary.shift() ?? secondary.shift(); + state.primaryTurn = !state.primaryTurn; + if (!station) break; + const key = `${station.provider}:${station.id}`; + if (state.seen.has(key)) continue; + state.seen.add(key); + items.push(station); + } + if (!items.length) { + if (browserResult.status === 'rejected') throw browserResult.reason; + if (gardenResult.status === 'rejected') throw gardenResult.reason; + } + return { + items, + exhausted: state.browserExhausted && state.gardenLoaded && state.browser.length === 0 && state.garden.length === 0, + sources: [...state.browserSources, state.gardenSource] + }; } - async resolve(station: Station): Promise { + async resolve(station: Station, options: {signal?: AbortSignal} = {}): Promise { + options.signal?.throwIfAborted(); if (station.provider === 'radio-garden') { - return this.radioGarden.resolve(station); + return abortable(this.radioGarden.resolve(station, options), options.signal); } - return this.radioBrowser.resolve(station); + return abortable(this.radioBrowser.resolve(station, options), options.signal); } vote(station: Station): Promise { @@ -79,14 +170,13 @@ export class ProviderManager { } } -function interleave(primary: Station[], secondary: Station[]): Station[] { - const merged: Station[] = []; - const length = Math.max(primary.length, secondary.length); - for (let index = 0; index < length; index += 1) { - const primaryStation = primary[index]; - const secondaryStation = secondary[index]; - if (primaryStation) merged.push(primaryStation); - if (secondaryStation) merged.push(secondaryStation); - } - return merged; +function matchesFilters(station: Station, options: SearchOptions): boolean { + return !(options.minBitrate && (station.bitrate ?? 0) < options.minBitrate) + && !(options.codec && station.codec?.toLowerCase() !== options.codec.toLowerCase()) + && !(options.language && !station.language?.toLowerCase().includes(options.language.toLowerCase())) + && !(options.countryCode && station.countryCode?.toUpperCase() !== options.countryCode.toUpperCase()); +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); } diff --git a/src/providers/radio-browser-pagination.test.ts b/src/providers/radio-browser-pagination.test.ts new file mode 100644 index 0000000..be6643d --- /dev/null +++ b/src/providers/radio-browser-pagination.test.ts @@ -0,0 +1,266 @@ +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import type {DiscoveryPage, Station} from '../types.js'; +import {ProviderCache} from './cache.js'; +import {RadioBrowserProvider} from './radio-browser.js'; + +const roots: string[] = []; +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); + +describe('Radio Browser discovery cursors', () => { + it('cancels location lookup and an abandoned atlas request without returning a fallback result', async () => { + for (const operation of ['location', 'nearby'] as const) { + const controller = new AbortController(); + let start!: (signal: AbortSignal) => void; + const started = new Promise(resolve => { start = resolve; }); + vi.stubGlobal('fetch', vi.fn((_url, init) => new Promise((_resolve, reject) => { + start(init.signal); + init.signal.addEventListener('abort', () => reject(init.signal.reason), {once: true}); + }))); + const browser = provider(); + const pending = operation === 'location' ? browser.detectLocation({signal: controller.signal}) + : browser.nearby({latitude: 48, longitude: 2, source: 'test'}, 10, {signal: controller.signal}); + const assertion = expect(pending).rejects.toMatchObject({name: 'AbortError'}); + const signal = await started; + controller.abort(); + await assertion; + expect(signal.aborted).toBe(true); + } + }); + + it('keeps a shared atlas request alive for the remaining nearby caller', async () => { + let respond!: (response: Response) => void; + let start!: () => void; + const started = new Promise(resolve => { start = resolve; }); + let requestSignal: AbortSignal | undefined; + const fetch = vi.fn((_url, init) => new Promise(resolve => { + requestSignal = init.signal; respond = resolve; start(); + })); + vi.stubGlobal('fetch', fetch); + const browser = provider(); + const controller = new AbortController(); + const location = {latitude: 48, longitude: 2, source: 'test'}; + const first = browser.nearby(location, 1, {signal: controller.signal}); + const assertion = expect(first).rejects.toMatchObject({name: 'AbortError'}); + const second = browser.nearby(location, 1); + await started; + controller.abort(); + await assertion; + expect(requestSignal?.aborted).toBe(false); + respond(new Response(JSON.stringify([{...row(1), geo_lat: 48, geo_long: 2}]))); + expect(await second).toMatchObject([{id: 's1'}]); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('rejects an oversized atlas instead of silently truncating nearby coverage', async () => { + serve(() => new Response(JSON.stringify(Array.from({length: 100_001}, () => null)))); + await expect(provider().nearby({latitude: 48, longitude: 2, source: 'test'})).rejects.toThrow('row limit'); + }); + + it('applies minimum bitrate before the directory limit in both APIs', async () => { + const rows = Array.from({length: 180}, (_, index) => row(index, {bitrate: index < 90 ? 32 : 128})); + const fetch = serve(url => { + expect(url.searchParams.get('bitrateMin')).toBe('128'); + return slice(rows.filter(item => item.bitrate >= Number(url.searchParams.get('bitrateMin'))), url); + }); + const browser = provider(); + expect((await browser.search('Jazz', {limit: 90, minBitrate: 128})).map(item => item.id)).toEqual(rows.slice(90).map(item => item.stationuuid)); + const first = await browser.searchPage('Jazz', {limit: 90, minBitrate: 128}); + expect(first.items).toHaveLength(90); + expect(first.exhausted).toBe(false); + const final = await browser.searchPage('Jazz', {limit: 90, minBitrate: 128, cursor: first.nextCursor}); + expect(final).toMatchObject({items: [], exhausted: true}); + expect(final.nextCursor).toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(4); + }); + + it('consumes each name/tag source independently and retains ranked overflow', async () => { + const all = Array.from({length: 30}, (_, index) => row(index)); + const offsets: Record = {name: [], tag: []}; + serve(url => { + const source = url.searchParams.has('name') ? 'name' : 'tag'; + offsets[source]!.push(Number(url.searchParams.get('offset'))); + return slice(source === 'name' ? all.slice(0, 18) : all.slice(12), url); + }); + const browser = provider(); + const pages = await drain(cursor => browser.searchPage('Jazz', {limit: 5, cursor})); + const ids = pages.flatMap(page => page.items.map(item => item.id)); + expect(ids).toHaveLength(30); + expect(new Set(ids)).toEqual(new Set(all.map(item => item.stationuuid))); + expect(pages[0]?.items.map(item => item.id)).toEqual(all.slice(0, 5).map(item => item.stationuuid)); + expect(offsets).toEqual({name: [0, 5, 10, 15], tag: [0, 5, 10, 15]}); + expect(pages.at(-1)?.sources).toEqual([ + {provider: 'radio-browser', source: 'name', consumed: 18, exhausted: true, status: 'available', message: undefined}, + {provider: 'radio-browser', source: 'tag', consumed: 18, exhausted: true, status: 'available', message: undefined} + ]); + }); + + it('advances country offsets by raw rows when normalization and deduplication remove entries', async () => { + const rows: unknown[] = [row(0), null, row(0), {bad: true}, row(1), row(2), row(3), row(3), row(4), row(5)]; + const offsets: number[] = []; + serve(url => { + expect(url.searchParams.get('countrycode')).toBe('US'); + offsets.push(Number(url.searchParams.get('offset'))); + return slice(rows, url); + }); + const browser = provider(); + const pages = await drain(cursor => browser.byCountryPage('us', {limit: 3, cursor})); + expect(pages.flatMap(page => page.items.map(item => item.id))).toEqual(['s0', 's1', 's2', 's3', 's4', 's5']); + expect(offsets).toEqual([0, 3, 6, 9]); + expect(pages.at(-1)?.sources[0]).toMatchObject({consumed: 10, exhausted: true}); + }); + + it('returns a continuation after a bounded scan of invalid rows', async () => { + const rows = [...Array.from({length: 12}, () => ({bad: true})), row(99)]; + serve(url => slice(rows, url)); + const browser = provider(); + const first = await browser.byCountryPage('US', {limit: 3}); + expect(first).toMatchObject({items: [], exhausted: false, nextCursor: expect.any(String)}); + expect(first.sources[0]?.consumed).toBe(12); + const second = await browser.byCountryPage('US', {limit: 3, cursor: first.nextCursor}); + expect(second.items.map(item => item.id)).toEqual(['s99']); + expect(second.exhausted).toBe(true); + }); + + it('keeps unavailable sources retryable while returning useful partial results', async () => { + let failTag = true; + serve(url => { + if (url.searchParams.has('tag')) { + if (failTag) throw new Error('directory offline'); + return slice([row(20), row(21)], url); + } + return slice([row(0), row(1), row(2)], url); + }); + const browser = provider(); + const first = await browser.searchPage('Jazz', {limit: 2}); + expect(first.items.map(item => item.id)).toEqual(['s0', 's1']); + expect(first.sources.find(source => source.source === 'tag')).toMatchObject({consumed: 0, exhausted: false, status: 'unavailable'}); + failTag = false; + const remaining = await drain(cursor => browser.searchPage('Jazz', {limit: 2, cursor}), first.nextCursor); + expect(remaining.flatMap(page => page.items.map(item => item.id))).toEqual(['s2', 's20', 's21']); + expect(remaining.at(-1)?.sources.every(source => source.status === 'available')).toBe(true); + }); + + it('rejects filter changes against an existing cursor', async () => { + serve(url => slice(Array.from({length: 5}, (_, index) => row(index)), url)); + const browser = provider(); + const first = await browser.searchPage('Jazz', {limit: 2}); + await expect(browser.searchPage('Jazz', {limit: 2, minBitrate: 128, cursor: first.nextCursor})).rejects.toThrow(/does not match/); + }); + + it('aborts a resolve without converting cancellation into fallback playback', async () => { + const controller = new AbortController(); + const reason = new DOMException('User stopped playback', 'AbortError'); + let observedSignal: AbortSignal | null | undefined; + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + vi.stubGlobal('fetch', vi.fn((_url: unknown, init?: RequestInit) => { + observedSignal = init?.signal; + started(); + return new Promise(() => undefined); + })); + const resolving = provider().resolve({id: 'one', name: 'One', provider: 'radio-browser', tags: [], streamUrl: 'https://saved.example/live'}, {signal: controller.signal}); + await ready; + controller.abort(reason); + await expect(resolving).rejects.toBe(reason); + expect(observedSignal?.aborted).toBe(true); + }); + + it('coalesces matching reads without letting one cancelled caller abort another', async () => { + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + let release!: (response: Response) => void; + let sharedSignal: AbortSignal | null | undefined; + const fetch = vi.fn((_url: unknown, init?: RequestInit) => { + sharedSignal = init?.signal; + started(); + return new Promise(resolve => { release = resolve; }); + }); + vi.stubGlobal('fetch', fetch); + const browser = provider(); + const controller = new AbortController(); + const first = browser.byCountryPage('US', {limit: 3, signal: controller.signal}); + await ready; + const second = browser.byCountryPage('US', {limit: 3}); + controller.abort(); + await expect(first).rejects.toMatchObject({name: 'AbortError'}); + expect(sharedSignal?.aborted).toBe(false); + release(Response.json([row(1)])); + expect((await second).items.map(item => item.id)).toEqual(['s1']); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('rolls back source progress when a later page is aborted and safely replays retries', async () => { + const rows = Array.from({length: 6}, (_, index) => row(index)); + let delayPage = false; + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + const fetch = serve(url => { + if (delayPage && url.searchParams.get('offset') === '2') { + started(); + return new Promise(() => undefined); + } + return slice(rows, url); + }); + const browser = provider(); + const first = await browser.byCountryPage('US', {limit: 2}); + delayPage = true; + const controller = new AbortController(); + const pending = browser.byCountryPage('US', {limit: 2, cursor: first.nextCursor, signal: controller.signal}); + await ready; + controller.abort(); + await expect(pending).rejects.toMatchObject({name: 'AbortError'}); + delayPage = false; + const second = await browser.byCountryPage('US', {limit: 2, cursor: first.nextCursor}); + expect(second.items.map(item => item.id)).toEqual(['s2', 's3']); + const callCount = fetch.mock.calls.length; + const replay = await browser.byCountryPage('US', {limit: 2, cursor: first.nextCursor}); + expect(replay).toEqual(second); + expect(fetch).toHaveBeenCalledTimes(callCount); + }); +}); + +function provider(): RadioBrowserProvider { + const root = mkdtempSync(join(tmpdir(), 'radiocli-cursors-')); + roots.push(root); + return new RadioBrowserProvider(['https://directory.example'], new ProviderCache(join(root, 'cache.json'))); +} + +function row(index: number, values: {bitrate?: number} = {}) { + return {stationuuid: `s${index}`, name: `Jazz ${index}`, clickcount: 1000 - index, bitrate: 192, ...values}; +} + +function slice(rows: unknown[], url: URL): Response { + const offset = Number(url.searchParams.get('offset')); + const limit = Number(url.searchParams.get('limit')); + return Response.json(rows.slice(offset, offset + limit)); +} + +function serve(handler: (url: URL) => Response | Promise) { + const fetch = vi.fn((input: string | URL) => handler(new URL(String(input)))); + vi.stubGlobal('fetch', fetch); + return fetch; +} + +async function drain(load: (cursor?: string) => Promise, initial?: string): Promise { + const pages: DiscoveryPage[] = []; + let cursor = initial; + do { + const page = await load(cursor); + pages.push(page); + if (page.exhausted) break; + expect(page.nextCursor).toBeTypeOf('string'); + cursor = page.nextCursor; + if (pages.length > 30) throw new Error('Pagination did not terminate.'); + } while (cursor); + const items: Station[] = pages.flatMap(page => page.items); + expect(new Set(items.map(station => `${station.provider}:${station.id}`)).size).toBe(items.length); + return pages; +} diff --git a/src/providers/radio-browser.test.ts b/src/providers/radio-browser.test.ts index 2a49c39..117f091 100644 --- a/src/providers/radio-browser.test.ts +++ b/src/providers/radio-browser.test.ts @@ -2,6 +2,7 @@ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {createServer} from 'node:http'; +import {setImmediate as nextEventLoopTurn} from 'node:timers/promises'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {ProviderCache} from './cache.js'; import {RadioBrowserProvider} from './radio-browser.js'; @@ -10,6 +11,7 @@ const roots: string[] = []; describe('RadioBrowserProvider', () => { afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.useRealTimers(); @@ -461,8 +463,11 @@ describe('RadioBrowserProvider', () => { it('gives large atlas downloads a size-aware timeout before trying another mirror', async () => { vi.useFakeTimers(); + let started!: () => void; + const requestStarted = new Promise(resolve => {started = resolve;}); const fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + started(); init?.signal?.addEventListener('abort', () => reject(new Error('atlas attempt aborted')), {once: true}); }) ); @@ -475,6 +480,8 @@ describe('RadioBrowserProvider', () => { const request = provider .nearby({latitude: 48.8566, longitude: 2.3522, source: 'test'}, 2) .then(() => null, error => error as Error); + // Cache I/O is asynchronous; measure the network deadline from its start. + await requestStarted; await vi.advanceTimersByTimeAsync(5_001); expect(fetch).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(25_000); @@ -484,6 +491,39 @@ describe('RadioBrowserProvider', () => { vi.useRealTimers(); }); + it('keeps atlas normalization, deduplication, and ranking stable across input-friendly batches', async () => { + const rows: unknown[] = Array.from({length: 1100}, (_, index) => ({stationuuid: `far-${index}`, name: `Far ${index}`, geo_lat: 55, geo_long: -80})); + rows[0] = {stationuuid: 'missing-first', name: 'Invalid coordinates', geo_lat: 100, geo_long: 2.3522}; + rows[1] = {stationuuid: 'recovered', name: 7}; + rows[249] = {stationuuid: 'boundary', name: ' 東京 · Rádio ', tags: 'jazz,world', countrycode: 'fr', url_resolved: 'https://example.test/live', geo_lat: 48.8566, geo_long: 2.3522, lastcheckok: 1}; + rows[251] = {stationuuid: 'quality', name: 'Quality Radio', geo_lat: 48.8566, geo_long: 2.3522, lastcheckok: 1, clickcount: 1000}; + rows[500] = {stationuuid: 'boundary', name: 'Later duplicate', geo_lat: 48.8566, geo_long: 2.3522}; + rows[700] = {stationuuid: 'missing-first', name: 'Later valid coordinates', geo_lat: 48.8566, geo_long: 2.3522}; + rows[750] = {stationuuid: 'recovered', name: 'Recovered', geo_lat: 48.8566, geo_long: 2.3522}; + const cache = cacheForTest(); + vi.spyOn(cache, 'getAsync').mockResolvedValue(rows); + const fetch = mockFetch(() => {throw new Error('Cached atlas must not fetch.');}); + const provider = new RadioBrowserProvider(['https://primary.example'], cache); + const location = {latitude: 48.8566, longitude: 2.3522, source: 'test'}; + const events: string[] = []; + const loading = provider.nearby(location, 2000).then(stations => {events.push('loaded'); return stations;}); + await nextEventLoopTurn(); + events.push('input'); + const stations = await loading; + expect(events).toEqual(['input', 'loaded']); + expect(stations.slice(0, 3).map(station => station.id)).toEqual(['quality', 'boundary', 'recovered']); + expect(stations[1]).toMatchObject({name: '東京 · Rádio', tags: ['jazz', 'world'], countryCode: 'FR', streamUrl: 'https://example.test/live', distanceKm: 0}); + expect(stations.filter(station => station.id === 'boundary')).toHaveLength(1); + expect(stations.some(station => station.id === 'missing-first')).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + events.length = 0; + const ranking = provider.nearby(location, 3).then(stations => {events.push('ranked'); return stations;}); + await nextEventLoopTurn(); + events.push('input'); + expect((await ranking).map(station => station.id)).toEqual(['quality', 'boundary', 'recovered']); + expect(events).toEqual(['input', 'ranked']); + }); + it('upvotes Radio Browser stations and ignores non-Radio-Browser stations', async () => { const fetch = mockFetch(url => { expect(url.pathname).toBe('/json/vote/tokyo-jazz'); diff --git a/src/providers/radio-browser.ts b/src/providers/radio-browser.ts index d0b9053..7674623 100644 --- a/src/providers/radio-browser.ts +++ b/src/providers/radio-browser.ts @@ -1,8 +1,11 @@ import type {SrvRecord} from 'node:dns'; import {resolveSrv} from 'node:dns/promises'; +import {randomUUID} from 'node:crypto'; +import {setImmediate as yieldToEventLoop} from 'node:timers/promises'; import {z} from 'zod'; -import type {Country, LocationGuess, ResolvedStream, SearchOptions, Station} from '../types.js'; +import type {Country, DiscoveryPage, DiscoveryPageOptions, DiscoverySourceStatus, LocationGuess, ResolvedStream, SearchOptions, Station} from '../types.js'; import {ProviderCache} from './cache.js'; +import {abortable, DiscoverySessions} from './discovery-session.js'; import {userAgent} from '../version.js'; import {safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText} from '../safety.js'; import { @@ -60,6 +63,7 @@ const locationSchema = z .passthrough(); const geoAtlasLimit = 100_000; +const geoAtlasMaxBytes = 32 * 1024 * 1024; const geoAtlasMaxAgeMs = 6 * 60 * 60 * 1000; // 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 @@ -70,6 +74,22 @@ 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']); +const maxDiscoveryRoundsPerPage = 4; + +type SearchSource = DiscoverySourceStatus & {params: Record}; +type BrowserDiscoveryState = { + query: string; + tokens: string[]; + filters: Pick; + sources: SearchSource[]; + buffer: Station[]; + seen: Set; + initialized: boolean; + country: boolean; + bytes: number; +}; +type RequestOptions = {maxAgeMs?: number; timeoutMs?: number; attemptTimeoutMs?: number; cacheOnly?: boolean; signal?: AbortSignal}; +type PendingRequest = {controller: AbortController; subscribers: number; promise: Promise}; type RadioBrowserRuntime = { resolveSrv?: (hostname: string) => Promise; @@ -96,9 +116,12 @@ export class RadioBrowserProvider { private readonly baseUrls: string[]; private readonly discoverMirrors: boolean; private activeBaseUrl: string; - private geoAtlasPromise: Promise | null = null; + private geoAtlas: Station[] | null = null; + private geoAtlasLoad: {promise: Promise; controller: AbortController; subscribers: number; settled: boolean} | null = null; private geoAtlasLoadedAt = 0; private mirrorDiscoveryPromise: Promise | null = null; + private readonly discovery = new DiscoverySessions(state => state.bytes + state.seen.size * 128 + 1024); + private readonly pendingRequests = new Map(); constructor( baseUrls: string[] | undefined = undefined, @@ -132,7 +155,7 @@ export class RadioBrowserProvider { .slice(0, limit); } - async popular(limit = 80): Promise { + async popular(limit = 80, options: {signal?: AbortSignal} = {}): Promise { const rows = await this.request( '/json/stations/search', { @@ -141,13 +164,13 @@ export class RadioBrowserProvider { order: 'clickcount', reverse: 'true' }, - {maxAgeMs: 15 * 60 * 1000} + {maxAgeMs: 15 * 60 * 1000, signal: options.signal} ); return this.normalizeStations(rows); } - async byCountry(countryCode: string, limit = 100, offset = 0): Promise { + async byCountry(countryCode: string, limit = 100, offset = 0, options: {signal?: AbortSignal} = {}): Promise { const rows = await this.request( '/json/stations/search', { @@ -158,21 +181,119 @@ export class RadioBrowserProvider { order: 'clickcount', reverse: 'true' }, - {maxAgeMs: 30 * 60 * 1000} + {maxAgeMs: 30 * 60 * 1000, signal: options.signal} ); return this.normalizeStations(rows); } + byCountryPage(countryCode: string, options: Pick = {}): Promise { + const limit = pageLimit(options.limit, 100); + const country = countryCode.trim().toUpperCase(); + return this.discovery.page(JSON.stringify(['country', country, limit]), options.cursor, () => ({ + query: country, tokens: [], filters: {}, sources: [discoverySource('country', {countrycode: country})], + buffer: [], seen: new Set(), initialized: true, country: true, bytes: 0 + }), state => this.discoveryPage(state, limit, options.signal), options.signal); + } + + searchPage(query: string, options: DiscoveryPageOptions = {}): Promise { + const trimmed = query.trim(); + const limit = pageLimit(options.limit, trimmed ? 60 : 80); + const filters = searchFilters(options); + return this.discovery.page(JSON.stringify(['search', trimmed, filters, limit]), options.cursor, () => ({ + query: trimmed, tokens: trimmed.split(/\s+/).filter(token => token.length > 2).slice(0, 4), filters, + sources: trimmed ? [discoverySource('name', {name: trimmed}), discoverySource('tag', {tag: trimmed})] : [discoverySource('popular', {})], + buffer: [], seen: new Set(), initialized: !trimmed, country: false, bytes: 0 + }), state => this.discoveryPage(state, limit, options.signal), options.signal); + } + + private async discoveryPage(state: BrowserDiscoveryState, limit: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + let successfulRequests = 0; + let requestError: unknown; + const load = async (sources: SearchSource[]): Promise => { + const results = await Promise.allSettled(sources.map(source => this.request( + '/json/stations/search', { + hidebroken: 'true', limit: String(limit), offset: String(source.consumed), order: 'clickcount', reverse: 'true', + ...searchFilterParams(state.filters), ...source.params + }, {maxAgeMs: state.country ? 30 * 60 * 1000 : 10 * 60 * 1000, signal} + ))); + signal?.throwIfAborted(); + let rawCount = 0; + for (const [index, result] of results.entries()) { + const source = sources[index]!; + if (result.status === 'rejected' || !Array.isArray(result.value)) { + requestError = result.status === 'rejected' ? result.reason : new Error('Radio Browser returned an invalid station list.'); + source.status = 'unavailable'; + source.message = requestError instanceof Error ? requestError.message : String(requestError); + continue; + } + successfulRequests += 1; + const rows = result.value; + source.status = 'available'; + source.message = undefined; + source.consumed += rows.length; + source.exhausted = rows.length < limit; + rawCount += rows.length; + for (const station of this.normalizeStations(rows)) { + const key = `${station.provider}:${station.id}`; + if (state.seen.has(key)) continue; + state.seen.add(key); + state.bytes += key.length * 2; + if (filterStations([station], state.filters).length === 0) continue; + state.buffer.push(station); + state.bytes += JSON.stringify(station).length * 2; + } + } + return rawCount; + }; + + for (let round = 0; state.buffer.length < limit && round < maxDiscoveryRoundsPerPage; round += 1) { + const pending = state.sources.filter(source => !source.exhausted); + if (!pending.length) break; + let rawCount = await load(pending); + if (!state.initialized) { + // Preserve the first-page broadening policy and relevance score. Each + // activated source subsequently keeps its own raw offset. + if (rawCount < Math.min(12, limit)) { + const secondary = [discoverySource('country', {country: state.query}), discoverySource('language', {language: state.query})]; + state.sources.push(...secondary); + rawCount += await load(secondary); + } + if (rawCount === 0 && state.tokens.length > 1) { + const tokenSources = state.tokens.slice(0, 3).flatMap((token, index) => [ + discoverySource(`token-${index}-name`, {name: token}), discoverySource(`token-${index}-tag`, {tag: token}) + ]); + state.sources.push(...tokenSources); + await load(tokenSources); + } + state.initialized = true; + } + if (!state.country) state.buffer.sort((left, right) => scoreStation(right, state.query, state.tokens) - scoreStation(left, state.query, state.tokens)); + // Failures remain retryable on the next cursor; do not spend four full + // network deadlines retrying an unavailable source in a single call. + if (state.sources.some(source => source.status === 'unavailable')) break; + } + if (!state.buffer.length && !successfulRequests && requestError) throw requestError; + const items = state.buffer.splice(0, limit); + state.bytes -= items.reduce((sum, station) => sum + JSON.stringify(station).length * 2, 0); + return { + items, + exhausted: state.buffer.length === 0 && state.sources.every(source => source.exhausted), + sources: state.sources.map(({params: _params, ...source}) => source) + }; + } + async byId(id: string): Promise { const rows = await this.request(`/json/stations/byuuid/${encodeURIComponent(id)}`, {}, {maxAgeMs: 10 * 60 * 1000}); return this.normalizeStations(rows)[0] ?? null; } async search(query: string, options: SearchOptions = {}): Promise { + options.signal?.throwIfAborted(); const trimmed = query.trim(); if (!trimmed) { - return this.popular(options.limit ?? 80); + return this.popular(options.limit ?? 80, {signal: options.signal}); } const limit = options.limit ?? 60; @@ -187,21 +308,24 @@ export class RadioBrowserProvider { reverse: 'true', ...(options.codec ? {codec: options.codec} : {}), ...(options.language ? {language: options.language} : {}), - ...(options.countryCode ? {countrycode: options.countryCode.toUpperCase()} : {}) + ...(options.countryCode ? {countrycode: options.countryCode.toUpperCase()} : {}), + ...(options.minBitrate ? {bitrateMin: String(options.minBitrate)} : {}) }; const primary = await Promise.allSettled([ - this.request('/json/stations/search', {...baseParams, name: trimmed}, {maxAgeMs: 10 * 60 * 1000}), - this.request('/json/stations/search', {...baseParams, tag: trimmed}, {maxAgeMs: 10 * 60 * 1000}) + this.request('/json/stations/search', {...baseParams, name: trimmed}, {maxAgeMs: 10 * 60 * 1000, signal: options.signal}), + this.request('/json/stations/search', {...baseParams, tag: trimmed}, {maxAgeMs: 10 * 60 * 1000, signal: options.signal}) ]); + options.signal?.throwIfAborted(); let attempts = [...primary]; let rows = fulfilledRows(primary); if (rows.length < Math.min(12, limit + offset)) { const secondary = await Promise.allSettled([ - this.request('/json/stations/search', {...baseParams, country: trimmed}, {maxAgeMs: 10 * 60 * 1000}), - this.request('/json/stations/search', {...baseParams, language: trimmed}, {maxAgeMs: 10 * 60 * 1000}) + this.request('/json/stations/search', {...baseParams, country: trimmed}, {maxAgeMs: 10 * 60 * 1000, signal: options.signal}), + this.request('/json/stations/search', {...baseParams, language: trimmed}, {maxAgeMs: 10 * 60 * 1000, signal: options.signal}) ]); + options.signal?.throwIfAborted(); attempts = [...attempts, ...secondary]; rows.push(...fulfilledRows(secondary)); } @@ -210,10 +334,11 @@ export class RadioBrowserProvider { if (rows.length === 0 && tokens.length > 1) { const tokenResults = await Promise.allSettled( tokens.slice(0, 3).flatMap(token => [ - this.request('/json/stations/search', {...baseParams, name: token, limit: '25'}, {maxAgeMs: 10 * 60 * 1000}), - this.request('/json/stations/search', {...baseParams, tag: token, limit: '25'}, {maxAgeMs: 10 * 60 * 1000}) + this.request('/json/stations/search', {...baseParams, name: token, limit: '25'}, {maxAgeMs: 10 * 60 * 1000, signal: options.signal}), + this.request('/json/stations/search', {...baseParams, tag: token, limit: '25'}, {maxAgeMs: 10 * 60 * 1000, signal: options.signal}) ]) ); + options.signal?.throwIfAborted(); attempts.push(...tokenResults); rows = fulfilledRows(tokenResults); } @@ -229,12 +354,14 @@ export class RadioBrowserProvider { .slice(offset, offset + limit); } - async nearby(location: LocationGuess, limit = 80): Promise { - const stations = await this.geotaggedAtlas(); - return nearestStations(stations, location, limit); + async nearby(location: LocationGuess, limit = 80, options: {signal?: AbortSignal} = {}): Promise { + options.signal?.throwIfAborted(); + const stations = await this.geotaggedAtlas(options.signal); + return nearestStations(stations, location, limit, options.signal); } - async resolve(station: Station): Promise { + async resolve(station: Station, options: {signal?: AbortSignal} = {}): Promise { + options.signal?.throwIfAborted(); if (station.provider !== this.id) { const streamUrl = station.streamUrl ? safeMediaTarget(station.streamUrl) : null; if (!streamUrl) { @@ -248,13 +375,14 @@ export class RadioBrowserProvider { // Click URLs can rotate and the endpoint records directory engagement, so // do not reuse a day-old resolution from the general provider cache. const result = clickResolveSchema.parse( - await this.request(`/json/url/${encodeURIComponent(station.id)}`, {}, {timeoutMs: 6000}) + await this.request(`/json/url/${encodeURIComponent(station.id)}`, {}, {timeoutMs: 6000, signal: options.signal}) ); const resolvedUrl = result.url ? safeMediaTarget(result.url) : null; if (result.ok && resolvedUrl) { return {url: resolvedUrl, name: sanitizeTerminalText(result.name) ?? station.name}; } } catch { + options.signal?.throwIfAborted(); // A directory outage should not discard a validated URL already supplied // with the station row. } @@ -285,11 +413,12 @@ export class RadioBrowserProvider { } } - async detectLocation(): Promise { + async detectLocation(options: {signal?: AbortSignal} = {}): Promise { + options.signal?.throwIfAborted(); try { return await withExternalResponse('https://ipapi.co/json/', { timeoutMs: 5000, - init: {headers: {'User-Agent': userAgent()}} + init: {headers: {'User-Agent': userAgent()}, signal: options.signal} }, async response => { if (!response.ok) return null; const parsed = locationSchema.parse(await response.json()); @@ -305,6 +434,7 @@ export class RadioBrowserProvider { }; }); } catch { + options.signal?.throwIfAborted(); return null; } } @@ -339,24 +469,39 @@ export class RadioBrowserProvider { })); } - private async geotaggedAtlas(): Promise { + private async geotaggedAtlas(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); const atlasExpired = this.geoAtlasLoadedAt > 0 && Date.now() - this.geoAtlasLoadedAt > geoAtlasMaxAgeMs; - if (!this.geoAtlasPromise || atlasExpired) { - this.geoAtlasPromise = this.loadGeotaggedAtlas() - .then(stations => { + if (this.geoAtlas && !atlasExpired) return this.geoAtlas; + let load = this.geoAtlasLoad; + if (!load) { + const controller = new AbortController(); + load = {controller, subscribers: 0, settled: false, promise: Promise.resolve([])}; + const created = load; + this.geoAtlasLoad = created; + created.promise = this.loadGeotaggedAtlas(controller.signal).then(stations => { + controller.signal.throwIfAborted(); + this.geoAtlas = stations; this.geoAtlasLoadedAt = Date.now(); return stations; }) - .catch(error => { - this.geoAtlasPromise = null; - throw error; + .finally(() => { + created.settled = true; + if (this.geoAtlasLoad === created) this.geoAtlasLoad = null; }); } - - return this.geoAtlasPromise; + load.subscribers += 1; + try { return await abortable(load.promise, signal); } + finally { + load.subscribers -= 1; + if (load.subscribers === 0 && !load.settled) { + if (this.geoAtlasLoad === load) this.geoAtlasLoad = null; + load.controller.abort(); + } + } } - private async loadGeotaggedAtlas(): Promise { + private async loadGeotaggedAtlas(signal?: AbortSignal): Promise { const rows = await this.request( '/json/stations/search', { @@ -369,21 +514,79 @@ export class RadioBrowserProvider { maxAgeMs: geoAtlasMaxAgeMs, timeoutMs: geoAtlasTotalTimeoutMs, attemptTimeoutMs: geoAtlasAttemptTimeoutMs, - cacheOnly: networkPolicy().lowBandwidth + cacheOnly: networkPolicy().lowBandwidth, + signal } ); - - return dedupeStations(this.normalizeStations(rows)).filter(hasValidCoordinates); + signal?.throwIfAborted(); + if (!Array.isArray(rows) || rows.length > geoAtlasLimit) throw new Error('Radio Browser atlas exceeded the supported row limit or returned an invalid response.'); + + // Normalize the full atlas in bounded chunks so keyboard and playback + // events can run between batches. Preserve the original row/dedup order. + const stations: Station[] = []; + const seen = new Set(); + let retainedBytes = 0; + const chunkSize = 250; + for (let offset = 0; offset < rows.length; offset += chunkSize) { + signal?.throwIfAborted(); + for (const station of this.normalizeStations(rows.slice(offset, offset + chunkSize))) { + const key = `${station.provider}:${station.id}`; + // Dedupe before validating coordinates, matching ordinary normalization. + if (seen.has(key)) continue; + seen.add(key); + if (hasValidCoordinates(station)) { + retainedBytes += JSON.stringify(station).length * 2 + 128; + if (retainedBytes > geoAtlasMaxBytes) throw new Error('Radio Browser atlas exceeded the local memory budget. Use country or search discovery.'); + stations.push(station); + } + } + if (offset + chunkSize < rows.length) await yieldToEventLoop(); + } + return stations; } private async request( path: string, params: Record = {}, - options: {maxAgeMs?: number; timeoutMs?: number; attemptTimeoutMs?: number; cacheOnly?: boolean} = {} + options: RequestOptions = {} + ): Promise { + options.signal?.throwIfAborted(); + // Cacheable reads share work, but click/vote endpoints keep their effects. + // Cancellation belongs to each subscriber, never to cache identity. + const key = JSON.stringify([buildCacheKey(path, params), options.maxAgeMs, options.timeoutMs, options.attemptTimeoutMs, options.cacheOnly, + options.maxAgeMs ? undefined : randomUUID()]); + let pending = this.pendingRequests.get(key); + if (!pending) { + if (this.pendingRequests.size >= 64) throw new ProviderUnavailableError('Radio Browser is busy. Retry after the current directory requests finish.'); + const controller = new AbortController(); + const operation: PendingRequest = {controller, subscribers: 0, promise: Promise.resolve(undefined)}; + operation.promise = this.performRequest(path, params, {...options, signal: controller.signal}).finally(() => { + if (this.pendingRequests.get(key) === operation) this.pendingRequests.delete(key); + }); + pending = operation; + this.pendingRequests.set(key, pending); + } + pending.subscribers += 1; + try { return await abortable(pending.promise as Promise, options.signal); } + finally { + pending.subscribers -= 1; + if (pending.subscribers === 0 && this.pendingRequests.get(key) === pending) { + this.pendingRequests.delete(key); + pending.controller.abort(new DOMException('Directory request has no active callers.', 'AbortError')); + } + } + } + + private async performRequest( + path: string, + params: Record, + options: RequestOptions ): Promise { + options.signal?.throwIfAborted(); const cacheKey = buildCacheKey(path, params); if (options.maxAgeMs) { - const cached = this.cache.get(cacheKey, options.maxAgeMs); + const cached = await abortable(this.cache.getAsync(cacheKey, options.maxAgeMs), options.signal); + options.signal?.throwIfAborted(); if (cached) { return cached; } @@ -391,7 +594,8 @@ export class RadioBrowserProvider { const policy = networkPolicy(); if (policy.offline || options.cacheOnly) { - const stale = options.maxAgeMs ? this.cache.getStale(cacheKey) : null; + const stale = options.maxAgeMs ? await abortable(this.cache.getStaleAsync(cacheKey), options.signal) : null; + options.signal?.throwIfAborted(); if (stale !== null) return stale; throw new ProviderUnavailableError(policy.offline ? `${this.label} is offline: no cached response is available.` @@ -404,6 +608,7 @@ export class RadioBrowserProvider { const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl); let discoveryAttempted = !this.discoverMirrors; for (let mirrorIndex = 0; mirrorIndex < mirrors.length; mirrorIndex += 1) { + options.signal?.throwIfAborted(); const baseUrl = mirrors[mirrorIndex]!; const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { @@ -421,32 +626,36 @@ export class RadioBrowserProvider { 1, Math.min(remainingMs, options.attemptTimeoutMs ?? fairShareTimeoutMs) ); - const value = await fetchJsonWithTimeout(url, attemptTimeoutMs, this.runtime); + const value = await fetchJsonWithTimeout(url, attemptTimeoutMs, this.runtime, options.signal); + options.signal?.throwIfAborted(); this.activeBaseUrl = baseUrl; if (options.maxAgeMs) { try { - this.cache.set(cacheKey, value); + await abortable(this.cache.setAsync(cacheKey, value), options.signal); } catch { + options.signal?.throwIfAborted(); // A read-only or full cache directory must not turn live data into // an apparent provider outage. } } return value; } catch (error) { + options.signal?.throwIfAborted(); 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); + const discovered = await abortable(this.loadDiscoveredMirrors(discoveryBudgetMs), options.signal); for (const baseUrl of discovered) { if (!mirrors.includes(baseUrl)) mirrors.push(baseUrl); } } } - const stale = this.cache.getStale(cacheKey); + const stale = await abortable(this.cache.getStaleAsync(cacheKey), options.signal); + options.signal?.throwIfAborted(); if (stale) { return stale; } @@ -498,9 +707,10 @@ function buildCacheKey(path: string, params: Record): string { return `${path}?${new URLSearchParams(pairs).toString()}`; } -async function fetchJsonWithTimeout(url: URL, timeoutMs: number, runtime: RadioBrowserRuntime): Promise { +async function fetchJsonWithTimeout(url: URL, timeoutMs: number, runtime: RadioBrowserRuntime, signal?: AbortSignal): Promise { const startedAt = Date.now(); const init = { + signal, headers: { 'User-Agent': userAgent(' (+https://radio-browser.info)'), Accept: 'application/json' @@ -516,6 +726,7 @@ async function fetchJsonWithTimeout(url: URL, timeoutMs: number, runtime: Rad try { return await withExternalResponse(url, {timeoutMs, init}, consume); } catch (error) { + signal?.throwIfAborted(); const remainingMs = timeoutMs - (Date.now() - startedAt); if ( remainingMs <= 0 || @@ -590,7 +801,7 @@ function settleWithin(promise: Promise, timeoutMs: number, fallback: T): P }); } -export function dedupeStations(stations: Station[]): Station[] { +function dedupeStations(stations: Station[]): Station[] { const seen = new Set(); const deduped: Station[] = []; @@ -657,12 +868,15 @@ function compareNearbyStations(a: Station, b: Station): number { return a.name.localeCompare(b.name); } -function nearestStations(stations: Station[], location: LocationGuess, limit: number): Station[] { +async function nearestStations(stations: Station[], location: LocationGuess, limit: number, signal?: AbortSignal): Promise { const nearest: Station[] = []; const boundedLimit = Math.max(0, Math.floor(limit)); if (boundedLimit === 0) return nearest; - for (const station of stations) { + for (let index = 0; index < stations.length; index += 1) { + if (index > 0 && index % 500 === 0) await yieldToEventLoop(); + if (index % 500 === 0) signal?.throwIfAborted(); + const station = stations[index]!; if (!hasValidCoordinates(station)) continue; const candidate: Station = { ...station, @@ -728,6 +942,29 @@ function scoreStation(station: Station, query: string, tokens: string[]): number return exactScore + tokenScore + Math.log10((station.clickCount ?? 0) + 1); } +function discoverySource(source: string, params: Record): SearchSource { + return {provider: 'radio-browser', source, consumed: 0, exhausted: false, status: 'available', params}; +} + +function pageLimit(value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < 1 || value > 1000) throw new Error('Discovery page size must be between 1 and 1000.'); + return value; +} + +function searchFilters(options: SearchOptions): BrowserDiscoveryState['filters'] { + return {codec: options.codec, language: options.language, countryCode: options.countryCode?.toUpperCase(), minBitrate: options.minBitrate}; +} + +function searchFilterParams(options: SearchOptions): Record { + return { + ...(options.codec ? {codec: options.codec} : {}), + ...(options.language ? {language: options.language} : {}), + ...(options.countryCode ? {countrycode: options.countryCode.toUpperCase()} : {}), + ...(options.minBitrate ? {bitrateMin: String(options.minBitrate)} : {}) + }; +} + function filterStations(stations: Station[], options: SearchOptions): Station[] { return stations.filter(station => { if (options.minBitrate && (station.bitrate ?? 0) < options.minBitrate) { diff --git a/src/providers/radio-garden.ts b/src/providers/radio-garden.ts index 9bb817b..070600d 100644 --- a/src/providers/radio-garden.ts +++ b/src/providers/radio-garden.ts @@ -48,12 +48,13 @@ export class RadioGardenProvider { } async search(query: string, options: SearchOptions = {}): Promise { + options.signal?.throwIfAborted(); const url = new URL(`${this.baseUrl}/search`); url.searchParams.set('q', query); const parsed = await withExternalResponse(url, { timeoutMs: 9000, - init: {headers: {'User-Agent': 'radiocli/0.1', Accept: 'application/json'}} + init: {headers: {'User-Agent': 'radiocli/0.1', Accept: 'application/json'}, signal: options.signal} }, async response => { if (!response.ok) throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`); return searchSchema.parse(await response.json()); @@ -75,7 +76,8 @@ export class RadioGardenProvider { .slice(0, options.limit ?? 30); } - async resolve(station: Station): Promise { + async resolve(station: Station, options: {signal?: AbortSignal} = {}): Promise { + options.signal?.throwIfAborted(); if (station.streamUrl) { return {url: station.streamUrl, name: station.name}; } diff --git a/src/setup-airplay.test.ts b/src/setup-airplay.test.ts new file mode 100644 index 0000000..dcd269d --- /dev/null +++ b/src/setup-airplay.test.ts @@ -0,0 +1,195 @@ +import {existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {PassThrough} from 'node:stream'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import * as backendInstall from './player/backend-install.js'; +import {runSetup, parseSetupArgs} from './setup.js'; +import {JsonLibraryStore} from './storage/store.js'; +import type {AirPlayReadiness} from './player/airplay-readiness.js'; + +vi.mock('./agent/mcp-install.js', () => ({configureMcpIntegrations: vi.fn(async () => [])})); +vi.mock('./setup-playback.js', () => ({verifyLocalPlayback: vi.fn(async () => [])})); + +const ready: AirPlayReadiness = {eligible: true, discoveryExecutable: true, discoveryService: 'ready', senderLoaded: true, ffmpeg: true, ready: true, message: 'Ready.'}; +let directory: string; +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'radiocli-setup-airplay-')); + vi.stubEnv('HOME', directory); + vi.stubEnv('RADIOCLI_HOME', directory); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(directory, {recursive: true, force: true}); }); + +function fixture(overrides: Partial[0]> = {}) { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => { text += String(chunk); }); + const runCommand = vi.fn(async () => undefined); + const probeAirPlay = vi.fn(async () => ready); + const saveLinuxAirPlayEnabled = vi.fn(); + const options = {platform: 'linux' as const, osRelease: 'ID=arch', env: {HOME: directory, RADIOCLI_HOME: directory}, + args: ['--yes', '--airplay'], input: new PassThrough(), output, + hasCommand: (_command: string) => true, getUid: () => 0, isSystemd: () => true, + runCommand, probeAirPlay, saveLinuxAirPlayEnabled, ...overrides}; + return {options, runCommand, probeAirPlay, saveLinuxAirPlayEnabled, text: () => text}; +} + +describe('explicit Linux AirPlay setup', () => { + it('parses both choices and rejects contradictory flags', () => { + expect(parseSetupArgs(['--airplay'])).toMatchObject({airplay: true}); + expect(parseSetupArgs(['--no-airplay'])).toMatchObject({airplay: false}); + expect(() => parseSetupArgs(['--airplay', '--no-airplay'])).toThrow('either --airplay or --no-airplay'); + }); + it.each([['--yes'], ['--all', '--yes'], ['--only=mpv', '--yes']])('does not opt in or reset saved choice with %j', async (...args) => { + const current = new JsonLibraryStore(join(directory, 'radiocli.json')); + current.updateSettings({linuxAirPlayEnabled: true}); + const before = readFileSync(current.filePath, 'utf8'); + const {options, probeAirPlay, saveLinuxAirPlayEnabled, runCommand} = fixture({args}); + await runSetup(options); + expect(saveLinuxAirPlayEnabled).not.toHaveBeenCalled(); + expect(probeAirPlay).not.toHaveBeenCalled(); + expect(runCommand).not.toHaveBeenCalled(); + expect(readFileSync(current.filePath, 'utf8')).toBe(before); + }); + it.each([ + {manager: 'apt', osRelease: 'ID=debian', uid: 0, helpers: [] as string[], expected: ['apt-get install -y ffmpeg', 'apt-get install -y avahi-daemon avahi-utils', 'systemctl enable --now avahi-daemon.service']}, + {manager: 'pacman', osRelease: 'ID=arch', uid: 1000, helpers: ['sudo'], expected: ['sudo pacman -S --needed --noconfirm ffmpeg', 'sudo pacman -S --needed --noconfirm avahi', 'sudo systemctl enable --now avahi-daemon.service']}, + {manager: 'apt', osRelease: 'ID=debian', uid: 1000, helpers: ['doas'], expected: ['doas apt-get install -y ffmpeg', 'doas apt-get install -y avahi-daemon avahi-utils', 'doas systemctl enable --now avahi-daemon.service']} + ])('reviews and executes verified $manager packages and shared service with uid $uid', async ({manager, osRelease, uid, helpers, expected}) => { + const {options, runCommand, saveLinuxAirPlayEnabled, probeAirPlay, text} = fixture({osRelease, args: ['--yes', '--only=mpv', '--airplay', `--package-manager=${manager}`], + getUid: () => uid, hasCommand: command => ['mpv', manager, 'systemctl', ...helpers].includes(command), + probeAirPlay: vi.fn(async (loadSender: boolean): Promise => loadSender ? ready : {...ready, discoveryService: 'unavailable'})}); + await runSetup(options); + expect(runCommand.mock.calls.map(call => (call as unknown as [{display: string}])[0].display)).toEqual(expected); + for (const command of expected) expect(text()).toContain(command); + expect(text()).toContain('whole machine'); + expect(saveLinuxAirPlayEnabled).toHaveBeenCalledWith(true); + expect(options.probeAirPlay).toHaveBeenCalledWith(false); + expect(options.probeAirPlay).toHaveBeenCalledWith(true); + expect(probeAirPlay).not.toHaveBeenCalled(); + }); + it.each([true, false])('dry-run choice %s has no settings, sender, service or installer side effects', async enabled => { + const {options, probeAirPlay, runCommand, saveLinuxAirPlayEnabled, text} = fixture({args: ['--dry-run', enabled ? '--airplay' : '--no-airplay'], hasCommand: command => ['mpv', 'pacman', 'systemctl'].includes(command)}); + await runSetup(options); + expect(text()).toContain('Dry run complete'); + expect(probeAirPlay).not.toHaveBeenCalled(); + expect(runCommand).not.toHaveBeenCalled(); + expect(saveLinuxAirPlayEnabled).not.toHaveBeenCalled(); + expect(existsSync(join(directory, 'radiocli.json'))).toBe(false); + }); + it('requires approval even when all tools and the service are ready', async () => { + const {options, runCommand, saveLinuxAirPlayEnabled} = fixture({args: ['--airplay']}); + await expect(runSetup(options)).rejects.toThrow('Use --yes'); + expect(runCommand).not.toHaveBeenCalled(); + expect(saveLinuxAirPlayEnabled).not.toHaveBeenCalled(); + }); + it('cancellation never saves consent or invokes a displayed service action', async () => { + const {options, saveLinuxAirPlayEnabled, runCommand} = fixture({args: ['--airplay', '--only=mpv'], input: Object.assign(new PassThrough(), {isTTY: true}), output: Object.assign(new PassThrough(), {isTTY: true}), prompt: vi.fn(async () => false)}); + await runSetup(options); + expect(runCommand).not.toHaveBeenCalled(); + expect(saveLinuxAirPlayEnabled).not.toHaveBeenCalled(); + }); + it.each(['install', 'service'])('does not persist opt-in after %s failure', async failure => { + const {options, saveLinuxAirPlayEnabled, text} = fixture({hasCommand: command => failure === 'install' ? ['mpv', 'pacman', 'systemctl'].includes(command) : true, + probeAirPlay: async () => ({...ready, discoveryService: 'unavailable'}), + runCommand: async () => { throw new Error(`${failure} failed`); }}); + await expect(runSetup(options)).rejects.toThrow(`${failure} failed`); + expect(saveLinuxAirPlayEnabled).not.toHaveBeenCalled(); + expect(text()).not.toContain('Setup complete.'); + }); + it('reports missing native sender separately from successfully installed prerequisites', async () => { + const {options, text, saveLinuxAirPlayEnabled} = fixture({probeAirPlay: async () => ({...ready, senderLoaded: false, ready: false, message: 'The optional AirPlay sender could not load.'})}); + await runSetup(options); + expect(saveLinuxAirPlayEnabled).toHaveBeenCalledWith(true); + expect(text()).toContain('Setup required'); + expect(text()).toContain('--include=optional'); + expect(text()).not.toContain('prerequisites ready'); + }); + it('does not change an already-running shared service', async () => { + const {options, runCommand, saveLinuxAirPlayEnabled} = fixture(); + await runSetup(options); + expect(runCommand).not.toHaveBeenCalled(); + expect(saveLinuxAirPlayEnabled).toHaveBeenCalledWith(true); + }); + it('accepts existing ready prerequisites without a package manager or systemd', async () => { + const {options, runCommand, saveLinuxAirPlayEnabled} = fixture({osRelease: 'ID=unknown', hasCommand: command => ['mpv', 'ffmpeg', 'avahi-browse', 'avahi-daemon'].includes(command), isSystemd: () => false}); + await runSetup(options); + expect(runCommand).not.toHaveBeenCalled(); + expect(saveLinuxAirPlayEnabled).toHaveBeenCalledWith(true); + }); + it('provides manual Avahi guidance for unverified managers and non-systemd services', async () => { + const {options, runCommand, text} = fixture({osRelease: 'ID=alpine', isSystemd: () => false, + hasCommand: command => ['apk', 'mpv', 'ffmpeg'].includes(command), probeAirPlay: async () => ({...ready, discoveryExecutable: false, discoveryService: 'unavailable', ready: false, message: 'Avahi is missing.'})}); + await runSetup(options); + expect(runCommand).not.toHaveBeenCalled(); + expect(text()).toContain('no verified automatic Avahi recipe'); + expect(text()).toContain('your system service manager'); + expect(text()).toContain('Setup required'); + }); + it('disables only application consent', async () => { + const {options, runCommand, probeAirPlay, saveLinuxAirPlayEnabled, text} = fixture({args: ['--yes', '--no-airplay']}); + await runSetup(options); + expect(saveLinuxAirPlayEnabled).toHaveBeenCalledWith(false); + expect(runCommand).not.toHaveBeenCalled(); + expect(probeAirPlay).not.toHaveBeenCalled(); + expect(text()).toContain('keep installed packages and shared services'); + }); + it.each([{platform: 'win32' as const}, {platform: 'darwin' as const}, {platform: 'freebsd' as const}, {platform: 'linux' as const, env: {WSL_DISTRO_NAME: 'Ubuntu'}}, {platform: 'linux' as const, env: {TERMUX_VERSION: '1'}}])('rejects Linux opt-in flags on %o', async overrides => { + const {options, runCommand, probeAirPlay, saveLinuxAirPlayEnabled} = fixture(overrides); + await expect(runSetup(options)).rejects.toThrow('native Linux AirPlay only'); + expect(runCommand).not.toHaveBeenCalled(); + expect(probeAirPlay).not.toHaveBeenCalled(); + expect(saveLinuxAirPlayEnabled).not.toHaveBeenCalled(); + }); + it('uses the selected library home and preserves existing and concurrent settings', async () => { + const store = new JsonLibraryStore(join(directory, 'radiocli.json')); + store.updateSettings({theme: 'amber'}); + store.toggleFavorite({id: 'kept', provider: 'playlist', name: 'Keep me', tags: []}); + const {options} = fixture({saveLinuxAirPlayEnabled: undefined, hasCommand: command => ['mpv', 'pacman', 'systemctl'].includes(command), + runCommand: async () => { new JsonLibraryStore(store.filePath).updateSettings({volume: 37}); }}); + await runSetup(options); + const saved = new JsonLibraryStore(store.filePath).snapshot(); + expect(saved.settings).toMatchObject({linuxAirPlayEnabled: true, theme: 'amber', volume: 37}); + expect(saved.favorites[0]?.name).toBe('Keep me'); + }); + it('does not repair malformed settings during dry-run', async () => { + const file = join(directory, 'radiocli.json'); + writeFileSync(file, '{broken'); + await runSetup(fixture({args: ['--dry-run', '--airplay'], saveLinuxAirPlayEnabled: undefined}).options); + expect(readFileSync(file, 'utf8')).toBe('{broken'); + }); + it.each([true, false])('includes application consent %s and the selected runtime in final backend verification', async enabled => { + const detect = vi.spyOn(backendInstall, 'detectPlaybackBackends').mockImplementation(options => + options?.platform === 'linux' && options.settings?.linuxAirPlayEnabled ? ['mpv', 'airplay'] : ['mpv']); + const {options, text} = fixture({args: ['--yes', enabled ? '--airplay' : '--no-airplay']}); + await runSetup(options); + expect(detect).toHaveBeenCalledWith(expect.objectContaining({platform: 'linux', env: options.env, hasCommand: options.hasCommand, settings: {linuxAirPlayEnabled: enabled}})); + expect(text()).toContain(`Playback backends: mpv${enabled ? ', airplay' : ''}\n`); + }); + it('honors an existing opt-in in the selected library during ordinary setup verification', async () => { + const saved = new JsonLibraryStore(join(directory, 'radiocli.json')); + saved.updateSettings({linuxAirPlayEnabled: true}); + const detect = vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv', 'airplay']); + const {options, text} = fixture({args: ['--yes']}); + await runSetup(options); + expect(detect).toHaveBeenCalledWith(expect.objectContaining({settings: {linuxAirPlayEnabled: true}})); + expect(text()).toContain('Playback backends: mpv, airplay'); + }); + + it('offers Linux experimental setup explicitly and defaults a new library to no', async () => { + const prompt = vi.fn(async (_question: string, defaultValue: boolean) => defaultValue); + const {options, saveLinuxAirPlayEnabled, runCommand} = fixture({args: ['--no-mcp'], input: Object.assign(new PassThrough(), {isTTY: true}), output: Object.assign(new PassThrough(), {isTTY: true}), prompt}); + await runSetup(options); + expect(prompt.mock.calls.find(([question]) => question.includes('AirPlay — experimental'))?.[1]).toBe(false); + expect(saveLinuxAirPlayEnabled).not.toHaveBeenCalled(); + expect(runCommand).not.toHaveBeenCalled(); + }); + it('keeps the macOS interactive component defaults and prompts unchanged', async () => { + const prompt = vi.fn(async (_question: string, defaultValue: boolean) => defaultValue); + const {options, saveLinuxAirPlayEnabled, probeAirPlay} = fixture({platform: 'darwin', args: ['--no-mcp'], input: Object.assign(new PassThrough(), {isTTY: true}), output: Object.assign(new PassThrough(), {isTTY: true}), prompt}); + await runSetup(options); + expect(prompt.mock.calls.map(([question, value]) => [question.includes('mpv') ? 'mpv' : question.includes('FFmpeg') ? 'ffmpeg' : 'vlc', value])).toEqual([['mpv', true], ['ffmpeg', true], ['vlc', false]]); + expect(saveLinuxAirPlayEnabled).not.toHaveBeenCalled(); + expect(probeAirPlay).not.toHaveBeenCalled(); + }); +}); diff --git a/src/setup-playback.test.ts b/src/setup-playback.test.ts new file mode 100644 index 0000000..17c7039 --- /dev/null +++ b/src/setup-playback.test.ts @@ -0,0 +1,95 @@ +import {EventEmitter} from 'node:events'; +import {existsSync, readFileSync} from 'node:fs'; +import {PassThrough} from 'node:stream'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {verifyLocalPlayback} from './setup-playback.js'; + +const {spawn} = vi.hoisted(() => ({spawn: vi.fn()})); +vi.mock('node:child_process', () => ({spawn})); +afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); }); + +describe('local decoder rehearsal', () => { + it('uses isolated WAV fixtures and backend-specific null/dummy outputs without changing the parent environment', async () => { + const fixtures: string[] = []; + const execute = vi.fn(async (command: string, args: string[], options: {env: NodeJS.ProcessEnv}) => { + const fixture = args.at(-1)!; + fixtures.push(fixture); + expect(readFileSync(fixture).subarray(0, 4).toString()).toBe('RIFF'); + if (command.endsWith('mpv')) { + expect(args).toContain('--no-config'); + expect(args).toContain('--ao=null'); + return {code: 0, output: 'RADIOCLI_DECODER_READY'}; + } + if (command.endsWith('ffplay')) { + expect(args).toContain('-autoexit'); + expect(options.env).toMatchObject({SDL_AUDIODRIVER: 'dummy', SDL_AUDIO_DRIVER: 'dummy'}); + return {code: 0, output: 'Audio: pcm_s16le, 44100 Hz'}; + } + expect(args).toContain('--aout=dummy'); + expect(args).toContain('--ignore-config'); + return {code: 0, output: 'using audio output module "adummy"\nend of file'}; + }); + const env = {SDL_AUDIODRIVER: 'user-output'}; + const results = await verifyLocalPlayback(['mpv', 'ffplay', 'vlc', 'airplay', 'mpv'], {env, resolve: command => `/native tools/${command}`, execute}); + expect(results.map(item => item.status)).toEqual(['passed', 'passed', 'passed']); + expect(results.every(item => item.physicalOutput === 'unverified')).toBe(true); + expect(env.SDL_AUDIODRIVER).toBe('user-output'); + expect(fixtures.every(path => !existsSync(path))).toBe(true); + expect(execute).toHaveBeenCalledTimes(3); + }); + + it.each([{code: 1, output: ''}, {code: 0, output: 'audio decoder error: failed'}, {code: null, output: '', failure: 'Timed out.'}])('does not confuse binary presence or clean process startup with working decoding: %j', async completed => { + const [result] = await verifyLocalPlayback(['mpv'], {resolve: () => '/native/mpv', execute: async () => completed}); + expect(result?.status).toBe('failed'); + }); + + it('keeps an unsupported evidence format and a foreign runtime explicitly unverified', async () => { + expect((await verifyLocalPlayback(['vlc'], {resolve: () => '/native/vlc', execute: async () => ({code: 0, output: ''})}))[0]?.status).toBe('unverified'); + const foreign = process.platform === 'win32' ? 'linux' : 'win32'; + expect((await verifyLocalPlayback(['mpv'], {platform: foreign}))[0]?.status).toBe('unverified'); + expect(spawn).not.toHaveBeenCalled(); + }); + + it.each([ + {backend: 'mpv', output: '[osd/libass] Failed to load fontconfig fonts!\nRADIOCLI_DECODER_READY\nExiting... (End of file)'}, + {backend: 'vlc', output: '[native] main libvlc debug: configured with CFLAGS=-Werror=format-security\n[native] main audio output debug: using audio output module "adummy"\n[native] dbus interface error: Failed to connect to the D-Bus session daemon\n[native] main interface error: no suitable interface module\n[native] main libvlc error: interface "globalhotkeys,none" initialization failed\n[native] main input source debug: attachment of directory-extractor failed\n[native] main input debug: EOF reached'} + ])('accepts completed $backend audio playback despite unavailable desktop services', async ({backend, output}) => { + const [result] = await verifyLocalPlayback([backend], {resolve: () => `/native/${backend}`, execute: async () => ({code: 0, output})}); + expect(result).toMatchObject({status: 'passed', physicalOutput: 'unverified'}); + }); + + it.each([ + {backend: 'mpv', output: 'RADIOCLI_DECODER_READY\n[ad/ffmpeg] Audio decoder error: failed'}, + {backend: 'ffplay', output: 'Audio: pcm_s16le, 44100 Hz\nSDL_OpenAudio: cannot open audio device'}, + {backend: 'vlc', output: 'using audio output module "adummy"\n[native] main decoder error: cannot decode audio\nEOF reached'} + ])('rejects $backend audio failures even when startup evidence is present', async ({backend, output}) => { + const [result] = await verifyLocalPlayback([backend], {resolve: () => `/native/${backend}`, execute: async () => ({code: 0, output})}); + expect(result?.status).toBe('failed'); + }); + + it('forces termination after the playback deadline and cleans the fixture', async () => { + let fixture = ''; + const child = Object.assign(new EventEmitter(), {stdout: new PassThrough(), stderr: new PassThrough(), unref: vi.fn(), kill: vi.fn((signal?: string) => { + if (signal === 'SIGKILL') queueMicrotask(() => child.emit('close', null)); + return true; + })}); + spawn.mockImplementation((_command, args) => { fixture = args.at(-1); return child; }); + const [result] = await verifyLocalPlayback(['mpv'], {resolve: () => '/native/mpv', timeoutMs: 250}); + expect(result).toMatchObject({status: 'failed', message: 'Local WAV playback exceeded its deadline.'}); + expect(child.kill.mock.calls).toEqual([[], ['SIGKILL']]); + expect(existsSync(fixture)).toBe(false); + }); + + it('cancels the child and removes its fixture when the caller aborts', async () => { + const controller = new AbortController(); + let fixture = ''; + const child = Object.assign(new EventEmitter(), {stdout: new PassThrough(), stderr: new PassThrough(), unref: vi.fn(), kill: vi.fn(() => { + queueMicrotask(() => child.emit('close', null)); + return true; + })}); + spawn.mockImplementation((_command, args) => { fixture = args.at(-1); queueMicrotask(() => controller.abort()); return child; }); + await expect(verifyLocalPlayback(['mpv'], {resolve: () => '/native/mpv', signal: controller.signal})).rejects.toMatchObject({name: 'AbortError'}); + expect(child.kill).toHaveBeenCalledOnce(); + expect(existsSync(fixture)).toBe(false); + }); +}); diff --git a/src/setup-playback.ts b/src/setup-playback.ts new file mode 100644 index 0000000..a91a4e6 --- /dev/null +++ b/src/setup-playback.ts @@ -0,0 +1,153 @@ +import {spawn} from 'node:child_process'; +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {resolveCommand} from './platform/executables.js'; + +type LocalBackend = 'mpv' | 'ffplay' | 'vlc'; +export type PlaybackRehearsal = { + backend: LocalBackend; + status: 'passed' | 'failed' | 'unverified'; + source: 'local-wav'; + output: 'null' | 'dummy'; + physicalOutput: 'unverified'; + message: string; +}; +type ProcessOptions = {env: NodeJS.ProcessEnv; signal?: AbortSignal; timeoutMs: number}; +type ProcessResult = {code: number | null; output: string; failure?: string}; +type RehearsalOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + timeoutMs?: number; + resolve?: typeof resolveCommand; + execute?: (command: string, args: string[], options: ProcessOptions) => Promise; +}; + +/** Exercise local decoders without sending audio to an output device or receiver. */ +export async function verifyLocalPlayback(backends: string[], options: RehearsalOptions = {}): Promise { + options.signal?.throwIfAborted(); + const local = [...new Set(backends)].filter((value): value is LocalBackend => ['mpv', 'ffplay', 'vlc'].includes(value)); + if (!local.length) return []; + const result = (backend: LocalBackend, status: PlaybackRehearsal['status'], message: string): PlaybackRehearsal => ({ + backend, status, source: 'local-wav', output: backend === 'mpv' ? 'null' : 'dummy', physicalOutput: 'unverified', message + }); + if (options.platform && options.platform !== process.platform && !options.execute) { + return local.map(backend => result(backend, 'unverified', 'The requested platform differs from the current runtime. Run the rehearsal on that device.')); + } + const directory = await mkdtemp(join(tmpdir(), 'radiocli-setup-')); + try { + const fixture = join(directory, 'local silence.wav'); + await writeFile(fixture, silentWav(), {mode: 0o600}); + const resolve = options.resolve ?? resolveCommand; + const execute = options.execute ?? runDecoderProcess; + const results: PlaybackRehearsal[] = []; + for (const backend of local) { + options.signal?.throwIfAborted(); + const command = backend === 'vlc' ? resolve('cvlc') ?? resolve('vlc') : resolve(backend); + if (!command) { + results.push(result(backend, 'failed', 'The executable was detected earlier but could not be resolved for the rehearsal.')); + continue; + } + const args = backend === 'mpv' + ? ['--no-config', '--no-video', '--ao=null', '--ao-null-untimed', '--idle=no', '--term-status-msg=', '--term-playing-msg=RADIOCLI_DECODER_READY', fixture] + : backend === 'ffplay' + ? ['-nodisp', '-autoexit', '-hide_banner', '-loglevel', 'info', fixture] + : ['--ignore-config', '--intf', 'dummy', '--no-video', '--aout=dummy', '--no-one-instance', '--no-media-library', '--no-repeat', '--no-loop', '--play-and-exit', '--verbose=2', fixture]; + let completed: ProcessResult; + try { + completed = await execute(command, args, { + env: {...options.env ?? process.env, LC_ALL: 'C', ...(backend === 'ffplay' ? {SDL_AUDIODRIVER: 'dummy', SDL_AUDIO_DRIVER: 'dummy', SDL_VIDEODRIVER: 'dummy', SDL_VIDEO_DRIVER: 'dummy'} : {})}, + timeoutMs: Math.max(250, Math.min(options.timeoutMs ?? 8000, 30_000)), signal: options.signal + }); + } catch { + options.signal?.throwIfAborted(); + completed = {code: null, output: '', failure: 'The playback process could not be started.'}; + } + options.signal?.throwIfAborted(); + const fatal = hasPlaybackFailure(backend, completed.output); + const observed = backend === 'mpv' ? completed.output.includes('RADIOCLI_DECODER_READY') + : backend === 'ffplay' ? /Audio:\s*pcm_s16le/i.test(completed.output) + : /using audio output module "(?:adummy|dummy)"/i.test(completed.output) && /(?:end of file|EOF)/i.test(completed.output); + results.push(completed.failure || completed.code !== 0 || fatal + ? result(backend, 'failed', completed.failure ?? `Local WAV playback failed${completed.code !== null ? ` (exit ${completed.code})` : ''}. Run radiocli doctor and check this backend's native dependencies.`) + : observed ? result(backend, 'passed', 'Local WAV playback completed with a null or dummy audio output.') + : result(backend, 'unverified', 'The process exited successfully, but it did not expose the expected playback evidence.')); + } + return results; + } finally { + await rm(directory, {force: true, recursive: true}); + } +} + +function hasPlaybackFailure(backend: LocalBackend, output: string): boolean { + return output.split(/\r?\n/).some(line => { + if (!/\b(?:error|failed|could not|cannot|invalid data)\b/i.test(line)) return false; + // The null-output rehearsal does not render OSD text. Minimal Linux installs + // can lack fonts while the audio decoder and output complete successfully. + if (backend === 'mpv' && /^\[osd(?:\/[^\]]+)?\]/i.test(line)) return false; + if (backend === 'vlc') { + // VLC logs rejected optional modules and build flags at debug level, and + // starts desktop interfaces even with a dummy playback interface. Neither + // proves failure of the isolated audio pipeline; readiness is checked below. + if (/\bdebug:/i.test(line)) return false; + if (/\b(?:dbus|interface)\b/i.test(line) && !/\b(?:audio output|decoder|demux|input|access|stream)\b/i.test(line)) return false; + } + return true; + }); +} + +function runDecoderProcess(command: string, args: string[], options: ProcessOptions): Promise { + return new Promise((resolve, reject) => { + options.signal?.throwIfAborted(); + const child = spawn(command, args, {env: options.env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, windowsHide: true}); + let output = ''; + let failure: string | undefined; + let force: ReturnType | undefined; + let deadline: ReturnType | undefined; + let settled = false; + const finish = (code: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + clearTimeout(force); + clearTimeout(deadline); + options.signal?.removeEventListener('abort', abort); + options.signal?.aborted ? reject(options.signal.reason) : resolve({code, output, failure}); + }; + const stop = (message: string) => { + if (settled || failure) return; + failure = message; + try { child.kill(); } catch { /* The force deadline also covers failed termination. */ } + force = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* Report through the cleanup deadline. */ } }, 250); + deadline = setTimeout(() => { + // A failed native termination must not hang setup or retain stdio handles. + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); + failure = 'The playback process did not acknowledge termination. Check for a remaining native player process.'; + finish(null); + }, 1500); + }; + const abort = () => stop('The rehearsal was cancelled.'); + const timeout = setTimeout(() => stop('Local WAV playback exceeded its deadline.'), options.timeoutMs); + const collect = (chunk: Buffer) => { output = `${output}${chunk.toString()}`.slice(-64 * 1024); }; + child.stdout?.on('data', collect); + child.stderr?.on('data', collect); + child.once('error', () => { failure = 'The playback process could not be started.'; finish(null); }); + child.once('close', code => finish(code)); + options.signal?.addEventListener('abort', abort, {once: true}); + if (options.signal?.aborted) abort(); + }); +} + +function silentWav(): Buffer { + const dataBytes = 44_100; // 250 ms, stereo, signed 16-bit PCM. + 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; +} diff --git a/src/setup.test.ts b/src/setup.test.ts index b9dc72a..ddf17cd 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -1,11 +1,32 @@ import {PassThrough} from 'node:stream'; import {describe, expect, it, vi} from 'vitest'; import {createSetupPlan, parseSetupArgs, runSetup, type SetupComponent} from './setup.js'; +import {verifyLocalPlayback} from './setup-playback.js'; + import {detectPackageManager, ffplayInstallCommand, mpvInstallCommand, type SetupPackageManager} from './platform/packages.js'; +vi.mock('./setup-playback.js', () => ({verifyLocalPlayback: vi.fn(async () => [])})); +vi.mock('./agent/mcp-install.js', () => ({configureMcpIntegrations: vi.fn(async () => [])})); + const nothingInstalled: Record = {mpv: false, ffmpeg: false, vlc: false}; describe('RadioCLI setup', () => { + it('awaits decoder verification on existing installations while preserving dry-run behavior', async () => { + const output = new PassThrough(); + let text = ''; + output.on('data', chunk => { text += String(chunk); }); + const rehearsal = vi.mocked(verifyLocalPlayback); + rehearsal.mockClear(); + const options = {platform: process.platform, args: ['--yes', '--only=mpv'], hasCommand: (command: string) => command === 'mpv', output, input: new PassThrough()}; + await runSetup({...options, args: ['--dry-run', '--only=mpv']}); + expect(rehearsal).not.toHaveBeenCalled(); + rehearsal.mockResolvedValueOnce([{backend: 'mpv', status: 'failed', source: 'local-wav', output: 'null', physicalOutput: 'unverified', message: 'Native decoder failed.'}]); + await runSetup(options); + expect(rehearsal).toHaveBeenCalledWith(['mpv'], expect.objectContaining({platform: process.platform})); + expect(text).toContain('decoder_mpv=failed Native decoder failed.'); + expect(text).toContain('physical_output=unverified'); + expect(text).toContain('Playback needs attention.'); + }); it.each([ ['darwin', '', 'brew', 'brew', {}], ['win32', '', 'winget', 'winget', {}], diff --git a/src/setup.ts b/src/setup.ts index f86574d..e2da141 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -7,10 +7,15 @@ import type {Readable, Writable} from 'node:stream'; 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 {defaultLibraryPath, JsonLibraryStore} from './storage/store.js'; import {defaultAgentControlSettings} from './types.js'; import {resolveTerminalCapabilities} from './platform/terminal.js'; +import {isAirPlayPlatformSupported} from './player/airplay-policy.js'; +import {probeAirPlayReadiness, type AirPlayReadiness} from './player/airplay-readiness.js'; +import {airPlaySetupVerification, hasSystemdSystemManager, linuxAirPlaySetupPlan} from './player/airplay-setup.js'; +import {readSettingsWithoutMigration} from './storage/read-settings.js'; import {identifyPlatform, readLinuxOsRelease, type PlatformProfile} from './platform/runtime.js'; +import {verifyLocalPlayback} from './setup-playback.js'; export type SetupPlan = { platform: NodeJS.Platform; @@ -31,6 +36,12 @@ type SetupOptions = { hasCommand?: (command: string) => boolean; runCommand?: (command: SetupCommand, output: Writable) => Promise; getUid?: () => number | undefined; + libraryPath?: string; + isSystemd?: () => boolean; + probeAirPlay?: (loadSender: boolean) => Promise; + saveLinuxAirPlayEnabled?: (enabled: boolean) => void; + prompt?: (question: string, defaultValue: boolean) => Promise; + verifyPlayback?: typeof verifyLocalPlayback; }; type ParsedSetupArgs = { @@ -41,6 +52,7 @@ type ParsedSetupArgs = { packageManager: SetupPackageManager | null; mcp: boolean | null; agentUi: boolean | null; + airplay: boolean | null; }; const components: SetupComponent[] = ['mpv', 'ffmpeg', 'vlc']; @@ -54,6 +66,11 @@ export async function runSetup(options: SetupOptions = {}): Promise { const output = options.output ?? process.stdout; const hasCommand = options.hasCommand ?? commandExists; const parsed = parseSetupArgs(options.args ?? []); + const linuxAirPlayEligible = platform === 'linux' && isAirPlayPlatformSupported(platform, env, host.release); + if (parsed.airplay !== null && !linuxAirPlayEligible) throw new Error('--airplay and --no-airplay configure experimental native Linux AirPlay only. macOS keeps its existing AirPlay setup; this environment has no Linux opt-in.'); + const prompt = options.prompt ?? ((question: string, defaultValue: boolean) => promptYesNo(input, output, question, defaultValue)); + const libraryPath = () => options.libraryPath ?? defaultLibraryPath({platform, env, home: env.HOME ?? env.USERPROFILE}); + const probe = options.probeAirPlay ?? ((loadSender: boolean) => probeAirPlayReadiness({platform, env, release: host.release, hasCommand, ...(loadSender ? {} : {sender: async () => ({loaded: false, message: 'Not probed before approval.'})})})); if (parsed.packageManager) validateNativePackageManager(parsed.packageManager, host); const installed = detectInstalledComponents(hasCommand); const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand, env); @@ -64,39 +81,60 @@ export async function runSetup(options: SetupOptions = {}): Promise { let selected = parsed.only ?? defaultComponents(platform, parsed.all); if (!parsed.yes && !parsed.only && isInteractive(input, output)) { - selected = await promptForComponents({platform, installed, input, output}); + selected = await promptForComponents({platform, installed, output, prompt}); + } + let airplay = parsed.airplay; + if (linuxAirPlayEligible && airplay === null && !parsed.yes && !parsed.only && isInteractive(input, output)) { + const enabled = readSettingsWithoutMigration(libraryPath())?.linuxAirPlayEnabled === true; + const choice = await prompt(' AirPlay — experimental (FFmpeg, shared Avahi network discovery)', enabled); + // Declining the default on a new install leaves the library untouched. + if (choice || enabled) airplay = choice; } let mcp = parsed.mcp; if (mcp === null && !parsed.yes && isInteractive(input, output)) { - mcp = await promptYesNo(input, output, ' Agent control via MCP (detected coding agents)', true); + mcp = await prompt(' Agent control via MCP (detected coding agents)', true); } let agentUi = parsed.agentUi; if (mcp === true && agentUi === null && !parsed.yes && isInteractive(input, output)) { const note = platform === 'darwin' ? ' (macOS will ask the agent app to control Terminal on first use)' : ' (opens a separate terminal window)'; - agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true); + agentUi = await prompt(` Open the RadioCLI TUI for agent playback${note}`, true); } const isRoot = (options.getUid ?? process.getuid)?.() === 0; const elevation = packageManagerElevation(hasCommand, isRoot); const plan = createSetupPlan({platform, osRelease, env, packageManager, installed, selected, elevation}); + const airPlayPlan = airplay === true ? linuxAirPlaySetupPlan({manager: packageManager, elevation, hasCommand, + systemd: (options.isSystemd ?? hasSystemdSystemManager)(), + serviceReady: parsed.dryRun ? false : (await probe(false).catch(() => null))?.discoveryService === 'ready', existingCommands: plan.commands}) : null; + if (airPlayPlan) plan.commands.push(...airPlayPlan.commands); printPlan(plan, output); + if (airplay !== null) output.write(` Linux AirPlay: ${airplay ? 'enable for this library after accepted setup; verify sender and discovery' : 'disable for this library; keep installed packages and shared services'}\n`); + for (const note of airPlayPlan?.notes ?? []) output.write(` Note: ${note}\n`); 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 verify = async () => { + let linuxAirPlayEnabled = airplay === true; + if (platform === 'linux' && airplay === null) { + try { linuxAirPlayEnabled = readSettingsWithoutMigration(libraryPath())?.linuxAirPlayEnabled === true; } catch { /* Unreadable consent stays disabled. */ } + } + await printVerification(output, {platform, osRelease, env, release: host.release, hasCommand, getUid: options.getUid, linuxAirPlayEnabled, verifyPlayback: options.verifyPlayback}); + }; + const missing = plan.selected.filter(component => !plan.installed[component]); - if (missing.length === 0) { + if (missing.length === 0 && airplay === null) { output.write(plan.selected.length === 0 ? '\nNo components selected.\n' : '\nEverything selected is already installed.\n'); await finishMcpSetup(mcp, agentUi, parsed.dryRun, output); - printVerification(output); + if (!parsed.dryRun) await verify(); return; } - if (!plan.packageManager) { + if (!plan.packageManager && missing.length > 0) { if (parsed.dryRun) { output.write('\nDry run complete. Install the missing components manually; no system packages were changed.\n'); await finishMcpSetup(mcp, agentUi, true, output); @@ -114,17 +152,17 @@ 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 && !elevation) { + if ((plan.packageManager && packageManagerNeedsRoot(plan.packageManager) || airPlayPlan?.commands.length) && plan.commands.length > 0 && !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))) { + if (plan.commands.length > 0 && 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)) { - const confirmed = await promptYesNo(input, output, '\nInstall these components?', true); + const confirmed = await prompt(airplay === null ? '\nInstall these components?' : '\nApply this setup plan and Linux AirPlay preference?', true); if (!confirmed) { output.write('Setup cancelled. No system packages were changed.\n'); return; @@ -133,21 +171,30 @@ export async function runSetup(options: SetupOptions = {}): Promise { throw new Error('Setup needs an interactive terminal. Use --yes to approve the displayed commands or --dry-run to inspect them.'); } - if (plan.commands.some(command => command.program === 'sudo')) { + if (!options.runCommand && plan.commands.some(command => command.program === 'sudo')) { output.write('\nRadioCLI needs administrator approval for the system package manager.\n'); await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']); } const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform)); - output.write('\nInstalling\n'); + if (plan.commands.length) output.write('\nInstalling\n'); for (const command of plan.commands) { await execute(command, output); } clearCommandCache(); + if (airplay !== null) { + if (options.saveLinuxAirPlayEnabled) options.saveLinuxAirPlayEnabled(airplay); + else new JsonLibraryStore(libraryPath()).updateSettings({linuxAirPlayEnabled: airplay}); + output.write(`\nLinux AirPlay ${airplay ? 'enabled' : 'disabled'} for this library. Restart active RadioCLI sessions to apply this preference.\n`); + if (airplay) { + const readiness = await probe(true).catch((): AirPlayReadiness => ({eligible: true, discoveryExecutable: false, discoveryService: 'unverified', senderLoaded: false, ffmpeg: false, ready: false, message: 'Readiness could not be verified. Run radiocli doctor.'})); + for (const line of airPlaySetupVerification(readiness)) output.write(` ${line}\n`); + } + } output.write('\nSetup complete.\n'); await finishMcpSetup(mcp, agentUi, false, output); - printVerification(output); + await verify(); } export function createSetupPlan({ @@ -202,12 +249,18 @@ export function parseSetupArgs(args: string[]): ParsedSetupArgs { let packageManager: SetupPackageManager | null = null; let mcp: boolean | null = null; let agentUi: boolean | null = null; + let airplay: boolean | null = null; for (let index = 0; index < args.length; index += 1) { const arg = args[index]!; if (arg === '--all') all = true; else if (arg === '--dry-run') dryRun = true; else if (arg === '--yes' || arg === '-y') yes = true; + else if (arg === '--airplay' || arg === '--no-airplay') { + const enabled = arg === '--airplay'; + if (airplay !== null && airplay !== enabled) throw new Error('Use either --airplay or --no-airplay, not both.'); + airplay = enabled; + } else if (arg === '--mcp') { if (mcp === false) throw new Error('Use either --mcp or --no-mcp, not both.'); mcp = true; @@ -230,7 +283,7 @@ export function parseSetupArgs(args: string[]): ParsedSetupArgs { if (all && only) throw new Error('Use either --all or --only, not both.'); if (agentUi !== null && mcp !== true) throw new Error('--agent-ui and --headless-agent require --mcp.'); - return {all, dryRun, yes, only, packageManager, mcp, agentUi}; + return {all, dryRun, yes, only, packageManager, mcp, agentUi, airplay}; } async function finishMcpSetup(enabled: boolean | null, agentUi: boolean | null, dryRun: boolean, output: Writable): Promise { @@ -290,19 +343,19 @@ function detectInstalledComponents(hasCommand: (command: string) => boolean): Re async function promptForComponents({ platform, installed, - input, - output + output, + prompt }: { platform: NodeJS.Platform; installed: Record; - input: Readable; output: Writable; + prompt(question: string, defaultValue: boolean): Promise; }): 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 ? ` ${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'); + if (await prompt(` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true)) selected.push('mpv'); + if (await prompt(` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin')) selected.push('ffmpeg'); + if (await prompt(` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false)) selected.push('vlc'); return selected; } @@ -380,12 +433,26 @@ function progressFrame(label: string, frame: number, elapsedMs: number, output: return ` ${accent(output, unicode ? '◒' : '*')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`; } -function printVerification(output: Writable): void { +async function printVerification(output: Writable, {platform, osRelease, env, release, hasCommand, getUid, linuxAirPlayEnabled, verifyPlayback = verifyLocalPlayback}: { + platform: NodeJS.Platform; + osRelease: string; + env: NodeJS.ProcessEnv; + release: string; + hasCommand(command: string): boolean; + getUid?: () => number | undefined; + linuxAirPlayEnabled: boolean; + verifyPlayback?: typeof verifyLocalPlayback; +}): Promise { clearCommandCache(); - const backends = detectPlaybackBackends(); + const backends = detectPlaybackBackends({platform, env, release, hasCommand, settings: {linuxAirPlayEnabled}}); output.write('\nVerification\n'); output.write(` Playback backends: ${backends.join(', ') || 'none'}\n`); - for (const line of playbackBackendStatusLines(backends).slice(0, 3)) output.write(` ${line}\n`); + for (const line of playbackBackendStatusLines(backends, platform, osRelease, {hasCommand, getUid}).slice(0, 3)) output.write(` ${line}\n`); + const rehearsals = await verifyPlayback(backends, {platform, env}); + for (const rehearsal of rehearsals) output.write(` decoder_${rehearsal.backend}=${rehearsal.status} ${rehearsal.message}\n`); + if (rehearsals.length === 0) output.write(' decoder=unverified No local playback backend is available for a rehearsal.\n'); + output.write(' physical_output=unverified Play a station on your intended speakers, headphones, or AirPlay receiver to verify that path.\n'); + if (rehearsals.some(item => item.status !== 'passed')) output.write(' Playback needs attention. Run radiocli doctor --playback for a repeatable local check.\n'); output.write('\nRun radiocli to start listening.\n'); } diff --git a/src/smoke/cli-smoke.ts b/src/smoke/cli-smoke.ts new file mode 100644 index 0000000..baf9005 --- /dev/null +++ b/src/smoke/cli-smoke.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import {execFile} from 'node:child_process'; +import {existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {setTimeout as delay} from 'node:timers/promises'; +import {fileURLToPath} from 'node:url'; +import {promisify} from 'node:util'; +import {detectPlaybackBackends} from '../player/backend-install.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import type {RadioSessionStatus} from '../agent/session.js'; +import type {Station} from '../types.js'; + +const run = promisify(execFile); +const cli = fileURLToPath(new URL('../../dist/cli.js', import.meta.url)); +if (!existsSync(cli)) throw new Error('Build RadioCLI before the CLI smoke check: npm run build.'); +if (!detectPlaybackBackends().includes('mpv')) throw new Error('The CLI smoke check requires mpv. Run radiocli setup.'); +const root = mkdtempSync(join(tmpdir(), 'radiocli-cli-smoke-')); +const sessionPath = join(root, 'runtime', 'agent-session.json'); +const env = {...process.env, RADIOCLI_HOME: root, RADIOCLI_MPV_AUDIO_OUTPUT: 'null', RADIOCLI_DESKTOP_MEDIA: 'off', RADIOCLI_OFFLINE: '1', NO_COLOR: '1'}; +const command = async (...args: string[]) => (await run(process.execPath, [cli, ...args], {env, timeout: 25_000})).stdout; +const snapshot = async () => JSON.parse(await command('now', '--json')) as RadioSessionStatus | null; + +try { + const path = join(root, 'silence.wav'); + writeFileSync(path, silentWav()); + const first: Station = {id: 'smoke-first', provider: 'playlist', name: 'CLI Smoke First', tags: [], streamUrl: path}; + const second: Station = {...first, id: 'smoke-second', name: 'CLI Smoke Second'}; + const store = new JsonLibraryStore(join(root, 'radiocli.json')); + store.updateSettings({preferredBackend: 'mpv', volume: 0, shareDirectoryVotes: false, desktopMediaControls: 'off'}); + store.toggleFavorite(first); + store.toggleFavorite(second); + + assert.equal(await snapshot(), null); + await command('play', '--favorites', '--quiet'); + assert.equal((await snapshot())?.playback.state, 'playing'); + assert.equal((await snapshot())?.owner, 'headless'); + const hostPid = JSON.parse(readFileSync(sessionPath, 'utf8')).pid as number; + await command('pause', '--quiet'); + assert.equal((await snapshot())?.playback.state, 'paused'); + await command('play', '--quiet'); + assert.equal((await snapshot())?.playback.state, 'playing'); + const continued = await snapshot(); + await command('play', '--quiet'); + const unchanged = await snapshot(); + assert.equal(unchanged?.playback.startedAt, continued?.playback.startedAt, 'Repeated play must not retune active audio.'); + assert.deepEqual(unchanged?.queue, continued?.queue, 'Continuing playback must retain its station queue.'); + await command('volume', '35', '--quiet'); + assert.equal((await snapshot())?.playback.volume, 35); + await command('mute', '--quiet'); + assert.equal((await snapshot())?.playback.muted, true); + await command('unmute', '--quiet'); + assert.equal((await snapshot())?.playback.muted, false); + const beforeNext = (await snapshot())?.station?.id; + await command('next', '--quiet'); + assert.notEqual((await snapshot())?.station?.id, beforeNext); + await command('previous', '--quiet'); + assert.equal((await snapshot())?.station?.id, beforeNext); + await command('preset', 'save', 'smoke', '--quiet'); + await command('play', 'smoke', '--quiet'); + assert.equal(JSON.parse(readFileSync(sessionPath, 'utf8')).pid, hostPid, 'CLI calls must reuse one host.'); + assert.equal((await command('_complete', 'play', 'smo')).trim(), 'smoke'); + assert.equal(JSON.parse(await command('favorites', '--json')).length, 2); + assert.equal(new JsonLibraryStore(store.filePath).snapshot().settings.agentControl?.enabled, false); + await command('stop', '--quiet'); + const deadline = Date.now() + 5000; + while (existsSync(sessionPath) && Date.now() < deadline) await delay(50); + assert.equal(existsSync(sessionPath), false, 'Stop must release the background player session.'); + assert.equal(await snapshot(), null); + const stats = JSON.parse(await command('stats', '--json')) as {sessions: number; totalSeconds: number}; + const sessions = new JsonLibraryStore(store.filePath).snapshot().activity.sessions; + assert.ok(stats.sessions > 0, 'Background playback must persist listening sessions.'); + assert.equal(stats.sessions, sessions.length, 'CLI stats must read the sessions saved by the background player.'); + assert.ok(stats.totalSeconds >= 0); + console.log('cli_smoke=ok (background mpv, null audio, shared session, controls, presets, completion, clean stop)'); +} finally { + if (existsSync(sessionPath)) { + const {pid} = JSON.parse(readFileSync(sessionPath, 'utf8')) as {pid: number}; + await command('stop', '--quiet').catch(() => { try { process.kill(pid, 'SIGTERM'); } catch { /* Already exited. */ } }); + const deadline = Date.now() + 5000; + while (existsSync(sessionPath) && Date.now() < deadline) await delay(50); + } + rmSync(root, {recursive: true, force: true}); +} + +function silentWav(): Buffer { + const sampleRate = 16_000; + const bytes = 60 * sampleRate * 2; + const buffer = Buffer.alloc(44 + bytes); + buffer.write('RIFF', 0); buffer.writeUInt32LE(36 + bytes, 4); buffer.write('WAVEfmt ', 8); + buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(1, 22); + buffer.writeUInt32LE(sampleRate, 24); buffer.writeUInt32LE(sampleRate * 2, 28); + buffer.writeUInt16LE(2, 32); buffer.writeUInt16LE(16, 34); buffer.write('data', 36); buffer.writeUInt32LE(bytes, 40); + return buffer; +} diff --git a/src/smoke/fixtures/README.md b/src/smoke/fixtures/README.md new file mode 100644 index 0000000..18fc068 --- /dev/null +++ b/src/smoke/fixtures/README.md @@ -0,0 +1,13 @@ +These are public, test-only fixtures. Never use the TLS key or certificate for a +real service. The self-signed certificate trusts only `localhost` and expires in +October 2036; network-playback-smoke installs it in a temporary mpv trust store, +never in the host trust store. Regenerate before expiry with: + +``` +openssl req -x509 -newkey rsa:2048 -nodes -keyout localhost-key.pem -out localhost-cert.pem -subj /CN=localhost -addext subjectAltName=DNS:localhost -days 3650 +openssl req -x509 -key localhost-key.pem -out wronghost-cert.pem -subj /CN=wrong.invalid -addext subjectAltName=DNS:wrong.invalid -days 3650 +ffmpeg -f lavfi -i anullsrc=r=22050:cl=mono -t 3 -c:a libmp3lame -b:a 64k -write_xing 0 -id3v2_version 0 -f mp3 silence.mp3 +``` + +The three-second MP3 contains only silence and has no duration/Xing header. The +HTTP fixture controls EOF and stalls; no public station or audio device is used. diff --git a/src/smoke/fixtures/localhost-cert.pem b/src/smoke/fixtures/localhost-cert.pem new file mode 100644 index 0000000..ced3c82 --- /dev/null +++ b/src/smoke/fixtures/localhost-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDHzCCAgegAwIBAgIUd5Qrd7zzN36OJ2UvaBy2EHKRJY4wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAxMzAxMzYwMloXDTM2MTAx +MDAxMzYwMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAs9f8y06QivvqZVRoK3/1ZdsZqHfTUJPNM9f+Ii2CEUvg +c6rq6WCWb2Bwbu4+me+gm76ysZ7ipKdbnK3lai1vWmEXParTRHkFjX+t0lDfIbIG +kYcjAXy2pYQY8q8so/DK/lCcjYvEEg6uujHuzaqoFoj+Y88C2sy+PhGLGUV8KYW4 +en7zf4d0tmf96VTMLD4NRlsY40n7vAYpbC3CQLW2MCbGgD56zGOhjJ7z9sODBM4g +6hHHOVIvAZkIIzHbUqC++rrBXK7V+R11LdSOG6bJ8rlJTwn/bAvhbrOPFoPoONQW +jrVCzd5jUCx+6oYAtkCFztgkVg1B6Y23AA/5hH9AqwIDAQABo2kwZzAdBgNVHQ4E +FgQU3Z/sXtOcl77n+5FI27qkC/b3Pj0wHwYDVR0jBBgwFoAU3Z/sXtOcl77n+5FI +27qkC/b3Pj0wDwYDVR0TAQH/BAUwAwEB/zAUBgNVHREEDTALgglsb2NhbGhvc3Qw +DQYJKoZIhvcNAQELBQADggEBAG57KbzDSHtt0wUpOA+jqkvHqBpHzeeCHqRu3ZsM +TdrQMh4YKL8ybnkrZ7n4drHwo76sneeOBJdPhEaAfMI7etN6AuoZFH1L0spN82IN +h1hIgAn6L54jlsYZiPRwJe2o8yKxUYK40VbvIrW6oH3C3V6RpolVvG6Us6HcaVSP +TFaCR2jofOqzf8Wk9L6x6M35afSDnTRImcEuiwXistaR2NjIeNt2usQSWAZ5zFC+ +fjVZ53COYAUpvfuX1TbCPmKyus4cYNzXDypm+N6/8JlzvKDFn5PUFtOlu8ySa563 +7HCdSIkpWD/1Oxsi65Bhpig5y4zIB5d6IvMaSeWdq0HPHMU= +-----END CERTIFICATE----- diff --git a/src/smoke/fixtures/localhost-key.pem b/src/smoke/fixtures/localhost-key.pem new file mode 100644 index 0000000..b8883c0 --- /dev/null +++ b/src/smoke/fixtures/localhost-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCz1/zLTpCK++pl +VGgrf/Vl2xmod9NQk80z1/4iLYIRS+BzqurpYJZvYHBu7j6Z76CbvrKxnuKkp1uc +reVqLW9aYRc9qtNEeQWNf63SUN8hsgaRhyMBfLalhBjyryyj8Mr+UJyNi8QSDq66 +Me7NqqgWiP5jzwLazL4+EYsZRXwphbh6fvN/h3S2Z/3pVMwsPg1GWxjjSfu8Bils +LcJAtbYwJsaAPnrMY6GMnvP2w4MEziDqEcc5Ui8BmQgjMdtSoL76usFcrtX5HXUt +1I4bpsnyuUlPCf9sC+Fus48Wg+g41BaOtULN3mNQLH7qhgC2QIXO2CRWDUHpjbcA +D/mEf0CrAgMBAAECggEAIRbKQjXQTmIPPFmNWQOQCQ+I5wA09wxaowFIls08syX8 +xAtccVf+Fbvus9pN1lDKgh1SD3AmTxkjt+4e2axGYqCegBAgEucZaHFnb9zFURPK +NZhhjnJ9wHJPjMdCNV+mWahHRZvDlXbyGvv47gpKJutJst9xrQQitJTRvPKV56WN +Ikkel6BTgrrV7/bNQoHRoG8qrHj1rQ1B7bbPHzyEB8q/Jre+FI0YpDgnQQP8nG3o +sGjmpQrMLJ8Geh6sqa1Y8E6yAjiMm6IkcUWKGcDoAIICahs2FzgSGUvUX6Tr1K0p +85FgVTiS8OwbtdTNz06HPfYbIEi0P0TjPYnMQvkzwQKBgQD1YtptHGi2pDiOJ6bn +I9njeWiPdWyMUNW49dbeoLyd754q/KKq+YKaqLS7WteTXAtmC2z0cXc2tLnathQf +KsPzMACv848f1zD6btT6sHiS57EJt6b1qtbRw6O+zWrwyefCEY/qkRcRSafMewCD +AvShoZNJP2UFCy5uYLIBoJg5cQKBgQC7n2LyI3Nty+d+Ik5JuThh+MAyTlLsQU1r +bmIqiNbYCzpfnMTwo+RFaHZwoiMZ/AQ5ZeRogDwYinU2fRmDwmwBYrQ+fDg8j8Fs +Bj+/l/S6OAqFkLp2Ruo3VzOzVofCczDOl6f1SiJ7TwUsAQaftarH7fkKAAdh8COz +EHZYbmFt2wKBgQDrtgZNMzHe54av/qTTeNuvJ4tp6ojXjDqQTg4Vkl88ctUDQ+RZ +yg1L1Oyu38cedRc58ZW6hVfXg+jXj3lqquDJNH4khhggpMwWc+o/2M4ph0fHJtPJ +Pk7V9lDPUnD8VWY7RDVzQ+XdpFv8DtdiAa/NpvBdNvkpzCDQxC+ZYpN28QKBgGMy +oAPZznuspRZBWRcNgMv70tJGKv2SgTZdxDUs6DqNpS1MG6wHWvd/ha+Daf+hCTK3 +to2xzxRCkDpg5g6NysDoyNeIocsTTnaozyejVdbnLljz7kH+uHLN/h3BkEG02YdB +E6JSsex0GQ3s9O2YBVVzcopg/YZAiv9vosKaiZSlAoGBAIZ1DxF7te0VodkLyQx2 +8+3CwMFf2mb/w6iPuLdSTXjSzH9ELNXagCQPGPGtYwHxPy7dFBYo1hNk+fPxMw9v +47QHm2MlwenWahtGNXNN+CQ1cvagsCPsvegKkjdpUmotwdqcK4uB75XxNxYZLKl5 +qZJd9CFxRM/yMxJCWjrPTUwO +-----END PRIVATE KEY----- diff --git a/src/smoke/fixtures/silence.mp3 b/src/smoke/fixtures/silence.mp3 new file mode 100644 index 0000000..b9d52fd Binary files /dev/null and b/src/smoke/fixtures/silence.mp3 differ diff --git a/src/smoke/fixtures/wronghost-cert.pem b/src/smoke/fixtures/wronghost-cert.pem new file mode 100644 index 0000000..2838a78 --- /dev/null +++ b/src/smoke/fixtures/wronghost-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUWfEkt/xRD3K7NKv6w3T+TgNqLbUwDQYJKoZIhvcNAQEL +BQAwGDEWMBQGA1UEAwwNd3JvbmcuaW52YWxpZDAeFw0yNjEwMTMwMTQxNTNaFw0z +NjEwMTAwMTQxNTNaMBgxFjAUBgNVBAMMDXdyb25nLmludmFsaWQwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCz1/zLTpCK++plVGgrf/Vl2xmod9NQk80z +1/4iLYIRS+BzqurpYJZvYHBu7j6Z76CbvrKxnuKkp1ucreVqLW9aYRc9qtNEeQWN +f63SUN8hsgaRhyMBfLalhBjyryyj8Mr+UJyNi8QSDq66Me7NqqgWiP5jzwLazL4+ +EYsZRXwphbh6fvN/h3S2Z/3pVMwsPg1GWxjjSfu8BilsLcJAtbYwJsaAPnrMY6GM +nvP2w4MEziDqEcc5Ui8BmQgjMdtSoL76usFcrtX5HXUt1I4bpsnyuUlPCf9sC+Fu +s48Wg+g41BaOtULN3mNQLH7qhgC2QIXO2CRWDUHpjbcAD/mEf0CrAgMBAAGjbTBr +MB0GA1UdDgQWBBTdn+xe05yXvuf7kUjbuqQL9vc+PTAfBgNVHSMEGDAWgBTdn+xe +05yXvuf7kUjbuqQL9vc+PTAPBgNVHRMBAf8EBTADAQH/MBgGA1UdEQQRMA+CDXdy +b25nLmludmFsaWQwDQYJKoZIhvcNAQELBQADggEBAF/DWrYPLDdWRlukhsJ22i4L +7ZORunglsWWBo13ZnqHd477fpsaEnWIoi/hzfvClL180sRd8PX0Gh6Ew6lt8idl/ +Jv7g/+8wEEdEjdorKl78JBaoC9xPmc2N7jrumCLWtVmRIer4zlxJ/sUbDYjTH7bG +EZWs/8sL80/D1W7qTcFwYMaspmRuPnXRzG4sHOq6hucbMdv0VbL5N0ls/OJbNnBW +G+WDdJMiE44ZJiepw/kEz1tNNn9gboTGS13QvrtltcP4fKEJEz8uOONa/7BndKD8 +eqBXmrlDz1wpHuJ/DqJttF9pMBiGwsMgTm35o8v3RI6qpi2X7+eiD4GmSkNiEJY= +-----END CERTIFICATE----- diff --git a/src/smoke/network-playback-smoke.ts b/src/smoke/network-playback-smoke.ts new file mode 100644 index 0000000..03797b7 --- /dev/null +++ b/src/smoke/network-playback-smoke.ts @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict'; +import {createServer, type Server, type ServerResponse} from 'node:http'; +import {createServer as createTlsServer} from 'node:https'; +import {mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {spawn} from 'node:child_process'; +import {setTimeout as delay} from 'node:timers/promises'; +import {PlayerController} from '../player/player-controller.js'; +import {RadioEngine} from '../player/radio-engine.js'; +import {JsonLibraryStore} from '../storage/store.js'; +import {resolveCommand} from '../platform/executables.js'; +import {canRetryPlainHttp, ffmpegInputArguments} from '../player/ffmpeg-input.js'; +import type {AppSettings, Station} from '../types.js'; + +// Required mpv verification, using only loopback fixtures and null audio. ffplay +// is checked when installed; absence is explicit rather than a false pass. +assert.ok(resolveCommand('mpv'), 'Network playback verification requires mpv.'); +const root = mkdtempSync(join(tmpdir(), 'radiocli-network-')); +const fixture = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); +const certificate = readFileSync(fixture('localhost-cert.pem')); +const audio = readFileSync(fixture('silence.mp3')); +const originalEnv = {...process.env}; +const servers: Server[] = []; +const responses = new Set(); +const timers = new Set(); +const players: PlayerController[] = []; +const engines: RadioEngine[] = []; +const privateCaFixtures = process.platform !== 'win32' || process.env.RADIOCLI_SMOKE_PRIVATE_CA === '1'; +const results: Record = {}; +const report = (name: string, result: string) => { results[name] = result; console.log(`${name}=${result}`); }; +const settings: AppSettings = {...new JsonLibraryStore(join(root, 'library.json')).settings(), + preferredBackend: 'mpv', volume: 0, tuneTimeoutSeconds: 5, desktopMediaControls: 'off', skipBrokenStreams: false}; +const station = (url: string, provider: Station['provider'] = 'playlist'): Station => ({id: 'network-fixture', name: 'Network fixture', provider, tags: [], streamUrl: url}); +const makePlayer = (backend: AppSettings['preferredBackend'] = 'mpv') => { + const player = new PlayerController(() => ({...settings, preferredBackend: backend})); players.push(player); return player; +}; +async function listen(server: Server): Promise { + servers.push(server); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); assert.ok(address && typeof address !== 'string'); return address.port; +} +function track(response: ServerResponse): void { responses.add(response); response.once('close', () => responses.delete(response)); } +async function until(predicate: () => boolean, label: string, timeout = 10_000): Promise { + const deadline = Date.now() + timeout; + while (!predicate()) { if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`); await delay(100); } +} + +try { + process.env.RADIOCLI_HOME = root; + process.env.RADIOCLI_MPV_AUDIO_OUTPUT = 'null'; + process.env.SDL_AUDIODRIVER = 'dummy'; process.env.SDL_VIDEODRIVER = 'dummy'; + process.env.RADIOCLI_VLC_AUDIO_OUTPUT = 'dummy'; + const untrusted = join(root, 'untrusted'); mkdirSync(untrusted); + const trusted = join(root, 'trusted'); mkdirSync(trusted); + const authorities = join(root, 'test-authorities.pem'); + writeFileSync(authorities, Buffer.concat([certificate, readFileSync(fixture('wronghost-cert.pem'))])); + // Even an old user config opting out cannot override the application's flag. + writeFileSync(join(trusted, 'mpv.conf'), `tls-verify=no\ntls-ca-file=${JSON.stringify(authorities)}\n`); + process.env.MPV_HOME = untrusted; + const wav = silentWav(3); + let tlsRequests = 0; + const tlsPort = await listen(createTlsServer({key: readFileSync(fixture('localhost-key.pem')), cert: certificate}, (_request, response) => { + tlsRequests++; + response.writeHead(200, {'content-type': 'audio/wav', 'content-length': wav.length}); response.end(wav); + })); + const redirectPort = await listen(createServer((_request, response) => { response.writeHead(302, {location: `https://localhost:${tlsPort}/radio.wav`}); response.end(); })); + const player = makePlayer(); + for (const url of [`https://localhost:${tlsPort}/radio.wav`, `http://127.0.0.1:${redirectPort}/redirect`]) { + await assert.rejects(player.play(station(url), url)); assert.equal(player.getState().ready, false); + assert.equal(player.recoverableFailure(), false, 'Certificate failures must not enter recovery.'); await player.stop(); + } + report('mpv_tls_rejection', 'passed: untrusted certificate and HTTP-to-HTTPS redirect'); + const valid = `https://localhost:${tlsPort}/radio.wav`; + if (privateCaFixtures) { + process.env.MPV_HOME = trusted; + const wrongPort = await listen(createTlsServer({key: readFileSync(fixture('localhost-key.pem')), cert: readFileSync(fixture('wronghost-cert.pem'))}, (_request, response) => { + response.writeHead(200, {'content-type': 'audio/wav', 'content-length': wav.length}); response.end(wav); + })); + const wrongHost = `https://localhost:${wrongPort}/radio.wav`; + await assert.rejects(player.play(station(wrongHost), wrongHost)); await player.stop(); + const mismatch = `https://127.0.0.1:${tlsPort}/radio.wav`; + // Keep this native IP identity limitation visible. A Node preflight cannot + // secure the player's actual TLS connection and would give false assurance. + let rejectsIpMismatch = false; + try { await player.play(station(mismatch), mismatch); } catch { rejectsIpMismatch = true; } + await player.stop(); + report('mpv_ip_san', rejectsIpMismatch ? 'passed: rejected' : 'KNOWN UPSTREAM LIMITATION: native TLS backend accepted a mismatched IP certificate'); + await player.play(station(valid), valid); assert.equal(player.getState().ready, true); + await until(() => player.getState().state === 'stopped', 'finite HTTPS recording EOF'); await player.stop(); + report('mpv_private_ca', 'passed: trusted HTTPS playback and DNS-hostname mismatch rejection'); + } else { + // The pinned Windows fixture uses Schannel, which ignores ca_file. Never + // install a test root into the user's system trust store to make it pass. + report('mpv_private_ca', 'unverified: Windows Schannel ignores the isolated CA file; trusted playback and DNS/IP identity fixtures require a CA-file-capable build'); + } + const finitePort = await listen(createServer((_request, response) => { + response.writeHead(200, {'content-type': 'audio/wav', 'content-length': wav.length}); response.end(wav); + })); + const finite = `http://127.0.0.1:${finitePort}/recording.wav`; + await player.play(station(finite), finite); assert.equal(player.getState().ready, true); + await until(() => player.getState().state === 'stopped', 'finite HTTP recording EOF'); + assert.equal(player.recoverableFailure(), false); await player.stop(); + report('finite_eof', 'passed: stopped without recovery'); + + for (const mode of ['disconnect', 'stall'] as const) { + let requests = 0; let observedFailure = false; + const port = await listen(createServer((_request, response) => { + const request = ++requests; track(response); + response.writeHead(200, {'content-type': 'audio/mpeg', 'icy-name': 'Live fixture', 'accept-ranges': 'none'}); + response.write(audio); + if (request === 1) { + if (mode === 'disconnect') timers.add(setTimeout(() => response.destroy(), 1800)); + } else { + const timer = setInterval(() => { if (!response.destroyed) response.write(audio); }, 1500); + timers.add(timer); response.once('close', () => { clearInterval(timer); timers.delete(timer); }); + } + })); + const url = `http://127.0.0.1:${port}/live`; + const live = makePlayer(); const store = new JsonLibraryStore(join(root, `${mode}.json`)); + const engine = new RadioEngine(live, {resolve: async () => ({url})}, store, () => settings); engines.push(engine); + engine.onChange(state => { if (state.playback.state === 'error') observedFailure = true; }); + await engine.tune(station(url, 'radio-browser')); + await until(() => requests >= 2 && live.getState().ready && live.getState().state === 'playing', `${mode} recovery`, 35_000); + assert.equal(observedFailure, true); assert.equal(requests, 2); + await engine.stop(); const stoppedRequests = requests; await delay(1200); + assert.equal(requests, stoppedRequests); assert.equal(live.getState().ready, false); + report(`mpv_${mode}`, 'passed: recovered; Stop prevents resurrection'); + } + + const emptyPort = await listen(createServer((_request, response) => { track(response); response.writeHead(200, {'content-type': 'audio/mpeg'}); response.flushHeaders(); })); + const file = join(root, 'silence.wav'); writeFileSync(file, silentWav(5)); + const manifest = `#EXTM3U\n#EXT-X-TARGETDURATION:3\n#EXTINF:3,\nhttps://localhost:${tlsPort}/segment.wav\n#EXT-X-ENDLIST\n`; + const localPlaylist = join(root, 'network.m3u8'); writeFileSync(localPlaylist, manifest); + let changingRequests = 0; + const mediaPort = await listen(createServer((request, response) => { + if (request.url === '/live.m3u8') { + response.writeHead(200, {'content-type': 'application/vnd.apple.mpegurl'}); response.end(manifest); + } else if (request.url === '/changing' && ++changingRequests > 1) { + response.writeHead(302, {location: valid}); response.end(); + } else { + response.writeHead(200, {'content-type': 'audio/wav', 'content-length': wav.length}); response.end(wav); + } + })); + const media = `http://127.0.0.1:${mediaPort}/media.wav`; + const hls = `http://127.0.0.1:${mediaPort}/live.m3u8`; + const changing = `http://127.0.0.1:${mediaPort}/changing`; + const nestedStart = tlsRequests; + process.env.MPV_HOME = untrusted; + await assert.rejects(player.play(station(hls), hls)); await player.stop(); + assert.equal(tlsRequests, nestedStart, 'mpv must verify nested HTTPS before requesting its media.'); + report('mpv_nested_tls', 'passed: untrusted HTTPS segment rejected before HTTP request'); + if (resolveCommand('ffplay')) { + const fallback = makePlayer('ffplay'); + const empty = `http://127.0.0.1:${emptyPort}/no-audio`; + await assert.rejects(fallback.play(station(empty), empty), /Timed out/); assert.equal(fallback.getState().ready, false); await fallback.stop(); + await fallback.play(station(file), file); assert.equal(fallback.getState().ready, true); await fallback.stop(); + await assert.rejects(fallback.play(station(valid), valid)); await fallback.stop(); + await fallback.play(station(media), media); assert.equal(fallback.getState().ready, true); await fallback.stop(); + const before = tlsRequests; + for (const target of [hls, localPlaylist]) { + await assert.rejects(fallback.play(station(target), target), /select mpv/); await fallback.stop(); + } + changingRequests = 0; + await assert.rejects(fallback.play(station(changing), changing)); await fallback.stop(); + assert.ok(changingRequests >= 2); assert.equal(tlsRequests, before, 'Fallbacks must not fetch unverified nested or redirected TLS resources.'); + report('ffplay', 'passed: decoded-clock; local/HTTP; headers-only/TLS rejection; nested TLS and retry redirect blocked'); + } else report('ffplay', 'unverified: not installed'); + if (resolveCommand('ffmpeg')) { + // Exercise actual AirPlay decoder input options independently of a receiver. + // The Linux native suite also verifies the complete worker/RAOP path. + const decode = async (target: string, trustedCa?: string) => { + const run = (plainHttp = false) => decodePcm(target, [...ffmpegInputArguments(target, {plainHttp, reconnect: true}), ...(trustedCa ? ['-ca_file', trustedCa] : [])]); + const first = await run(); + return !first.bytes && canRetryPlainHttp(target, first.diagnostic) ? run(true) : first; + }; + for (const target of [file, media, ...(privateCaFixtures ? [valid] : [])]) { + const result = await decode(target, target === valid ? authorities : undefined); + assert.equal(result.code, 0, result.diagnostic); assert.ok(result.bytes > 0, 'FFmpeg must produce decoded PCM.'); + } + const before = tlsRequests; + for (const target of [hls, localPlaylist]) { + const result = await decode(target); assert.equal(result.bytes, 0); assert.match(result.diagnostic, /not on whitelist/i); + } + changingRequests = 0; + const changed = await decode(changing); assert.equal(changed.bytes, 0); assert.match(changed.diagnostic, /blacklist/i); + assert.equal(tlsRequests, before); + report('ffmpeg', `passed: local/HTTP decode; nested TLS and retry redirect blocked; trusted HTTPS ${privateCaFixtures ? 'passed' : 'unverified: CA-file support not assumed'}`); + } else report('ffmpeg', 'unverified: not installed'); + if (resolveCommand('cvlc') || resolveCommand('vlc')) { + const fallback = makePlayer('vlc'); const empty = `http://127.0.0.1:${emptyPort}/no-audio`; + await assert.rejects(fallback.play(station(empty), empty), /Timed out/); assert.equal(fallback.getState().ready, false); await fallback.stop(); + for (const target of [file, media]) { + await fallback.play(station(target), target); assert.equal(fallback.getState().ready, true); await fallback.stop(); + } + report('vlc', 'passed: output-and-decoder readiness; headers-only rejection; local/HTTP'); + } else report('vlc', 'unverified: not installed'); + if (process.env.RADIOCLI_REQUIRE_FALLBACKS === '1') { + for (const binary of ['ffplay', 'ffmpeg']) assert.ok(resolveCommand(binary), `${binary} is required in this verification job.`); + assert.ok(resolveCommand('cvlc') || resolveCommand('vlc'), 'VLC is required in this verification job.'); + } + console.log('network_playback_smoke=ok; output=null/dummy; network=loopback-only'); + if (originalEnv.RADIOCLI_SMOKE_EVIDENCE_DIR) { + mkdirSync(originalEnv.RADIOCLI_SMOKE_EVIDENCE_DIR, {recursive: true}); + writeFileSync(join(originalEnv.RADIOCLI_SMOKE_EVIDENCE_DIR, 'network-playback.json'), `${JSON.stringify({result: 'passed', sourceRevision: originalEnv.RADIOCLI_SOURCE_REVISION ?? originalEnv.GITHUB_SHA ?? null, + platform: process.platform, architecture: process.arch, node: process.version, checks: results}, null, 2)}\n`); + } +} finally { + for (const engine of engines) await engine.close(); + for (const player of players) await player.stop(); + for (const timer of timers) clearTimeout(timer); + for (const response of responses) response.destroy(); + for (const server of servers) { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } + for (const key of Object.keys(process.env)) if (!(key in originalEnv)) delete process.env[key]; + Object.assign(process.env, originalEnv); + rmSync(root, {recursive: true, force: true}); +} + +async function decodePcm(target: string, inputArgs: string[]): Promise<{code: number | null; bytes: number; diagnostic: string}> { + const child = spawn(resolveCommand('ffmpeg')!, ['-hide_banner', '-loglevel', 'error', ...inputArgs, '-i', target, '-t', '0.25', '-f', 's16le', '-ac', '2', '-ar', '44100', 'pipe:1'], {stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true}); + let bytes = 0; let diagnostic = ''; + child.stdout.on('data', (chunk: Buffer) => { bytes += chunk.length; }); + child.stderr.on('data', chunk => { diagnostic = (diagnostic + String(chunk)).slice(-8192); }); + const timeout = setTimeout(() => child.kill('SIGKILL'), 8000); + try { + return await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', code => resolve({code, bytes, diagnostic})); + }); + } finally { clearTimeout(timeout); } +} + +function silentWav(seconds: number): Buffer { + const dataBytes = seconds * 44_100 * 2; + const wav = Buffer.alloc(44 + dataBytes); + wav.write('RIFF'); wav.writeUInt32LE(36 + dataBytes, 4); wav.write('WAVEfmt ', 8); wav.writeUInt32LE(16, 16); + wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); wav.writeUInt32LE(44_100, 24); wav.writeUInt32LE(88_200, 28); + wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); wav.write('data', 36); wav.writeUInt32LE(dataBytes, 40); + return wav; +} diff --git a/src/smoke/playback-smoke.ts b/src/smoke/playback-smoke.ts index d5470c2..0a2ffe7 100644 --- a/src/smoke/playback-smoke.ts +++ b/src/smoke/playback-smoke.ts @@ -1,4 +1,5 @@ import {setTimeout as delay} from 'node:timers/promises'; +import assert from 'node:assert/strict'; import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -37,6 +38,22 @@ try { console.log('source=local-wav'); try { + if (backends.includes('mpv')) { + let playedBeforeResume = false; + const unsubscribe = player.onChange(state => { if (state.state === 'playing') playedBeforeResume = true; }); + try { + await player.play(station, fixture, {startPaused: true}); + assert.equal(player.getState().state, 'paused'); + assert.equal(player.getState().ready, true); + await delay(300); + assert.equal(player.getState().state, 'paused'); + assert.equal(playedBeforeResume, false, 'A paused station must not report playing before resume.'); + } finally { unsubscribe(); } + assert.equal((await player.resume()).ok, true); + assert.equal(player.getState().state, 'playing'); + await player.stop(); + console.log('paused_start_and_resume=ok'); + } await player.play(station, fixture); await delay(1200); } finally { diff --git a/src/storage/architecture.test.ts b/src/storage/architecture.test.ts new file mode 100644 index 0000000..0b9ada3 --- /dev/null +++ b/src/storage/architecture.test.ts @@ -0,0 +1,190 @@ +import {mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync} from 'node:fs'; +import {spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it} from 'vitest'; +import {stationFromUrl} from '../playlists/playlist.js'; +import {JsonLibraryStore, stationKey} from './store.js'; +import {LibraryRepository} from './repository.js'; + +const roots: string[] = []; +const file = () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-repository-')); + roots.push(root); + return join(root, 'library.json'); +}; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); }); + +describe('station identity and fresh library reads', () => { + it('does not let a competing process replace a live writer whose lock looks old', async () => { + const path = file(); const gate = `${path}.continue`; + const store = new JsonLibraryStore(path); store.updateSettings({theme: 'amber'}); + const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', ` + import fs from 'node:fs'; + import {syncBuiltinESMExports} from 'node:module'; + import {JsonLibraryStore} from ${JSON.stringify(new URL('./store.ts', import.meta.url).href)}; + const sync = fs.fsyncSync; let held = false; + fs.fsyncSync = fd => { + if (!held) { + held = true; process.stdout.write('locked\\n'); + const deadline = Date.now() + 10000; + while (!fs.existsSync(${JSON.stringify(gate)})) { + if (Date.now() > deadline) throw new Error('Fixture timed out'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } + } + sync(fd); + }; + syncBuiltinESMExports(); + new JsonLibraryStore(${JSON.stringify(path)}).toggleFavorite({id:'slow',provider:'radio-browser',name:'Slow writer',tags:[]}); + `], {stdio: ['ignore', 'pipe', 'pipe']}); + const exited = once(child, 'exit'); let diagnostic = ''; child.stderr.on('data', data => { diagnostic += String(data); }); + try { + const ready = new Promise((resolve, reject) => { + child.stdout.once('data', () => resolve()); child.once('error', reject); + child.once('exit', () => reject(new Error(`Writer exited before holding lock: ${diagnostic}`))); + }); + await ready; + utimesSync(`${path}.lock`, new Date(0), new Date(0)); + const station = {id: 'competing', provider: 'radio-browser' as const, name: 'Competing writer', tags: []}; + expect(() => store.toggleFavorite(station)).toThrow(expect.objectContaining({code: 'EBUSY'})); + writeFileSync(gate, 'continue'); expect((await exited)[0], diagnostic).toBe(0); + store.toggleFavorite(station); + expect(new JsonLibraryStore(path).snapshot().favorites.map(item => item.id).sort()).toEqual(['competing', 'slow']); + } finally { + if (child.exitCode === null) { child.kill(); await exited; } + } + }, 15_000); + + it('retains both streams that collided under the legacy hash', () => { + const store = new JsonLibraryStore(file()); + const a = stationFromUrl('https://example.test/Aa'); + const b = stationFromUrl('https://example.test/BB'); + expect(a.id).not.toBe(b.id); + store.addImported([a, b]); + store.toggleFavorite(a); store.toggleFavorite(b); + expect(store.snapshot().imported).toHaveLength(2); + expect(store.snapshot().favorites).toHaveLength(2); + }); + + it('migrates embedded references and unambiguous track keys without changing arbitrary IDs', () => { + const path = file(); + const station = {...stationFromUrl('https://example.test/Aa'), id: 'custom-4bf0e7aa'}; + writeFileSync(path, JSON.stringify({favorites: [station], imported: [station], + recent: [{station, playedAt: '2026-10-03T00:00:00Z'}], presets: [{name: 'radio', station}], + trackHistory: [{title: 'Song', stationKey: 'playlist:custom-4bf0e7aa', stationName: 'Radio', at: '2026-10-03T00:00:00Z'}], + activity: {sessions: [{id: 'session', station, startedAt: '2026-10-03T00:00:00Z', listenedSeconds: 0}]}})); + const state = new JsonLibraryStore(path).snapshot(); + expect(state.favorites[0]?.legacyIds).toEqual(['custom-4bf0e7aa']); + expect(state.presets?.[0]?.station.id).toBe(state.favorites[0]?.id); + expect(state.recent[0]?.station.id).toBe(state.favorites[0]?.id); + expect(state.activity.sessions[0]?.station.id).toBe(state.favorites[0]?.id); + expect(state.trackHistory[0]?.stationKey).toBe(stationKey(state.favorites[0]!)); + expect(new JsonLibraryStore(path).snapshot().activity.sessions[0]?.endedAt).toBeUndefined(); + }); + + it('refreshes external settings while retaining a live AirPlay selection', () => { + const path = file(); + const reader = new JsonLibraryStore(path); + reader.updateSettings({preferredBackend: 'airplay'}); + const writer = new JsonLibraryStore(path); + writer.updateSettings({theme: 'amber', agentControl: {...writer.settings().agentControl!, enabled: true}}); + expect(reader.settings()).toMatchObject({theme: 'amber', preferredBackend: 'airplay', agentControl: {enabled: true}}); + writer.updateSettings({agentControl: {...writer.settings().agentControl!, enabled: false}}); + expect(reader.settings().agentControl?.enabled).toBe(false); + }); + + it('retains the current AirPlay output through library and alarm writes, while allowing an explicit local output', () => { + const path = file(); const store = new JsonLibraryStore(path); + const station = stationFromUrl('https://example.test/live'); + store.updateSettings({preferredBackend: 'airplay', preferredAirPlayDevice: 'receiver'}); + store.toggleFavorite(station); + store.updateSettings({theme: 'amber'}); + const alarm = store.addAlarm({label: 'Radio', enabled: true, station, + schedule: {type: 'once', at: '2030-01-01T12:00:00.000Z'}, + playback: {volume: 70, fadeSeconds: 0, stopAfterMinutes: 30}, + reliability: {missedRunGraceMinutes: 5, wakeIfSupported: false}}); + store.toggleAlarm(alarm.id, false); + store.removeAlarm(alarm.id); + expect(store.settings()).toMatchObject({preferredBackend: 'airplay', preferredAirPlayDevice: 'receiver', theme: 'amber'}); + expect(new JsonLibraryStore(path).settings().preferredBackend).toBe('auto'); + store.updateSettings({preferredBackend: 'auto'}); + store.toggleFavorite(station); + expect(store.settings().preferredBackend).toBe('auto'); + }); +}); + +describe('asynchronous library repository', () => { + it.each([true, false])('publishes receiver preference changes with worker=%s', async worker => { + const path = file(); const writer = new JsonLibraryStore(path); + writer.updateSettings({receiverStyle: 'ultracode'}); + const repository = new LibraryRepository(new JsonLibraryStore(path), {worker}); + try { + await repository.readFresh(); + writeFileSync(`${path}.receiver.json`, JSON.stringify({version: 1, receiverStyle: 'mesh', libraryReceiverStyle: 'ultracode'})); + await expect.poll(() => repository.settings().receiverStyle, {timeout: 5000}).toBe('mesh'); + await repository.updateSettings({volume: 42}); + expect(new JsonLibraryStore(path).settings()).toMatchObject({receiverStyle: 'mesh', volume: 42}); + } finally { await repository.close(); } + }, 10_000); + + it('keeps acknowledged receiver output after history writes and fresh snapshots', async () => { + const path = file(); const repository = new LibraryRepository(new JsonLibraryStore(path)); + try { + await repository.updateSettings({preferredBackend: 'airplay', preferredAirPlayDevice: 'receiver'}); + await repository.addRecent(stationFromUrl('https://example.test/live')); + await repository.updateSettings({theme: 'amber'}); + expect((await repository.readFresh()).settings).toMatchObject({preferredBackend: 'airplay', preferredAirPlayDevice: 'receiver'}); + expect(new JsonLibraryStore(path).settings().preferredBackend).toBe('auto'); + } finally { await repository.close(); } + }, 15_000); + + it('drains admitted writes on close and rejects writes after the barrier', async () => { + const path = file(); const repository = new LibraryRepository(new JsonLibraryStore(path)); + const saved = repository.updateSettings({volume: 23}); + const closed = repository.close(); + await expect(repository.updateSettings({volume: 99})).rejects.toThrow('closed'); + await Promise.all([saved, closed]); + expect(new JsonLibraryStore(path).settings().volume).toBe(23); + expect(repository.close()).toBe(closed); + }, 15_000); + + it('publishes external writes when the worker is unavailable', async () => { + const path = file(); const store = new JsonLibraryStore(path); store.updateSettings({volume: 23}); + const repository = new LibraryRepository(store, {worker: false}); + try { + new JsonLibraryStore(path).updateSettings({theme: 'amber'}); + await new Promise(resolve => { const unsubscribe = repository.subscribe(() => { unsubscribe(); resolve(); }); }); + expect(repository.settings().theme).toBe('amber'); + } finally { await repository.close(); } + }); + + it('acknowledges persisted, ordered mutations and merges another writer', async () => { + const path = file(); + const repository = new LibraryRepository(new JsonLibraryStore(path)); + try { + await repository.readFresh(); + const first = repository.updateSettings({theme: 'amber'}); + const second = repository.addImported([stationFromUrl('https://example.test/live')]); + await Promise.all([first, second]); + new JsonLibraryStore(path).updateSettings({volume: 33}); + expect((await repository.readFresh()).settings).toMatchObject({theme: 'amber', volume: 33}); + expect(repository.getSnapshot().imported).toHaveLength(1); + } finally { await repository.close(); } + }, 15_000); + + it('keeps the caller event loop responsive while another writer holds the lock', async () => { + const path = file(); + const repository = new LibraryRepository(new JsonLibraryStore(path)); + try { + await repository.readFresh(); + mkdirSync(`${path}.lock`); + const save = repository.updateSettings({volume: 19}); + await new Promise(resolve => setTimeout(resolve, 40)); + rmSync(`${path}.lock`, {recursive: true}); + await save; + expect(new JsonLibraryStore(path).settings().volume).toBe(19); + } finally { rmSync(`${path}.lock`, {recursive: true, force: true}); await repository.close(); } + }, 15_000); +}); diff --git a/src/storage/file-lock.test.ts b/src/storage/file-lock.test.ts new file mode 100644 index 0000000..e43c621 --- /dev/null +++ b/src/storage/file-lock.test.ts @@ -0,0 +1,77 @@ +import * as fs from 'node:fs'; +import {mkdtempSync, mkdirSync, readdirSync, rmSync, utimesSync, writeFileSync} from 'node:fs'; +import {randomUUID} from 'node:crypto'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {acquireFileLock} from './file-lock.js'; + +vi.mock('node:fs', async original => ({...await original()})); +const roots: string[] = []; +afterEach(() => { vi.restoreAllMocks(); for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); }); +function fixture() { const root = mkdtempSync(join(tmpdir(), 'radiocli-lock-')); roots.push(root); return join(root, 'library.lock'); } + +describe('library writer ownership', () => { + it('releases its marker after a partial owner-marker write fails', () => { + const directory = fixture(); + const write = fs.writeFileSync; + vi.spyOn(fs, 'writeFileSync').mockImplementationOnce((...args) => { + write(...args); + throw Object.assign(new Error('full disk'), {code: 'ENOSPC'}); + }); + expect(() => acquireFileLock(directory)).toThrow(expect.objectContaining({code: 'ENOSPC'})); + expect(fs.existsSync(directory)).toBe(false); + acquireFileLock(directory)(); + }); + + it('never expires a live writer, even after clock changes or a long suspension', () => { + const directory = fixture(); const release = acquireFileLock(directory); + const original = readdirSync(directory); + utimesSync(directory, new Date(0), new Date(0)); + expect(() => acquireFileLock(directory, 20)).toThrow(expect.objectContaining({code: 'EBUSY'})); + expect(readdirSync(directory)).toEqual(original); + release(); + const next = acquireFileLock(directory); const replacement = readdirSync(directory); + release(); // An old release must not remove a later owner's lock. + expect(readdirSync(directory)).toEqual(replacement); + next(); + }); + + it.each(['', 'unknown-owner', 'owner-999999999999999999999-123'])('leaves unknown/legacy ownership intact (%s)', marker => { + const directory = fixture(); mkdirSync(directory); + if (marker) writeFileSync(join(directory, marker), ''); + utimesSync(directory, new Date(0), new Date(0)); + expect(() => acquireFileLock(directory, 20)).toThrow(expect.objectContaining({code: 'EBUSY'})); + expect(readdirSync(directory)).toEqual(marker ? [marker] : []); + }); + + it.each(['ESRCH', 'EPERM', 'EINVAL'])('reclaims only a positively dead owner (%s)', code => { + const directory = fixture(); mkdirSync(directory); + const owner = `owner-12345-${randomUUID()}`; writeFileSync(join(directory, owner), ''); + vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('probe'), {code}); }); + if (code === 'ESRCH') { const release = acquireFileLock(directory); expect(readdirSync(directory)).not.toContain(owner); release(); } + else { expect(() => acquireFileLock(directory, 20)).toThrow(expect.objectContaining({code: 'EBUSY'})); expect(readdirSync(directory)).toEqual([owner]); } + }); + + it('recovers a released generation after cleanup failure while its PID is still alive', () => { + const directory = fixture(); const release = acquireFileLock(directory); + const unlink = vi.spyOn(fs, 'unlinkSync').mockImplementationOnce(() => { throw Object.assign(new Error('busy'), {code: 'EACCES'}); }); + release(); unlink.mockRestore(); + expect(readdirSync(directory)[0]).toMatch(/^released-/); + // This is an ownership assertion, not a sub-30ms filesystem benchmark. + acquireFileLock(directory)(); + expect(fs.existsSync(directory)).toBe(false); + }); + + it('cannot reap a replacement generation after inspecting a dead owner', () => { + const directory = fixture(); mkdirSync(directory); + const dead = `owner-12345-${randomUUID()}`; writeFileSync(join(directory, dead), ''); + const alive = `owner-${process.pid}-${randomUUID()}`; + vi.spyOn(process, 'kill').mockImplementation(pid => { + if (pid === 12345) { fs.unlinkSync(join(directory, dead)); writeFileSync(join(directory, alive), ''); throw Object.assign(new Error('dead'), {code: 'ESRCH'}); } + return true; + }); + expect(() => acquireFileLock(directory, 20)).toThrow(expect.objectContaining({code: 'EBUSY'})); + expect(readdirSync(directory)).toEqual([alive]); + }); +}); diff --git a/src/storage/file-lock.ts b/src/storage/file-lock.ts new file mode 100644 index 0000000..77fb3ef --- /dev/null +++ b/src/storage/file-lock.ts @@ -0,0 +1,71 @@ +import {mkdirSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {performance} from 'node:perf_hooks'; + +/** An owner is never evicted for being slow. Only a positively dead PID or an + * explicitly released generation can be reclaimed. Unknown/legacy locks fail + * closed; their age cannot distinguish a crashed writer from a suspended one. */ +export function acquireFileLock(directory: string, timeoutMs = 1000): () => void { + const marker = `owner-${process.pid}-${randomUUID()}`; + const deadline = performance.now() + timeoutMs; + while (true) { + let acquired = false; + try { mkdirSync(directory, {mode: 0o700}); acquired = true; } + catch (error) { if (codeOf(error) !== 'EEXIST') throw error; } + if (acquired) { + try { writeFileSync(join(directory, marker), '', {mode: 0o600, flag: 'wx'}); } + catch (error) { + // A failed write can leave its newly created marker behind. Remove only + // our unique file; never clear a directory belonging to a later writer. + try { unlinkSync(join(directory, marker)); } catch { /* May not have been created. */ } + try { rmdirSync(directory); } catch { /* Never remove another generation. */ } + throw error; + } + let released = false; + return () => { if (!released) { released = true; finishLock(directory, marker); } }; + } + + try { + const entries = readdirSync(directory); + const owner = entries.length === 1 ? entries[0]! : ''; + const match = /^(owner|released)-([1-9]\d*)-([a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12})$/.exec(owner); + if (match && Number.isSafeInteger(Number(match[2])) && (match[1] === 'released' || isDead(Number(match[2])))) { + // Compare and claim the exact marker, never rename/delete the lock + // directory after an observation: a different writer may now own it. + const reaper = `owner-${process.pid}-${randomUUID()}`; + renameSync(join(directory, owner), join(directory, reaper)); + finishLock(directory, reaper); + } + } catch (error) { if (codeOf(error) !== 'ENOENT') throw error; } + if (performance.now() >= deadline) { + throw Object.assign(new Error('The library lock has a live or unknown owner.'), {code: 'EBUSY'}); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } +} + +function isDead(pid: number): boolean { + try { process.kill(pid, 0); return false; } + 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 { /* Preserve the transaction result. */ } return; } + // Publish completion before cleanup so a failed unlink is recoverable even + // while this process is alive. Cleanup must not turn a commit into a failure. + try { removeOwnedLock(directory, released); } catch { /* Recover on the next acquisition. */ } +} + +function removeOwnedLock(directory: string, marker: string): void { + try { unlinkSync(join(directory, marker)); } + catch (error) { if (codeOf(error) === 'ENOENT') return; throw error; } + // An empty directory can be concurrently replaced only by a legacy writer. + // rmdir cannot remove a new nonempty generation, unlike recursive rm. + try { rmdirSync(directory); } + catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(codeOf(error) ?? '')) throw error; } +} + +function codeOf(error: unknown): string | undefined { return (error as NodeJS.ErrnoException)?.code; } diff --git a/src/storage/read-settings.ts b/src/storage/read-settings.ts new file mode 100644 index 0000000..9465abe --- /dev/null +++ b/src/storage/read-settings.ts @@ -0,0 +1,22 @@ +import {closeSync, constants, fstatSync, openSync, readSync} from 'node:fs'; + +/** Bounded, read-only preference inspection. Never creates or repairs a library. */ +export function readSettingsWithoutMigration(path: string | undefined): Record | undefined { + if (!path) return undefined; + let descriptor: number | undefined; + const limit = 2 * 1024 * 1024; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); + const info = fstatSync(descriptor); + if (!info.isFile() || info.size > limit) return undefined; + const buffer = Buffer.alloc(limit + 1); + const size = readSync(descriptor, buffer, 0, buffer.length, 0); + if (size > limit) return undefined; + const data: unknown = JSON.parse(buffer.toString('utf8', 0, size)); + if (!record(data)) return undefined; + return data.settings === undefined ? {} : record(data.settings) ? data.settings : undefined; + } catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? {} : undefined; } + finally { if (descriptor !== undefined) try { closeSync(descriptor); } catch { /* Read-only best effort. */ } } +} + +function record(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } diff --git a/src/storage/replace-file.test.ts b/src/storage/replace-file.test.ts new file mode 100644 index 0000000..a894baf --- /dev/null +++ b/src/storage/replace-file.test.ts @@ -0,0 +1,68 @@ +import * as fs from 'node:fs'; +import {mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {replaceFileSync} from './replace-file.js'; + +vi.mock('node:fs', async importOriginal => ({...await importOriginal()})); + +const roots: string[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'radiocli replace 日本 ')); + roots.push(root); + const source = join(root, 'pending.json'); + const destination = join(root, 'library.json'); + writeFileSync(source, 'new library'); + writeFileSync(destination, 'saved library'); + return {root, source, destination}; +} + +describe('Windows atomic file replacement', () => { + it.each(['EPERM', 'EACCES', 'EBUSY'])('retains saved bytes during a transient %s and then commits', code => { + const {root, source, destination} = fixture(); + const rename = fs.renameSync; + let attempts = 0; + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + expect(readFileSync(destination, 'utf8')).toBe('saved library'); + if (++attempts <= 2) throw Object.assign(new Error('temporarily locked'), {code}); + return rename(from, to); + }); + const wait = vi.spyOn(Atomics, 'wait').mockReturnValue('timed-out'); + replaceFileSync(source, destination, 'win32'); + expect(attempts).toBe(3); + expect(wait.mock.calls.map(call => call[3])).toEqual([10, 20]); + expect(readFileSync(destination, 'utf8')).toBe('new library'); + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it('bounds persistent denial and leaves both files intact for caller cleanup', () => { + const {source, destination} = fixture(); + const failure = Object.assign(new Error('permanently denied'), {code: 'EPERM'}); + const rename = vi.spyOn(fs, 'renameSync').mockImplementation(() => { throw failure; }); + const wait = vi.spyOn(Atomics, 'wait').mockReturnValue('timed-out'); + expect(() => replaceFileSync(source, destination, 'win32')).toThrow(failure); + expect(rename).toHaveBeenCalledTimes(11); + expect(wait.mock.calls.reduce((sum, call) => sum + (call[3] ?? 0), 0)).toBe(550); + expect(readFileSync(destination, 'utf8')).toBe('saved library'); + expect(readFileSync(source, 'utf8')).toBe('new library'); + }); + + it.each([['linux', 'EPERM'], ['darwin', 'EACCES'], ['win32', 'ENOSPC'], ['win32', 'EIO']] as const)( + 'immediately reports %s %s without retrying', (platform, code) => { + const {source, destination} = fixture(); + const failure = Object.assign(new Error('write failed'), {code}); + const rename = vi.spyOn(fs, 'renameSync').mockImplementation(() => { throw failure; }); + const wait = vi.spyOn(Atomics, 'wait').mockReturnValue('timed-out'); + expect(() => replaceFileSync(source, destination, platform)).toThrow(failure); + expect(rename).toHaveBeenCalledTimes(1); + expect(wait).not.toHaveBeenCalled(); + expect(readFileSync(destination, 'utf8')).toBe('saved library'); + } + ); +}); diff --git a/src/storage/replace-file.ts b/src/storage/replace-file.ts new file mode 100644 index 0000000..0c24564 --- /dev/null +++ b/src/storage/replace-file.ts @@ -0,0 +1,17 @@ +import {renameSync} from 'node:fs'; + +/** Keep the destination intact until the replacement succeeds. Windows readers + * and virus scanners can briefly deny replacement even in a writable folder. */ +export function replaceFileSync(source: string, destination: string, platform: NodeJS.Platform = process.platform): void { + const pause = new Int32Array(new SharedArrayBuffer(4)); + for (let retry = 0; ; retry++) { + try { renameSync(source, destination); return; } + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (platform !== 'win32' || !['EACCES', 'EPERM', 'EBUSY'].includes(code ?? '') || retry >= 10) throw error; + // At most 550 ms in total, while retaining the writer lock and flushed + // temporary file. Never unlink the destination to work around a lock. + Atomics.wait(pause, 0, 0, (retry + 1) * 10); + } + } +} diff --git a/src/storage/repository.ts b/src/storage/repository.ts new file mode 100644 index 0000000..8bafd33 --- /dev/null +++ b/src/storage/repository.ts @@ -0,0 +1,214 @@ +import {Worker} from 'node:worker_threads'; +import type {Alarm, AlarmCreateInput, AlarmRunRecord, AppSettings, LibraryState, Station, UpdateCheckState} from '../types.js'; +import {JsonLibraryStore, stationKey, type LibraryImportResult} from './store.js'; + +const methods = ['snapshot', 'savePreset', 'removePreset', 'exportBackup', 'importBackup', 'updateSettings', 'updateCheckState', + 'addAlarm', 'updateAlarm', 'removeAlarm', 'toggleAlarm', 'recordAlarmOutcome', 'snoozeAlarm', 'clearAlarmSnooze', + 'addRecent', 'recordTrack', 'addSearch', 'startListeningSession', 'checkpointActiveListeningSession', + 'finishActiveListeningSession', 'toggleFavorite', 'addImported'] as const; +type Method = typeof methods[number]; +type Request = {id: number; method: Method; args: unknown[]}; +type Reply = {ready?: boolean; id?: number; result?: unknown; state?: LibraryState; error?: {message: string; name: string; code?: string}}; +type Pending = {request: Request; resolve: (result: unknown) => void; reject: (error: Error) => void}; + +/** One ordered transactional writer, off the UI/audio event loop. Sync stores remain + * usable for short-lived commands and injected adapters; success follows persistence. */ +export class LibraryRepository { + readonly filePath: string; + private state: LibraryState; + private readonly listeners = new Set<(state: LibraryState) => void>(); + private readonly errors = new Set<(error: Error) => void>(); + private readonly pending = new Map(); + private worker: Worker | undefined; + private nextId = 1; + private inlineQueue: Promise = Promise.resolve(); + private closed = false; + private failure: Error | undefined; + private closing: Promise | undefined; + private workerReady = false; + private fallbackRefresh: NodeJS.Timeout | undefined; + + constructor(private readonly store: JsonLibraryStore, options: {worker?: boolean} = {}) { + this.filePath = store.filePath; + this.state = store.snapshot(); + if (options.worker === false) { this.startFallbackRefresh(); return; } + const source = import.meta.url.endsWith('.ts'); + const storeUrl = new URL(source ? './store.ts' : './store.js', import.meta.url).href; + try { this.worker = new Worker(repositoryWorker, {eval: true, workerData: {storeUrl, source, filePath: this.filePath, methods}, execArgv: []}); } + catch { this.startFallbackRefresh(); return; /* Some restricted hosts disable threads; transactional storage still works. */ } + this.worker.on('message', (reply: Reply) => this.receive(reply)); + this.worker.on('error', error => this.fail(error)); + this.worker.on('exit', code => { if (!this.closed) this.fail(new Error(`Library worker exited (${code}).`)); }); + this.worker.unref(); + } + + snapshot(): LibraryState { return structuredClone(this.state); } + getSnapshot(): LibraryState { return this.state; } + settings(): AppSettings { return structuredClone(this.state.settings); } + listAlarms(): Alarm[] { return structuredClone(this.state.alarms); } + getAlarm(id: string): Alarm | undefined { return structuredClone(this.state.alarms.find(alarm => alarm.id === id)); } + isFavorite(station?: Station | null): boolean { return Boolean(station && this.state.favorites.some(item => stationKey(item) === stationKey(station))); } + subscribe(listener: (state: LibraryState) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } + onError(listener: (error: Error) => void): () => void { this.errors.add(listener); return () => this.errors.delete(listener); } + readFresh(): Promise { return this.call('snapshot'); } + savePreset(name: string, station: Station): Promise { return this.call('savePreset', name, station); } + removePreset(name: string): Promise { return this.call('removePreset', name); } + exportBackup(path?: string): Promise { return this.call('exportBackup', path); } + importBackup(path: string): Promise { return this.call('importBackup', path); } + updateSettings(settings: Partial): Promise { return this.call('updateSettings', settings); } + updateCheckState(state: UpdateCheckState): Promise { return this.call('updateCheckState', state); } + addAlarm(input: AlarmCreateInput): Promise { return this.call('addAlarm', input); } + updateAlarm(id: string, updates: Parameters[1]): Promise { return this.call('updateAlarm', id, updates); } + removeAlarm(id: string): Promise { return this.call('removeAlarm', id); } + toggleAlarm(id: string, enabled?: boolean): Promise { return this.call('toggleAlarm', id, enabled); } + recordAlarmOutcome(id: string, outcome: AlarmRunRecord, options?: {clearNextOverride?: boolean}): Promise { return this.call('recordAlarmOutcome', id, outcome, options); } + snoozeAlarm(id: string, until: Date): Promise { return this.call('snoozeAlarm', id, until); } + clearAlarmSnooze(id: string): Promise { return this.call('clearAlarmSnooze', id); } + addRecent(station: Station): Promise { return this.call('addRecent', station); } + recordTrack(station: Station, title: string): Promise { return this.call('recordTrack', station, title); } + addSearch(query: string): Promise { return this.call('addSearch', query); } + startListeningSession(station: Station, at = new Date()): Promise { return this.call('startListeningSession', station, at); } + checkpointActiveListeningSession(at = new Date()): Promise { return this.call('checkpointActiveListeningSession', at); } + finishActiveListeningSession(at = new Date()): Promise { return this.call('finishActiveListeningSession', at); } + toggleFavorite(station: Station): Promise { return this.call('toggleFavorite', station); } + addImported(stations: Station[]): Promise { return this.call('addImported', stations); } + + async flush(): Promise { + // A barrier command follows every previously submitted semantic mutation. + await this.readFresh(); + } + close(): Promise { + return this.closing ??= (async () => { + try { await this.flush(); } + finally { + this.closed = true; + clearInterval(this.fallbackRefresh); + await this.worker?.terminate(); + for (const pending of this.pending.values()) pending.reject(new Error('RadioCLI library repository is closed.')); + this.pending.clear(); this.listeners.clear(); this.errors.clear(); + } + })(); + } + + private call(method: Method, ...args: unknown[]): Promise { + if (this.closed || this.closing) return Promise.reject(new Error('RadioCLI library repository is closed.')); + return this.enqueue(method, args); + } + private enqueue(method: Method, args: unknown[]): Promise { + if (this.failure) return Promise.reject(this.failure); + if (!this.worker) { + const result = this.inlineQueue.catch(() => undefined).then(() => { + const operation = this.store[method] as (...values: unknown[]) => unknown; + const value = operation.apply(this.store, args); + this.publish(this.store.cachedSnapshot()); + return value as T; + }); + this.inlineQueue = result; + return result; + } + const id = this.nextId++; + this.worker.ref(); + return new Promise((resolve, reject) => { + const request = {id, method, args} satisfies Request; + this.pending.set(id, {request, resolve: value => resolve(value as T), reject}); + try { if (this.workerReady) this.worker!.postMessage(request); } + catch (error) { this.pending.delete(id); reject(error); if (!this.pending.size) this.worker!.unref(); } + }); + } + + private receive(reply: Reply): void { + if (reply.state) this.publish(reply.state); + if (reply.ready) { + this.workerReady = true; + for (const {request} of this.pending.values()) this.worker!.postMessage(request); + return; + } + if (reply.id === undefined) { + if (reply.error) this.report(deserializeError(reply.error)); + return; + } + const pending = this.pending.get(reply.id); + this.pending.delete(reply.id); + if (reply.error) pending?.reject(deserializeError(reply.error)); + else pending?.resolve(reply.result); + if (!this.pending.size) this.worker?.unref(); + } + private publish(state: LibraryState): void { + this.state = state; + for (const listener of this.listeners) { try { listener(state); } catch { /* Consumers cannot invalidate a durable commit. */ } } + } + private report(error: Error): void { for (const listener of this.errors) { try { listener(error); } catch { /* Isolate observers. */ } } } + private fail(error: Error): void { + if (!this.workerReady && this.worker) { + const worker = this.worker; + this.worker = undefined; + void worker.terminate(); + this.startFallbackRefresh(); + // No operation is sent before the ready handshake, so this fallback cannot + // replay a possibly committed toggle or alarm mutation. + const waiting = [...this.pending.values()]; + this.pending.clear(); + for (const pending of waiting) void this.enqueue(pending.request.method, pending.request.args).then(pending.resolve, pending.reject); + this.report(new Error(`Library worker unavailable; using transactional storage: ${error.message}`)); + return; + } + if (!this.workerReady && !this.worker) return; + this.failure = error; + for (const request of this.pending.values()) request.reject(error); + this.pending.clear(); + this.worker?.unref(); + this.report(error); + } + private startFallbackRefresh(): void { + this.fallbackRefresh ??= setInterval(() => { + if (this.closed || this.closing) return; + const refresh = this.inlineQueue.catch(() => undefined).then(() => { + if (this.store.refresh()) this.publish(this.store.cachedSnapshot()); + }).catch(error => this.report(error instanceof Error ? error : new Error(String(error)))); + this.inlineQueue = refresh; + }, 1000); + this.fallbackRefresh.unref(); + } +} + +function deserializeError(value: NonNullable): Error { + return Object.assign(new Error(value.message), {name: value.name, ...(value.code ? {code: value.code} : {})}); +} + +// Only enumerated semantic operations cross the worker boundary. No user data +// becomes executable code. Source mode uses the existing development runner; +// the packed build loads ordinary ESM and has no development dependency. +const repositoryWorker = ` +const {parentPort, workerData} = require('node:worker_threads'); +const {watch} = require('node:fs'); +const {dirname} = require('node:path'); +(async () => { + const module = workerData.source + ? await (await import('tsx/esm/api')).tsImport(workerData.storeUrl, {parentURL: workerData.storeUrl}) + : await import(workerData.storeUrl); + const store = new module.JsonLibraryStore(workerData.filePath); + const allowed = new Set(workerData.methods); + const errorValue = error => ({message: error.message || 'Library operation failed.', name: error.name || 'Error', code: error.code}); + let queue = Promise.resolve(); + let watcher; + const refresh = () => { + try { if (store.refresh()) parentPort.postMessage({state: store.snapshot()}); } + catch (error) { parentPort.postMessage({error: errorValue(error)}); } + }; + try { watcher = watch(dirname(workerData.filePath), {persistent: false}, () => { queue = queue.catch(() => {}).then(refresh); }); } + catch { /* Missing folders and platforms without watchers use the revision fallback. */ } + const timer = setInterval(() => { queue = queue.catch(() => {}).then(refresh); }, 1000); + timer.unref(); + parentPort.on('message', request => { + queue = queue.catch(() => {}).then(async () => { + try { + if (!allowed.has(request.method)) throw new Error('Unknown library operation.'); + const result = await store[request.method](...request.args); + const state = result && result.settings && result.activity ? result : store.cachedSnapshot(); + parentPort.postMessage({id: request.id, result, state}); + } catch (error) { parentPort.postMessage({id: request.id, error: errorValue(error)}); } + }); + }); + parentPort.postMessage({ready: true, state: store.snapshot()}); +})().catch(error => { throw error; }); +`; diff --git a/src/storage/station-identity.ts b/src/storage/station-identity.ts new file mode 100644 index 0000000..be926d2 --- /dev/null +++ b/src/storage/station-identity.ts @@ -0,0 +1,24 @@ +import {createHash} from 'node:crypto'; +import type {Station} from '../types.js'; + +/** Paths and signed query parameters are significant: hash the exact target. */ +export function customStationId(target: string): string { + return `custom-v2-${createHash('sha256').update(target).digest('hex')}`; +} + +function legacyCustomStationId(target: string): string { + let hash = 0; + for (let index = 0; index < target.length; index += 1) hash = (hash * 31 + target.charCodeAt(index)) >>> 0; + return `custom-${hash.toString(16)}`; +} + +/** Only migrate IDs this application generated; preserve user-assigned IDs. */ +export function migrateStationIdentity(station: Station): Station { + if (station.provider !== 'playlist' || !station.streamUrl || station.id !== legacyCustomStationId(station.streamUrl)) return station; + return {...station, id: customStationId(station.streamUrl), legacyIds: [...new Set([...(station.legacyIds ?? []), station.id])]}; +} + +export function matchesStationId(station: Station, id: string): boolean { + const normalized = id.replace(new RegExp(`^${station.provider}:`), ''); + return station.id === normalized || Boolean(station.legacyIds?.includes(normalized)); +} diff --git a/src/storage/station-schema.ts b/src/storage/station-schema.ts new file mode 100644 index 0000000..cfec44b --- /dev/null +++ b/src/storage/station-schema.ts @@ -0,0 +1,29 @@ +import {z} from 'zod'; +import type {Station} from '../types.js'; + +export const stationSchema: z.ZodType = z + .object({ + id: z.string(), + legacyIds: z.array(z.string()).optional(), + provider: z.enum(['radio-browser', 'radio-garden', 'playlist']), + name: z.string(), + country: z.string().optional(), + countryCode: z.string().optional(), + state: z.string().optional(), + city: z.string().optional(), + language: z.string().optional(), + languageCodes: z.array(z.string()).optional(), + tags: z.array(z.string()), + codec: z.string().optional(), + bitrate: z.number().optional(), + homepage: z.string().optional(), + favicon: z.string().optional(), + streamUrl: z.string().optional(), + clickCount: z.number().optional(), + votes: z.number().optional(), + latitude: z.number().optional(), + longitude: z.number().optional(), + distanceKm: z.number().optional(), + hls: z.boolean().optional(), + lastCheckedOk: z.boolean().optional() +}); diff --git a/src/storage/store.test.ts b/src/storage/store.test.ts index 3c3df41..8ab6b3d 100644 --- a/src/storage/store.test.ts +++ b/src/storage/store.test.ts @@ -420,6 +420,31 @@ describe('JsonLibraryStore', () => { expect(new JsonLibraryStore(file).snapshot().favorites.map(item => item.id)).toContain('favorite'); }); + it('backs up the latest concurrent library before replacing it on restore', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli restore café ')); + roots.push(root); + const file = join(root, 'library.json'); + const backupFile = join(root, 'portable backup.json'); + const restoring = new JsonLibraryStore(file); + restoring.updateSettings({theme: 'ruby'}); + restoring.exportBackup(backupFile); + + const writer = new JsonLibraryStore(file); + const station: Station = {id: 'new-favorite', provider: 'radio-browser', name: 'New Favorite', tags: []}; + writer.toggleFavorite(station); + writer.savePreset('new', station); + writer.updateSettings({theme: 'amber', receiverStyle: 'mesh'}); + + const result = restoring.importBackup(backupFile); + const safety = new JsonLibraryStore(result.safetyBackupPath!).snapshot(); + expect(safety.favorites.map(item => item.id)).toEqual(['new-favorite']); + expect(safety.presets?.[0]?.station.id).toBe('new-favorite'); + expect(safety.settings).toMatchObject({theme: 'amber', receiverStyle: 'mesh'}); + expect(result.state.favorites).toEqual([]); + expect(result.state.settings.theme).toBe('ruby'); + expect(new JsonLibraryStore(file).snapshot().settings.theme).toBe('ruby'); + }); + it('rejects invalid imports without replacing the current library', () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-')); roots.push(root); @@ -555,6 +580,23 @@ describe('JsonLibraryStore', () => { expect(readFileSync(join(root, backup!), 'utf8')).toBe('{not json'); }); + it('preserves an intact library with a receiver style from a newer build', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-newer-style-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + const station: Station = {id: 'retained', provider: 'playlist', name: 'Retained station', tags: []}; + store.toggleFavorite(station); + const saved = store.snapshot(); + const contents = JSON.stringify({...saved, settings: {...saved.settings, receiverStyle: 'future-scene'}}); + writeFileSync(file, contents); + + expect(() => store.snapshot()).toThrow(/Restart RadioCLI and its MCP clients/); + expect(() => new JsonLibraryStore(file)).toThrow(/receiver style this process does not support/); + expect(readFileSync(file, 'utf8')).toBe(contents); + expect(readdirSync(root).filter(name => name.startsWith('library.json.bad-'))).toEqual([]); + }); + 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); @@ -767,21 +809,41 @@ describe('JsonLibraryStore', () => { const lockPath = `${file}.lock`; mkdirSync(lockPath); const failure = Object.assign(new Error(`lock inspection failed at ${lockPath}`), {code: 'EIO'}); - const stat = fs.statSync; + const readDirectory = fs.readdirSync; let inspected = false; - vi.spyOn(fs, 'statSync').mockImplementation(((...args: Parameters) => { + vi.spyOn(fs, 'readdirSync').mockImplementation(((...args: Parameters) => { if (args[0] === lockPath && !inspected) { inspected = true; throw failure; } - return stat(...args); - }) as typeof fs.statSync); + return readDirectory(...args); + }) as typeof fs.readdirSync); expect(() => store.updateSettings({theme: 'ruby'})).toThrow(expect.objectContaining({code: 'EIO', cause: failure})); expect(store.snapshot()).toEqual(before); expect(readFileSync(file, 'utf8')).toBe(original); }); + it('preserves both disk and memory when flushing the temporary data 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 = readFileSync(file, 'utf8'); + vi.spyOn(fs, 'fsyncSync').mockImplementationOnce(() => { throw Object.assign(new Error('flush failed'), {code: 'EIO'}); }); + expect(() => store.updateSettings({theme: 'ruby'})).toThrow(expect.objectContaining({code: 'EIO'})); + expect(readFileSync(file, 'utf8')).toBe(before); + expect(store.cachedSnapshot().settings.theme).toBe('amber'); + expect(readdirSync(root)).toEqual(['library.json']); + }); + + it('keeps a committed replacement in memory when the filesystem cannot sync directories', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); roots.push(root); + const file = join(root, 'library.json'); const store = new JsonLibraryStore(file); + const original = fs.fsyncSync; let syncs = 0; + vi.spyOn(fs, 'fsyncSync').mockImplementation(fd => { if (++syncs === 2) throw Object.assign(new Error('directory sync unsupported'), {code: 'EINVAL'}); original(fd); }); + expect(store.updateSettings({theme: 'ruby'}).settings.theme).toBe('ruby'); + expect(new JsonLibraryStore(file).settings().theme).toBe('ruby'); + }); + 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'])( 'migrates removed receiver style %s to the default receiver style', receiverStyle => { @@ -952,6 +1014,109 @@ describe('JsonLibraryStore', () => { } }); + // This matrix performs durable filesystem commits for every receiver. + it('preserves every supported receiver and favorites through background writes', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.toggleFavorite(exampleAlarm().station); + const observer = new JsonLibraryStore(file); + for (const receiverStyle of receiverStyleNames) { + store.updateSettings({receiverStyle}); + const legacy = JSON.parse(readFileSync(file, 'utf8')); + expect(legacy.settings.receiverStyle).toBe(receiverStyle); + // Old stores rewrite only the fields they know, leaving the sidecar alone. + legacy.settings.volume = 39; + writeFileSync(file, JSON.stringify(legacy)); + expect(observer.snapshot().settings).toMatchObject({receiverStyle, volume: 39}); + expect(observer.snapshot().favorites).toHaveLength(1); + } + store.updateSettings({receiverStyle: 'ultracode'}); + expect(existsSync(`${file}.receiver.json`)).toBe(false); + expect(observer.snapshot().settings.receiverStyle).toBe('ultracode'); + expect(readdirSync(root).some(name => name.includes('.bad-'))).toBe(false); + }, 20_000); + + it('refreshes receiver-only changes and exports and restores the actual selection', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const writer = new JsonLibraryStore(file); + writer.updateSettings({receiverStyle: 'ultracode'}); + writeFileSync(`${file}.receiver.json`, JSON.stringify({version: 1, receiverStyle: 'mesh', libraryReceiverStyle: 'ultracode'})); + const observer = new JsonLibraryStore(file); + const backup = writer.exportBackup(join(root, 'backup.json')); + expect(JSON.parse(readFileSync(backup, 'utf8')).library.settings.receiverStyle).toBe('mesh'); + // Simulate another owner atomically replacing only the preference file. + writeFileSync(`${file}.receiver.json`, JSON.stringify({version: 1, receiverStyle: 'galaxy', libraryReceiverStyle: 'ultracode'})); + expect(observer.snapshot().settings.receiverStyle).toBe('galaxy'); + writer.importBackup(backup); + expect(observer.snapshot().settings.receiverStyle).toBe('mesh'); + }); + + it.each(['synthwave-sun', 'neon-alley', 'safari-dusk', 'hot-air-balloons', 'deep-sea-whale', 'meteor-shower', 'volcano', 'desert-highway', 'firefly-grove', 'cherry-blossom', 'event-horizon', 'lighthouse'])('migrates retired %s selections without resetting library data', receiverStyle => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const writer = new JsonLibraryStore(file); + writer.toggleFavorite(exampleAlarm().station); + writer.addRecent(exampleAlarm().station); + const saved = writer.snapshot(); + const previous = {...saved, settings: {...saved.settings, receiverStyle}}; + writeFileSync(file, JSON.stringify(previous)); + const store = new JsonLibraryStore(file); + expect(store.settings().receiverStyle).toBe('ultracode'); + expect(store.snapshot().favorites).toEqual(saved.favorites); + expect(store.snapshot().recent).toEqual(saved.recent); + store.updateSettings({volume: 38}); + expect(JSON.parse(readFileSync(file, 'utf8')).settings.receiverStyle).toBe('ultracode'); + writeFileSync(`${file}.receiver.json`, JSON.stringify({version: 1, receiverStyle, libraryReceiverStyle: 'ultracode'})); + expect(new JsonLibraryStore(file).settings().receiverStyle).toBe('ultracode'); + const backup = join(root, 'preview-backup.json'); + writeFileSync(backup, JSON.stringify(previous)); + store.importBackup(backup); + expect(store.settings().receiverStyle).toBe('ultracode'); + expect(store.snapshot().favorites).toEqual(saved.favorites); + expect(store.snapshot().recent).toEqual(saved.recent); + expect(readdirSync(root).some(name => name.includes('.bad-'))).toBe(false); + }); + + it.each(['mesh', 'galaxy', 'ultracode'] as const)('rolls back a legacy receiver preference if the library replacement fails (%s)', receiverStyle => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + new JsonLibraryStore(file).updateSettings({receiverStyle: 'ultracode'}); + writeFileSync(`${file}.receiver.json`, JSON.stringify({version: 1, receiverStyle: 'mesh', libraryReceiverStyle: 'ultracode'})); + const store = new JsonLibraryStore(file); + const library = readFileSync(file, 'utf8'); + const preference = readFileSync(`${file}.receiver.json`, 'utf8'); + const rename = fs.renameSync; + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (to === file) throw Object.assign(new Error('replacement failed'), {code: 'EIO'}); + return rename(from, to); + }); + expect(() => store.updateSettings({receiverStyle, volume: 40})).toThrow(/write/); + expect(readFileSync(file, 'utf8')).toBe(library); + expect(readFileSync(`${file}.receiver.json`, 'utf8')).toBe(preference); + expect(store.settings().receiverStyle).toBe('mesh'); + }); + + it('preserves the library when a newer receiver preference is unsupported', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + const store = new JsonLibraryStore(file); + store.updateSettings({receiverStyle: 'ultracode'}); + const library = readFileSync(file, 'utf8'); + const preference = JSON.stringify({version: 1, receiverStyle: 'future-scene', libraryReceiverStyle: 'ultracode'}); + writeFileSync(`${file}.receiver.json`, preference); + expect(() => store.updateSettings({volume: 25})).toThrow(/library has been preserved/); + expect(readFileSync(file, 'utf8')).toBe(library); + expect(readFileSync(`${file}.receiver.json`, 'utf8')).toBe(preference); + expect(readdirSync(root).some(name => name.includes('.bad-'))).toBe(false); + }); + it('persists every display color exposed by the UI cycle', () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-')); roots.push(root); diff --git a/src/storage/store.ts b/src/storage/store.ts index 2cb6e68..6e3a9bf 100644 --- a/src/storage/store.ts +++ b/src/storage/store.ts @@ -1,6 +1,6 @@ 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 {chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; import {dirname, isAbsolute, join, resolve} from 'node:path'; import {randomUUID} from 'node:crypto'; @@ -22,32 +22,11 @@ import { } from '../types.js'; import {canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone} from '../alarms/schedule.js'; import {migrateReceiverStyle} from '../ui/visualizers/receiver-style-registry.js'; +import {migrateStationIdentity} from './station-identity.js'; +import {stationSchema} from './station-schema.js'; +import {acquireFileLock} from './file-lock.js'; +import {replaceFileSync} from './replace-file.js'; -const stationSchema: z.ZodType = z - .object({ - id: z.string(), - provider: z.enum(['radio-browser', 'radio-garden', 'playlist']), - name: z.string(), - country: z.string().optional(), - countryCode: z.string().optional(), - state: z.string().optional(), - city: z.string().optional(), - language: z.string().optional(), - languageCodes: z.array(z.string()).optional(), - tags: z.array(z.string()), - codec: z.string().optional(), - bitrate: z.number().optional(), - homepage: z.string().optional(), - favicon: z.string().optional(), - streamUrl: z.string().optional(), - clickCount: z.number().optional(), - votes: z.number().optional(), - latitude: z.number().optional(), - longitude: z.number().optional(), - distanceKm: z.number().optional(), - hls: z.boolean().optional(), - lastCheckedOk: z.boolean().optional() - }); const defaultMediaKeys = { previous: [], @@ -57,6 +36,17 @@ const defaultMediaKeys = { const MAX_ALARMS = 500; +// Keep the shared library readable by pre-scene releases whose background MCP +// workers quarantine an entire library when they see an unfamiliar enum value. +// Crossfade is the last style understood by those releases. New styles live in +// a separate preference file that old writers never read or overwrite. +const sharedLibraryReceiverStyles = new Set(receiverStyleNames.slice(0, receiverStyleNames.indexOf('crossfade') + 1)); +const receiverPreferenceSchema = z.object({ + version: z.literal(1), + receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames)), + libraryReceiverStyle: z.literal('ultracode') +}); + const settingsSchema: z.ZodType = z.object({ theme: z.enum(themeNames).default('green'), receiverStyle: z.preprocess( @@ -70,6 +60,7 @@ const settingsSchema: z.ZodType = z.object({ shareDirectoryVotes: z.boolean().default(true), preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'), preferredAirPlayDevice: z.string().min(1).optional(), + linuxAirPlayEnabled: z.boolean().optional(), tuneTimeoutSeconds: z.number().min(3).max(45).default(12), skipBrokenStreams: z.boolean().default(true), mediaKeys: z @@ -85,6 +76,7 @@ const settingsSchema: z.ZodType = z.object({ reduceMotion: z.boolean().default(false), mouseSupport: z.boolean().default(true), automaticUpdateChecks: z.boolean().default(true), + desktopMediaControls: z.enum(['auto', 'on', 'off']).default('auto'), agentControl: z.object({ enabled: z.boolean().default(false), openUiOnPlay: z.boolean().default(true), @@ -201,6 +193,10 @@ const librarySchema: z.ZodType = z.object({ ) .default([]), favorites: z.array(stationSchema).default([]), + presets: z.array(z.object({ + name: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}$/), + station: stationSchema + })).max(200).default([]), imported: z.array(stationSchema).default([]), trackHistory: z .array( @@ -281,6 +277,7 @@ export class JsonLibraryStore { private state: LibraryState; private readonly idGenerator: () => string; private readonly now: () => Date; + private revision = ''; constructor( filePath = defaultLibraryPath(), @@ -289,14 +286,67 @@ export class JsonLibraryStore { this.filePath = filePath; this.idGenerator = options.idGenerator ?? randomUUID; this.now = options.now ?? (() => new Date()); + this.revision = this.diskRevision(); this.state = this.read(); + // Normalize libraries saved by an earlier preview before any legacy + // background reader encounters its new enum value again. + if (!sharedLibraryReceiverStyles.has(this.state.settings.receiverStyle) && !existsSync(this.receiverPreferencePath())) this.commit(this.state); } snapshot(): LibraryState { + this.refresh(); + return this.cachedSnapshot(); + } + + cachedSnapshot(): LibraryState { return structuredClone(this.state); } + /** Refresh atomically replaced files without recovering a live listening session. */ + refresh(): boolean { + const revision = this.diskRevision(); + if (revision === this.revision) return false; + const next = this.readForCurrentSession(); + this.state = next; + this.revision = revision; + return true; + } + + settings(): AppSettings { + this.refresh(); + return structuredClone(this.state.settings); + } + + private diskRevision(): string { + return [this.filePath, this.receiverPreferencePath()].map(file => { + try { + const info = statSync(file, {bigint: true}); + return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'; + throw storageError('read the RadioCLI library', error); + } + }).join('|'); + } + + savePreset(name: string, station: Station): void { + this.refresh(); + const key = normalizePresetName(name); + const presets = this.state.presets ?? []; + if (presets.length >= 200 && !presets.some(preset => preset.name === key)) throw new Error('RadioCLI supports at most 200 station presets. Remove one with radiocli preset remove .'); + this.commit({...this.state, presets: [{name: key, station}, ...presets.filter(preset => preset.name !== key)]}); + } + + removePreset(name: string): void { + this.refresh(); + const key = normalizePresetName(name); + const presets = this.state.presets ?? []; + if (!presets.some(preset => preset.name === key)) throw new Error(`No preset named ${key}. Run radiocli presets to see saved names.`); + this.commit({...this.state, presets: presets.filter(preset => preset.name !== key)}); + } + exportBackup(requestedPath?: string): string { + this.refresh(); const targetPath = requestedPath?.trim() ? resolveUserPath(requestedPath) : resolve(process.cwd(), `radiocli-backup-${fileTimestamp(new Date())}.json`); @@ -340,7 +390,9 @@ export class JsonLibraryStore { ? `${this.filePath}.before-import-${fileTimestamp(new Date())}.bak` : undefined; if (safetyBackupPath) { - writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.state)); + // Another TUI or background player may have saved since this owner + // last refreshed. Preserve its latest data while holding the lock. + writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.read())); } this.write(importedState); this.state = importedState; @@ -351,6 +403,7 @@ export class JsonLibraryStore { } updateSettings(settings: Partial): LibraryState { + this.refresh(); return this.commit({ ...this.state, settings: { @@ -362,13 +415,14 @@ export class JsonLibraryStore { } updateCheckState(updateCheck: UpdateCheckState): LibraryState { + this.refresh(); return this.commit({...this.state, updateCheck}); } addAlarm(input: AlarmCreateInput): Alarm { const release = acquireStoreLock(this.filePath); try { - const latestState = this.read(); + const latestState = this.readForCurrentSession(); if (latestState.alarms.length >= MAX_ALARMS) { throw new Error(`RadioCLI supports at most ${MAX_ALARMS} alarms.`); } @@ -413,7 +467,7 @@ export class JsonLibraryStore { removeAlarm(id: string): boolean { const release = acquireStoreLock(this.filePath); try { - const latestState = this.read(); + const latestState = this.readForCurrentSession(); if (!latestState.alarms.some(alarm => alarm.id === id)) { this.state = latestState; return false; @@ -436,11 +490,13 @@ export class JsonLibraryStore { } getAlarm(id: string): Alarm | undefined { + this.refresh(); const alarm = this.state.alarms.find(item => item.id === id); return alarm ? structuredClone(alarm) : undefined; } listAlarms(): Alarm[] { + this.refresh(); return structuredClone(this.state.alarms); } @@ -478,6 +534,7 @@ export class JsonLibraryStore { } addRecent(station: Station): LibraryState { + this.refresh(); const key = stationKey(station); const recent = [ {station, playedAt: new Date().toISOString()}, @@ -490,9 +547,10 @@ export class JsonLibraryStore { // Persist the ICY track titles a station announces so "what was that song?" // is answerable later. Consecutive duplicates on the same station are skipped. recordTrack(station: Station, title: string): LibraryState { + this.refresh(); const cleaned = title.trim(); if (!cleaned) { - return this.snapshot(); + return this.cachedSnapshot(); } const key = stationKey(station); @@ -513,6 +571,7 @@ export class JsonLibraryStore { // Most-recent-first search queries for up-arrow recall in the search box. addSearch(query: string): LibraryState { + this.refresh(); const cleaned = query.trim(); if (!cleaned) { return this.snapshot(); @@ -523,6 +582,7 @@ export class JsonLibraryStore { } startListeningSession(station: Station, startedAt = new Date()): LibraryState { + this.refresh(); const finishedState = recoverActiveListeningSessionInState(this.state); const session: ListeningSession = { id: `${startedAt.toISOString()}-${stationKey(station)}`, @@ -541,6 +601,7 @@ export class JsonLibraryStore { } checkpointActiveListeningSession(activeAt = new Date()): LibraryState { + this.refresh(); const active = this.state.activity.sessions[0]; if (!active || active.endedAt) { return this.snapshot(); @@ -562,6 +623,7 @@ export class JsonLibraryStore { } finishActiveListeningSession(endedAt = new Date()): LibraryState { + this.refresh(); const nextState = finishActiveListeningSessionInState(this.state, endedAt); if (nextState === this.state) { return this.snapshot(); @@ -571,6 +633,7 @@ export class JsonLibraryStore { } toggleFavorite(station: Station): LibraryState { + this.refresh(); const key = stationKey(station); const exists = this.state.favorites.some(item => stationKey(item) === key); const favorites = exists @@ -581,6 +644,7 @@ export class JsonLibraryStore { } addImported(stations: Station[]): LibraryState { + this.refresh(); const incoming: Station[] = []; const incomingKeys = new Set(); for (const station of stations) { @@ -604,6 +668,7 @@ export class JsonLibraryStore { return false; } + this.refresh(); const key = stationKey(station); return this.state.favorites.some(item => stationKey(item) === key); } @@ -611,7 +676,7 @@ export class JsonLibraryStore { private mutateAlarm(id: string, change: (alarm: Alarm) => Alarm): Alarm { const release = acquireStoreLock(this.filePath); try { - const latestState = this.read(); + const latestState = this.readForCurrentSession(); const existing = latestState.alarms.find(alarm => alarm.id === id); if (!existing) { this.state = latestState; @@ -639,9 +704,18 @@ export class JsonLibraryStore { throw storageError('read the RadioCLI library', error); } + let state: LibraryState; try { - return migrateLibraryState(librarySchema.parse(JSON.parse(contents))); - } catch { + state = migrateLibraryState(librarySchema.parse(JSON.parse(contents))); + } catch (error) { + if (error instanceof z.ZodError && error.issues.some(issue => + issue.code === 'invalid_value' && issue.path.length === 2 && + issue.path[0] === 'settings' && issue.path[1] === 'receiverStyle' + )) { + // Another running build may have saved a newer receiver. Its library + // is intact: never quarantine it or replace it with empty defaults. + throw new Error('The RadioCLI library uses a receiver style this process does not support. Restart RadioCLI and its MCP clients to load the updated styles.'); + } try { assertStorageWritable(this.filePath); renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`); @@ -651,16 +725,40 @@ export class JsonLibraryStore { } return defaultState(); } + // A legacy writer can still change ordinary preferences and favorites. Its + // fallback style must not erase the selection saved by an updated process. + let preference: string; + try { preference = readFileSync(this.receiverPreferencePath(), 'utf8'); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return state; + throw storageError('read the receiver preference', error); + } + const parsed = receiverPreferenceSchema.safeParse(JSON.parse(preference)); + if (!parsed.success) throw new Error('The RadioCLI receiver preference is unsupported or invalid. Restart RadioCLI and its MCP clients with the latest version. Your library has been preserved.'); + if (state.settings.receiverStyle === parsed.data.libraryReceiverStyle) state.settings.receiverStyle = parsed.data.receiverStyle; + return state; + } + + private receiverPreferencePath(): string { return `${this.filePath}.receiver.json`; } + + private readForCurrentSession(): LibraryState { + const next = this.read(); + // Disk intentionally stores auto instead of reopening a receiver on launch. + // Within this owner, unrelated durable changes retain its selected output. + if (this.state.settings.preferredBackend === 'airplay' && next.settings.preferredBackend === 'auto') { + next.settings.preferredBackend = 'airplay'; + } + return next; } private commit(nextState: LibraryState): LibraryState { const release = acquireStoreLock(this.filePath); try { - const latestState = this.read(); + const latestState = this.readForCurrentSession(); const mergedState = mergeLibraryChanges(this.state, latestState, nextState); this.write(mergedState); this.state = mergedState; - return this.snapshot(); + return this.cachedSnapshot(); } finally { release(); } @@ -670,7 +768,32 @@ export class JsonLibraryStore { try { assertStorageWritable(this.filePath); mkdirSync(dirname(this.filePath), {recursive: true, mode: 0o700}); - writeJsonAtomically(this.filePath, libraryStateForDisk(state)); + const diskState = libraryStateForDisk(state); + const separateReceiver = !sharedLibraryReceiverStyles.has(state.settings.receiverStyle); + const preferencePath = this.receiverPreferencePath(); + let previousPreference: string | undefined; + try { previousPreference = readFileSync(preferencePath, 'utf8'); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + const nextPreference = separateReceiver + ? `${JSON.stringify({version: 1, receiverStyle: state.settings.receiverStyle, libraryReceiverStyle: 'ultracode'}, null, 2)}\n` + : undefined; + const preferenceChanged = previousPreference !== nextPreference; + if (preferenceChanged) { + if (nextPreference === undefined) rmSync(preferencePath); + else writeTextAtomically(preferencePath, nextPreference); + } + try { + writeJsonAtomically(this.filePath, separateReceiver + ? {...diskState, settings: {...diskState.settings, receiverStyle: 'ultracode'}} + : diskState); + } catch (error) { + if (preferenceChanged) { + if (previousPreference === undefined) rmSync(preferencePath, {force: true}); + else writeTextAtomically(preferencePath, previousPreference); + } + throw error; + } + try { this.revision = this.diskRevision(); } catch { this.revision = ''; } } catch (error) { throw storageError('write the RadioCLI library', error); } @@ -678,7 +801,7 @@ export class JsonLibraryStore { } export function stationKey(station: Station): string { - return `${station.provider}:${station.id}`; + return `${station.provider}:${migrateStationIdentity(station).id}`; } function finishActiveListeningSessionInState(state: LibraryState, endedAt: Date): LibraryState { @@ -720,8 +843,8 @@ function recoverActiveListeningSessionInState(state: LibraryState): LibraryState return finishActiveListeningSessionInState(state, new Date(recoveredEnd)); } -export function defaultLibraryPath(): string { - const {library, legacyLibrary} = platformPaths(); +export function defaultLibraryPath(options: Parameters[0] = {}): string { + const {library, legacyLibrary} = platformPaths(options); for (const filePath of [library, legacyLibrary]) { try { statSync(filePath); @@ -754,6 +877,7 @@ function defaultState(): LibraryState { return { recent: [], favorites: [], + presets: [], imported: [], trackHistory: [], searchHistory: [], @@ -784,9 +908,32 @@ function defaultState(): LibraryState { } function migrateLibraryState(state: LibraryState): LibraryState { + const aliases = new Map>(); + const migrate = (station: Station): Station => { + const next = migrateStationIdentity(station); + if (next.id !== station.id) { + const key = `${station.provider}:${station.id}`; + const targets = aliases.get(key) ?? new Set(); + targets.add(stationKey(next)); + aliases.set(key, targets); + } + return next; + }; + const recent = state.recent.map(item => ({...item, station: migrate(item.station)})); + const favorites = state.favorites.map(migrate); + const imported = state.imported.map(migrate); + const presets = state.presets?.map(item => ({...item, station: migrate(item.station)})); + const sessions = state.activity.sessions.map(item => ({...item, station: migrate(item.station)})); + const alarms = state.alarms.map(alarm => normalizeAlarm({...alarm, station: migrate(alarm.station), + playback: {...alarm.playback, ...(alarm.playback.fallbackStation ? {fallbackStation: migrate(alarm.playback.fallbackStation)} : {})}})); return { ...state, - alarms: state.alarms.map(normalizeAlarm), + recent, favorites, imported, presets, alarms, + activity: {sessions}, + trackHistory: state.trackHistory.map(item => { + const targets = aliases.get(item.stationKey); + return targets?.size === 1 ? {...item, stationKey: [...targets][0]!} : item; + }), settings: { ...state.settings, preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend, @@ -814,18 +961,35 @@ function libraryStateForDisk(state: LibraryState): LibraryState { } function writeJsonAtomically(filePath: string, value: unknown): void { + writeTextAtomically(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function writeTextAtomically(filePath: string, text: string): void { const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`; try { - writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {encoding: 'utf8', mode: 0o600, flag: 'wx'}); - if (process.platform !== 'win32') { - chmodSync(tempPath, 0o600); + const fd = openSync(tempPath, 'wx', 0o600); + try { + writeFileSync(fd, text, 'utf8'); + if (process.platform !== 'win32') chmodSync(tempPath, 0o600); + fsyncSync(fd); + } catch (error) { + try { closeSync(fd); } catch { /* Preserve the original persistence error. */ } + throw error; } - // No fallible persistence steps follow the atomic replacement. - renameSync(tempPath, filePath); + closeSync(fd); + replaceFileSync(tempPath, filePath); } catch (error) { try { rmSync(tempPath, {force: true}); } catch { /* Preserve the original write failure. */ } throw storageError('write RadioCLI data', error); } + // Flush the replacement directory where supported. Once rename succeeds the + // transaction is committed: a directory-sync failure cannot roll it back or + // leave the caller's in-memory state describing the previous file. Windows + // and some network/removable filesystems do not support syncing directories. + let directoryFd: number | undefined; + try { directoryFd = openSync(dirname(filePath), 'r'); fsyncSync(directoryFd); } + catch { /* The file itself was flushed before replacement. */ } + finally { if (directoryFd !== undefined) try { closeSync(directoryFd); } catch { /* Already committed. */ } } } function acquireStoreLock(filePath: string): () => void { @@ -836,32 +1000,8 @@ function acquireStoreLock(filePath: string): () => void { } catch (error) { throw storageError('write the RadioCLI library', error); } - const deadline = Date.now() + 1000; - while (true) { - try { - mkdirSync(lockPath, {mode: 0o700}); - 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 storageError('lock the RadioCLI library', error); - try { - if (Date.now() - statSync(lockPath).mtimeMs > 10_000) { - rmSync(lockPath, {recursive: true, force: true}); - } - } catch (lockError) { - if ((lockError as NodeJS.ErrnoException).code !== 'ENOENT') { - throw storageError('lock the RadioCLI library', lockError); - } - } - if (Date.now() >= deadline) { - 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); - } - } + try { return acquireFileLock(lockPath); } + catch (error) { throw storageError('lock the RadioCLI library', error); } } class LibraryStorageError extends Error { @@ -884,10 +1024,17 @@ function storageError(operation: string, cause: unknown): LibraryStorageError { return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause); } +function normalizePresetName(name: string): string { + const key = name.trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9_-]{0,39}$/.test(key)) throw new Error('Preset names must be 1-40 letters, numbers, hyphens, or underscores, starting with a letter or number.'); + return key; +} + function mergeLibraryChanges(base: LibraryState, disk: LibraryState, next: LibraryState): LibraryState { return { recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50), favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true), + presets: mergeKeyedChanges(base.presets ?? [], disk.presets ?? [], next.presets ?? [], preset => preset.name, 200, true), imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true), trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100), searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory), diff --git a/src/types.ts b/src/types.ts index 30f324a..323aa38 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,7 @@ export type Country = { export type Station = { id: string; + legacyIds?: string[]; provider: ProviderId; name: string; country?: string; @@ -110,8 +111,28 @@ export type SearchOptions = { codec?: string; language?: string; minBitrate?: number; + signal?: AbortSignal; }; +/** Progress belongs to the source rows consumed, never the displayed item count. */ +export type DiscoverySourceStatus = { + provider: Station['provider']; + source: string; + consumed: number; + exhausted: boolean; + status: 'available' | 'unavailable'; + message?: string; +}; + +export type DiscoveryPage = { + items: Station[]; + nextCursor?: string; + exhausted: boolean; + sources: DiscoverySourceStatus[]; +}; + +export type DiscoveryPageOptions = Omit & {cursor?: string}; + export type MediaKeyBindings = { previous: string[]; playPause: string[]; @@ -139,6 +160,7 @@ export type AppSettings = { shareDirectoryVotes: boolean; preferredBackend: 'auto' | 'mpv' | 'ffplay' | 'vlc' | 'airplay'; preferredAirPlayDevice?: string; + linuxAirPlayEnabled?: boolean; tuneTimeoutSeconds: number; skipBrokenStreams: boolean; mediaKeys: MediaKeyBindings; @@ -149,6 +171,7 @@ export type AppSettings = { mouseSupport?: boolean; automaticUpdateChecks?: boolean; agentControl?: AgentControlSettings; + desktopMediaControls?: 'auto' | 'on' | 'off'; }; export type UpdateCheckState = { @@ -265,6 +288,7 @@ export type AlarmPowerGuardState = { export type LibraryState = { recent: RecentPlay[]; favorites: Station[]; + presets?: {name: string; station: Station}[]; imported: Station[]; activity: ListeningActivity; trackHistory: TrackPlay[]; @@ -285,6 +309,7 @@ export type PlaybackState = { stationName?: string; airPlayDeviceName?: string; ready: boolean; + buffering?: boolean; elapsedSeconds?: number; }; diff --git a/src/ui/AdaptiveContent.tsx b/src/ui/AdaptiveContent.tsx index b195032..4b4c28b 100644 --- a/src/ui/AdaptiveContent.tsx +++ b/src/ui/AdaptiveContent.tsx @@ -16,9 +16,9 @@ import {computeListeningStats} from '../activity/stats.js'; import {playbackBackendLabel} from '../player/backend-install.js'; import {visibleWindow} from './list-window.js'; import {displayWidth, padDisplayEnd, stationLocation, stationTags, stationTech, truncate} from './format.js'; -import {homeItems, settingsGroup, settingsGroups, settingsItemsForPage, type SettingsPage} from './screen-items.js'; +import {homeItems, settingsGroup, settingsGroupsForAirPlay, settingsItemsForPage, type SettingsPage} from './screen-items.js'; import {screenTitle} from './screen-meta.js'; -import {keyHelpSections, commandHelp} from './help-content.js'; +import {keyHelpSections, commandHelpForAirPlay} from './help-content.js'; import {settingLabel, settingValue} from './screens/SettingsScreen.js'; import {exploreMapLand, mapMarker, panelBorder, textDim, textMuted, themeAccent} from './theme.js'; import {panelBorderStyle, useDisplay} from './display-context.js'; @@ -33,6 +33,7 @@ import {adaptiveExploreFrameMetrics, computeAdaptiveExploreLayout} from './adapt import {AdaptiveMarquee} from './components/AdaptiveMarquee.js'; type AdaptiveContentProps = { + platform?: NodeJS.Platform; mode: 'compact' | 'micro'; screen: Screen; settingsPage?: SettingsPage; @@ -185,9 +186,11 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { width, mode, ascii, - airPlaySupported + airPlaySupported, + platform: props.platform }); const empty = adaptiveEmptyState({ + airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'), screen, searchQuery, editingSearch, @@ -261,9 +264,11 @@ function AdaptiveContentBody(props: AdaptiveContentProps): React.ReactElement { width, mode, ascii, - airPlaySupported + airPlaySupported, + platform: props.platform }); const empty = adaptiveEmptyState({ + airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'), screen, searchQuery, editingSearch, @@ -784,6 +789,7 @@ function adaptiveRows(input: { mode: 'compact' | 'micro'; ascii: boolean; airPlaySupported: boolean; + platform?: NodeJS.Platform; }): AdaptiveRow[] { const {screen, stations, countries, airPlayDevices, library, diagnostics, backends, updateCheck, favoriteKeys, selected, width, mode, ascii} = input; if (screen === 'home') { @@ -795,15 +801,15 @@ function adaptiveRows(input: { })); } if (screen === 'settings') { - const pageItems = settingsItemsForPage(input.settingsPage); + const pageItems = settingsItemsForPage(input.settingsPage, input.airPlaySupported); 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, input.airPlaySupported), labelWidth), labelWidth), detail: input.settingsPage === 'root' - ? adaptiveSettingsRootValue(item) - : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion), + ? adaptiveSettingsRootValue(item, input.airPlaySupported) + : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion, input.platform), separator: ' ', index })); @@ -834,7 +840,7 @@ function adaptiveRows(input: { } } rows.push({key: 'section-commands', label: 'Commands', heading: true}); - for (const command of commandHelp) { + for (const command of commandHelpForAirPlay(input.airPlaySupported)) { rows.push({key: `command-${command.name}`, label: `:${command.name}${command.args ? ` ${command.args}` : ''}`, detail: command.description, index}); index += 1; } @@ -843,8 +849,8 @@ function adaptiveRows(input: { return stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii); } -function adaptiveSettingsRootValue(item: string): string | undefined { - const group = settingsGroups.find(candidate => candidate.label === item); +function adaptiveSettingsRootValue(item: string, airPlayVisible: boolean): string | undefined { + const group = settingsGroupsForAirPlay(airPlayVisible).find(candidate => candidate.label === item); return group ? `${group.items.length} settings ›` : undefined; } @@ -878,6 +884,7 @@ function pairedColumnWidth(labels: string[], width: number, mode: 'compact' | 'm } function adaptiveEmptyState(input: { + airPlaySetupRequired?: boolean; screen: Screen; searchQuery: string; editingSearch: boolean; @@ -920,6 +927,7 @@ function adaptiveEmptyState(input: { return [{key: 'error', label: stationError}, {key: 'hint', label: 'Try again later or reopen this view.'}]; } if (screen === 'airplay-settings') { + if (input.airPlaySetupRequired) return [{key: 'empty', label: 'Setup required'}, {key: 'hint', label: 'radiocli setup'}, {key: 'option', label: '--airplay'}]; return [{key: 'empty', label: 'No AirPlay receivers found.'}, {key: 'hint', label: 'Press r to scan again.'}]; } return [{key: 'empty', label: `No stations in ${stationTitle.toLowerCase()}.`}, {key: 'hint', label: 'Try another view or clear filters.'}]; @@ -969,6 +977,7 @@ function adaptiveStatus(props: AdaptiveContentProps): string { return playingStation ? playingStation.name : 'Receiver authentication'; } if (screen === 'airplay-settings') { + if (props.platform === 'linux' && !props.backends.includes('airplay')) return 'Setup required'; return `${props.airPlayDevices.length} receivers · ${playbackBackendLabel(playback.backend)}`; } return `${stations.length.toLocaleString()} stations${playingStation ? ` · playing ${playingStation.name}` : ''}`; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index cfe0529..7f9679b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2,10 +2,13 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {fileURLToPath} from 'node:url'; 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 {airPlayMacOSOnlyMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, playbackBackendLabel} from '../player/backend-install.js'; +import {PlaybackOutputError, PlayerController, type PlaybackControlResult} from '../player/player-controller.js'; +import {airPlayLocalDeviceLabel, 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 {LibraryRepository} from '../storage/repository.js'; +import {RadioEngine} from '../player/radio-engine.js'; +import {TuiPlayer, TuiRadioEngine, type TuiSession} from '../agent/tui-session.js'; +import {defaultAgentControlSettings, type AirPlayDevice, type AppSettings, type Country, type IcyNowPlaying, type LibraryState, type LocationGuess, type PlaybackState, type DiscoveryPage, type Screen, type Station} from '../types.js'; import {nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent} from './theme.js'; import {DisplayContext, resolveDisplayMode} from './display-context.js'; import {homeItems, settingsItemsForPage, type SettingsPage} from './screen-items.js'; @@ -17,7 +20,7 @@ import {playbackFooterText, playbackStateForPendingStation} from './playback-foo import {balancedFooterLegendRows, fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, microShortcutFooterText, pageFooterText} from './page-footer.js'; import {disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell, shouldEnableMouseReporting} from './terminal-mouse.js'; import {useAppInput} from './use-app-input.js'; -import {useCommandExecutor} from './use-command-executor.js'; +import {useCommandExecutor, type SettingsUpdate} from './use-command-executor.js'; import {isAirPlayCodePromptActive} from './screens/AirPlayCodeScreen.js'; import {isAirPlayBackendAvailable} from './airplay-settings.js'; import {networkPolicy} from '../platform/network.js'; @@ -26,14 +29,16 @@ 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'; +import {airPlayPolicy, airPlaySetupRequiredMessage} from '../player/airplay-policy.js'; +import {helpItemCountForAirPlay} from './help-content.js'; import {EXIT_CONFIRMATION_MS, ctrlCExitDecision} from './exit-confirmation.js'; import {createAlarmTuiService, serializeAlarmTuiService, type AlarmTuiService} from './alarm-tui-service.js'; import {useAlarmTui} from './use-alarm-tui.js'; import {toAsciiSafe} from './ascii.js'; import {registerTuiPresence} from '../alarms/tui-presence.js'; import {configureMcpIntegrations, type McpInstallResult} from '../agent/mcp-install.js'; -import {startRadioSession, type RadioSessionCommand, type RadioSessionResult, type RadioSessionStatus} from '../agent/session.js'; +import {allowedRadioSessionSettings, resolveDesktopTransport, serializeRadioCommands, startRadioSession, type RadioSessionCommand, type RadioSessionResult, type RadioSessionStatus} from '../agent/session.js'; +import {desktopMediaPolicy, startDesktopMediaControls} from '../platform/desktop-media.js'; import { activeTabForScreen, addMediaKeyBinding, @@ -48,8 +53,6 @@ import { mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, - normalizeMediaKeyBindings, - shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs, @@ -75,16 +78,16 @@ type AppProps = { mcpConfigurator?: typeof configureMcpIntegrations; updateChecker?: typeof checkForUpdate; platform?: NodeJS.Platform; + playbackSession?: TuiSession; + playbackRecoveryWarning?: string; }; const LOADING_SPINNER_MS = 120; const TRANSIENT_NOTICE_MS = 4500; -const LISTENING_HEARTBEAT_MS = 30_000; const COUNTRY_STATIONS_PAGE_SIZE = 120; const COUNTRY_STATIONS_LOAD_AHEAD = 12; const SEARCH_RESULTS_PAGE_SIZE = 90; const SEARCH_RESULTS_LOAD_AHEAD = 12; -const BROKEN_STREAM_AUTO_SKIP_DELAY_MS = 1000; type BooleanSetting = 'resumeOnLaunch' | 'transparentBackground' | 'asciiMode' | 'reduceMotion' | 'mouseSupport' | 'automaticUpdateChecks'; @@ -97,39 +100,42 @@ const settingToggleLabel: Record = { automaticUpdateChecks: 'Automatic update checks' }; -export function App({store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform}: AppProps): React.ReactElement { +export function App({store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform, playbackSession, playbackRecoveryWarning}: 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 store = useMemo(() => new LibraryRepository(providedStore ?? new JsonLibraryStore(), {worker: !providedStore}), [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 [library, setLibrary] = useState(() => localSessionLibrary(store.snapshot(), platform, playbackSession?.status.tui?.selectionRequired)); const settingsRef = useRef(library.settings); - settingsRef.current = library.settings; + const settingsQueueRef = useRef>(Promise.resolve()); - const player = useMemo(() => new PlayerController(() => settingsRef.current), []); + const player = useMemo(() => playbackSession ? new TuiPlayer(playbackSession, () => settingsRef.current) + : new PlayerController(() => settingsRef.current, {platform, arch: process.arch, env: platform === process.platform ? process.env : {}}), [platform, playbackSession]); const [playback, setPlayback] = useState(() => player.getState()); const [availableBackends, setAvailableBackends] = useState(() => player.detectedBackends()); + const airPlay = airPlayPolicy(library.settings, {platform}); + const airPlaySupported = airPlay.enabled; + const airPlayUnavailableMessage = airPlay.enabled ? (platform === 'linux' ? airPlaySetupRequiredMessage : 'AirPlay is not ready on this install. Run radiocli doctor.') : airPlay.message; const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices()); - const [screen, setScreen] = useState('home'); + const [screen, setScreen] = useState(playbackSession?.status.station ? 'now-playing' : 'home'); 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 [presenceWarning, setPresenceWarning] = useState(playbackRecoveryWarning ?? null); const [footerMessage, setFooterMessage] = useState(null); const [countries, setCountries] = useState([]); const [countryFilter, setCountryFilter] = useState(''); const [editingCountryFilter, setEditingCountryFilter] = useState(false); const [loadingCountries, setLoadingCountries] = useState(false); const [stationContexts, setStationContexts] = useState>(initialStationContexts); - const [loadingStations, setLoadingStations] = useState(false); + const [loadingStationContexts, setLoadingStationContexts] = useState>>({}); const [searchQuery, setSearchQuery] = useState(''); const [editingSearch, setEditingSearch] = useState(true); const [playingStation, setPlayingStation] = useState(null); @@ -143,7 +149,11 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const [commandText, setCommandText] = useState(''); const [airPlayCode, setAirPlayCode] = useState(''); const [filters, setFilters] = useState({codec: null, language: null, minBitrate: null}); - const [sleepUntil, setSleepUntil] = useState(null); + const [sleepUntil, setSleepUntilState] = useState(playbackSession?.status.tui?.sleepUntil ?? null); + const setSleepUntil = useCallback((until: number | null) => { + if (playbackSession) void playbackSession.call({type: 'tui-sleep', until}).catch(error => setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not update the sleep timer.')); + else setSleepUntilState(until); + }, [playbackSession]); const [showDiagnostics, setShowDiagnostics] = useState(false); const [capturingTransportAction, setCapturingTransportAction] = useState(null); const reportActionError = useCallback((error: unknown) => { @@ -151,9 +161,9 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, []); // 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 => { + const persistLibrary = useCallback(async (write: () => LibraryState | Promise): Promise => { try { - const nextLibrary = write(); + const nextLibrary = localSessionLibrary(await write(), platform, player.airPlaySelectionRequired()); setLibrary(nextLibrary); return nextLibrary; } catch (error) { @@ -161,18 +171,25 @@ export function App({store: providedStore, providers: providedProviders, alarmSe setPersistenceWarning(`Library not saved. Set RADIOCLI_HOME to a writable directory.${detail ? ` ${detail}` : ''}`); return undefined; } - }, []); + }, [platform, player]); + const engine = useMemo(() => playbackSession ? new TuiRadioEngine(playbackSession) : new RadioEngine(player, providers, store, () => settingsRef.current, saved => { + setLibrary(localSessionLibrary(saved, platform, player.airPlaySelectionRequired())); + }), [player, providers, store, platform, playbackSession]); const announcedUpdateRef = useRef(false); const automaticUpdateCheckStartedRef = useRef(false); const installingUpdateRef = useRef(false); const configuringMcpRef = useRef(false); const displayStationsRef = useRef([]); - const playbackQueueRef = useRef(null); + const playbackQueueRef = useRef(playbackSession?.status.queue.length + ? {title: 'Recovered station list', sourceScreen: 'now-playing', sourceContextKey: null, stations: playbackSession.status.queue} : null); + const focusNowPlayingRef = useRef(playbackSession?.status.tui?.focusNowPlaying ?? 0); const lastRawTransportAtRef = useRef(0); - const loadingStationsRef = useRef(false); + const loadingStationContextsRef = useRef>>({}); const countryPageRequestRef = useRef(null); const searchPageRequestRef = useRef(null); - const playStationRef = useRef<(station: Station, options?: PlayStationOptions) => void>(() => undefined); + const activeSearchRef = useRef<{query: string; fingerprint: string} | null>(null); + const searchContextFingerprintRef = useRef(null); + const searchFingerprint = JSON.stringify([filters.codec, filters.language, filters.minBitrate, Boolean(library.settings.enableRadioGarden)]); const screenRef = useRef(screen); const selectedRef = useRef(selected); const settingsPageRef = useRef(settingsPage); @@ -191,14 +208,31 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const helpReturnScreenRef = useRef('home'); const countriesLoadAttemptedRef = useRef(false); const tuneRequestRef = useRef(0); + const outputCancellationRef = useRef(0); + const pendingOutputChangesRef = useRef(0); const searchRequestRef = useRef(0); const countryRequestRef = useRef(0); const nearbyRequestRef = useRef(0); - const skipBrokenTimerRef = useRef | null>(null); + const discoveryAbortRef = useRef>>({}); + const beginDiscovery = useCallback((key: StationContextKey) => { + discoveryAbortRef.current[key]?.abort(); + const controller = new AbortController(); discoveryAbortRef.current[key] = controller; return controller.signal; + }, []); + const cancelPendingTune = useCallback(() => { + tuneRequestRef.current += 1; + outputCancellationRef.current += 1; + engine.cancel(); + }, [engine]); const volumeRequestRef = useRef(0); - const agentHandlerRef = useRef<(command: RadioSessionCommand) => Promise>(async () => { + const agentHandlerRef = useRef<(command: RadioSessionCommand, signal?: AbortSignal) => Promise>(async () => { throw new Error('RadioCLI is still starting.'); }); + const dispatchOwnerCommand = useMemo(() => serializeRadioCommands((command, signal) => agentHandlerRef.current(command, signal), {responsive: true}), []); + const desktopPolicy = useMemo(() => desktopMediaPolicy(library.settings, {platform}), [library.settings.desktopMediaControls, platform]); + const desktopSuspendedRef = useRef(false); + const desktopMediaRef = useRef | null>(null); + const activeAlarmRef = useRef(false); + const [controlSessionReady, setControlSessionReady] = useState(Boolean(playbackSession)); const showTransientMessage = useCallback((nextMessage: string) => { if (transientMessageTimerRef.current) { @@ -244,11 +278,11 @@ export function App({store: providedStore, providers: providedProviders, alarmSe screenRef.current = screen; selectedRef.current = selected; settingsPageRef.current = settingsPage; - loadingStationsRef.current = loadingStations; stationContextsRef.current = activeStationContexts; exploreCursorRef.current = exploreCursor; const renderedStationContextKey = stationContextKeyForScreen(screen); + const loadingStations = renderedStationContextKey ? Boolean(loadingStationContexts[renderedStationContextKey]) : false; const activeStationContextKey = renderedStationContextKey ?? lastStationContextKeyRef.current; const stationContext = activeStationContexts[activeStationContextKey]; const stationCounts = useMemo>( @@ -278,8 +312,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe 'alarm-ringing': 0, 'airplay-settings': 0, 'airplay-code': 1, - settings: settingsItemsForPage(settingsPage).length, - help: helpItemCount + settings: settingsItemsForPage(settingsPage, airPlaySupported).length, + help: helpItemCountForAirPlay(airPlaySupported) }); itemCountsRef.current = { home: homeItems.length, @@ -298,8 +332,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe 'alarm-ringing': 0, 'airplay-settings': availableAirPlayDevices.length, 'airplay-code': 1, - settings: settingsItemsForPage(settingsPage).length, - help: helpItemCount + settings: settingsItemsForPage(settingsPage, airPlaySupported).length, + help: helpItemCountForAirPlay(airPlaySupported) }; const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]); @@ -332,7 +366,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe library.settings.mouseSupport !== false ); - useEffect(() => player.onChange(setPlayback), [player]); + useEffect(() => { if (!stdin.isTTY) return; try { @@ -342,57 +376,46 @@ export function App({store: providedStore, providers: providedProviders, alarmSe 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.'); + setPresenceWarning([playbackRecoveryWarning, 'Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.'].filter(Boolean).join(' ')); } - }, [stdin]); + }, [stdin, playbackRecoveryWarning]); const playingStationRef = useRef(null); playingStationRef.current = playingStation; - const activeListeningStationRef = useRef(null); - const lastRecordedTrackRef = useRef(null); - - useEffect(() => { - const station = playingStationRef.current; - const isAudible = playback.state === 'playing' && playback.ready && station; - if (isAudible) { - const key = stationKey(station); - if (activeListeningStationRef.current !== key) { - activeListeningStationRef.current = key; - persistLibrary(() => store.startListeningSession(station)); + useEffect(() => engine.onChange(current => { + setPlayback(current.playback); + playingStationRef.current = current.station; + setPlayingStation(current.station); + setTuningStation(current.tuningStation); + setNowPlaying(current.nowPlaying); + if (playbackSession) { + setSleepUntilState(playbackSession.status.tui?.sleepUntil ?? null); + const previous = playbackQueueRef.current; + playbackQueueRef.current = current.queue.length ? {...(previous ?? {title: 'Current station list', sourceScreen: 'now-playing', sourceContextKey: null}), stations: current.queue} : null; + const focus = playbackSession.status.tui?.focusNowPlaying ?? 0; + if (focus !== focusNowPlayingRef.current) { + focusNowPlayingRef.current = focus; + screenRef.current = 'now-playing'; + setScreen('now-playing'); } - return; - } - - if (activeListeningStationRef.current) { - activeListeningStationRef.current = null; - persistLibrary(() => store.finishActiveListeningSession()); } - }, [persistLibrary, playback.ready, playback.state, playingStation, store]); - + if (current.warning) setPersistenceWarning(current.warning.replace('Listening history was not saved', 'Library not saved')); + if (current.message) setMessage(current.message); + }), [engine, playbackSession]); useEffect(() => { - if (playback.state !== 'playing' || !playback.ready || !playingStation) { - return; - } - const timer = setInterval(() => { - persistLibrary(() => store.checkpointActiveListeningSession()); - }, LISTENING_HEARTBEAT_MS); - return () => clearInterval(timer); - }, [persistLibrary, playback.ready, playback.state, playingStation, store]); - - useEffect( - () => - player.onMetadata(metadata => { - setNowPlaying(metadata); - const station = playingStationRef.current; - if (station && metadata.title) { - const trackKey = `${stationKey(station)}:${metadata.title}`; - if (lastRecordedTrackRef.current === trackKey) return; - lastRecordedTrackRef.current = trackKey; - persistLibrary(() => store.recordTrack(station, metadata.title!)); - } - }), - [persistLibrary, player, store] - ); + const off = store.subscribe(saved => { + const next = localSessionLibrary(saved, platform, player.airPlaySelectionRequired()); + settingsRef.current = next.settings; + setLibrary(next); + }); + const offError = store.onError(error => setPersistenceWarning(`Library could not refresh: ${error.message}`)); + return () => { + off(); offError(); + // Normal shutdown closes the owner explicitly. An unexpected render + // failure must release this view's storage without stopping its audio. + void (playbackSession ? store.close() : engine.close().finally(() => store.close())).catch(() => undefined); + }; + }, [store, engine, platform, player, playbackSession]); useEffect(() => { if (!stdout.isTTY || !mouseReportingActive) { @@ -447,6 +470,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe useEffect( () => () => { + exploreRequestRef.current += 1; + countryRequestRef.current += 1; + searchRequestRef.current += 1; + nearbyRequestRef.current += 1; + for (const controller of Object.values(discoveryAbortRef.current)) controller.abort(); + countryPageRequestRef.current = null; + searchPageRequestRef.current = null; if (exploreMoveTimerRef.current) { clearTimeout(exploreMoveTimerRef.current); } @@ -456,9 +486,6 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (transientFooterMessageTimerRef.current) { clearTimeout(transientFooterMessageTimerRef.current); } - if (skipBrokenTimerRef.current) { - clearTimeout(skipBrokenTimerRef.current); - } }, [] ); @@ -468,39 +495,38 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, [availableAirPlayDevices.length, displayStations.length, filteredCountries.length, screen]); useEffect(() => { + if (playbackSession) return; if (!sleepUntil) { return; } const delayMs = sleepUntil - Date.now(); if (delayMs <= 0) { - tuneRequestRef.current += 1; - persistLibrary(() => store.finishActiveListeningSession()); - void player.stop().catch(reportActionError); + cancelPendingTune(); + void engine.stop({retainSelection: true}).catch(reportActionError); setSleepUntil(null); return; } const timer = setTimeout(() => { - tuneRequestRef.current += 1; - persistLibrary(() => store.finishActiveListeningSession()); - void player.stop().catch(reportActionError); + cancelPendingTune(); + void engine.stop({retainSelection: true}).catch(reportActionError); setSleepUntil(null); }, delayMs); return () => clearTimeout(timer); - }, [persistLibrary, player, reportActionError, sleepUntil, store]); + }, [cancelPendingTune, engine, reportActionError, sleepUntil, playbackSession, setSleepUntil]); useEffect(() => { const backends = player.refreshDetectedBackends(); setAvailableBackends(backends); const network = networkPolicy(); - if (airPlaySupported && !network.offline && !network.lowBandwidth) { + if (platform === 'darwin' && airPlaySupported && !network.offline && !network.lowBandwidth) { void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([])); } if (backends.length === 0) { setMessage(`No playback backend found. ${playbackBackendInstallHint()}`); } - }, [airPlaySupported, player]); + }, [airPlaySupported, platform, player]); const refreshProviderHealth = useCallback(() => { providers.health(settingsRef.current).then(setProviderHealth).catch(() => setProviderHealth({})); @@ -545,9 +571,16 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]); const setStationContextFor = useCallback((key: StationContextKey, context: StationContext) => { + stationContextsRef.current = {...stationContextsRef.current, [key]: context}; setStationContexts(current => ({...current, [key]: context})); }, []); + const setStationLoading = useCallback((key: StationContextKey, loading: boolean) => { + if (loadingStationContextsRef.current[key] === loading) return; + loadingStationContextsRef.current[key] = loading; + setLoadingStationContexts(current => ({...current, [key]: loading})); + }, []); + const stationMatches = useCallback((left: Station, right: Station) => stationKey(left) === stationKey(right), []); const queueContainsStation = useCallback( @@ -555,16 +588,37 @@ export function App({store: providedStore, providers: providedProviders, alarmSe [stationMatches] ); - const updateSettings = useCallback( - (settings: Partial) => { - const nextLibrary = store.updateSettings(settings); + const updateSettings = useCallback((update: SettingsUpdate): Promise => { + // Evaluate relative inputs after the preceding save has acknowledged. React + // may still be rendering that save, so the committed ref is authoritative. + const operation = settingsQueueRef.current.then(async () => { + const settings = typeof update === 'function' ? update(settingsRef.current) : update; + const saved = await store.updateSettings(settings); + if (settings.preferredBackend === 'airplay' && settings.preferredAirPlayDevice) { + // Receiver consent belongs to this acknowledged selection. Publish its + // ID to the player before applying the Linux session output overlay. + settingsRef.current = saved.settings; + await player.selectAirPlayDevice(settings.preferredAirPlayDevice); + } + if (settings.preferredBackend && settings.preferredBackend !== 'airplay') await player.selectAirPlayDevice(null); + const nextLibrary = localSessionLibrary(saved, platform, player.airPlaySelectionRequired()); + if (settings.enableNearbyLocation === false) { + nearbyRequestRef.current += 1; + discoveryAbortRef.current.nearby?.abort(); + setStationLoading('nearby', false); + } settingsRef.current = nextLibrary.settings; setLibrary(nextLibrary); setPersistenceWarning(null); + engine.clearWarning(); return nextLibrary; - }, - [store] - ); + }); + settingsQueueRef.current = operation.then(() => undefined, () => undefined); + // Keyboard callbacks can intentionally fire and forget; awaited callers still + // receive the original rejection and never acknowledge an uncommitted save. + void operation.catch(reportActionError); + return operation; + }, [engine, platform, player, reportActionError, setStationLoading, store]); useEffect(() => { if (!selectedAirPlayDevice?.local || settingsRef.current.preferredBackend !== 'airplay') { @@ -573,24 +627,19 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const preferredBackend = preferredLocalPlaybackBackend(availableBackends); if (!preferredBackend) { - setMessage(`${selectedAirPlayDevice.name} is this Mac. Install mpv to use local playback instead of AirPlay.`); + setMessage(`${selectedAirPlayDevice.name} is ${airPlayLocalDeviceLabel(platform)}. Install mpv to use local playback instead of AirPlay.`); return; } - 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']) => { - updateSettings({mediaKeys: normalizeMediaKeyBindings(mediaKeys)}); - }, - [updateSettings] - ); + let changed = false; + void updateSettings(current => { + if (current.preferredBackend !== 'airplay' || current.preferredAirPlayDevice !== selectedAirPlayDevice.id) return {}; + changed = true; + return {preferredBackend}; + }).then(() => { + if (changed) setMessage(`${selectedAirPlayDevice.name} is ${airPlayLocalDeviceLabel(platform)}. Audio output: ${audioOutputLabel(preferredBackend)}.`); + }).catch(() => undefined); + }, [availableBackends, platform, selectedAirPlayDevice, updateSettings]); const beginLearningTransportKey = useCallback((action: MediaTransportAction) => { setCapturingTransportAction(action); @@ -598,18 +647,19 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, []); const resetLearnedTransportKeys = useCallback(() => { - updateMediaKeys({previous: [], playPause: [], next: []}); - setMessage('Learned media keys reset. Built-in fallbacks still work.'); - }, [updateMediaKeys]); + void updateSettings({mediaKeys: {previous: [], playPause: [], next: []}}) + .then(() => setMessage('Learned media keys reset. Built-in fallbacks still work.')) + .catch(() => undefined); + }, [updateSettings]); const saveLearnedTransportKey = useCallback( (action: MediaTransportAction, input: string) => { - const mediaKeys = addMediaKeyBinding(settingsRef.current.mediaKeys, action, input); - updateMediaKeys(mediaKeys); setCapturingTransportAction(null); - setMessage(`Learned ${mediaActionLabel(action)} key.`); + void updateSettings(current => ({mediaKeys: addMediaKeyBinding(current.mediaKeys, action, input)})) + .then(() => setMessage(`Learned ${mediaActionLabel(action)} key.`)) + .catch(() => undefined); }, - [updateMediaKeys] + [updateSettings] ); const go = useCallback((next: Screen, options: NavigationOptions = {}) => { @@ -627,19 +677,22 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ) { settingsPageRef.current = 'root'; setSettingsPage('root'); - itemCountsRef.current.settings = settingsItemsForPage('root').length; + itemCountsRef.current.settings = settingsItemsForPage('root', airPlaySupported).length; selectedByScreenRef.current.settings = selectedBySettingsPageRef.current.root ?? 0; } selectedByScreenRef.current[screenRef.current] = selectedRef.current; const remembered = selectedByScreenRef.current[destination] ?? 0; - const nextSelection = options.resetSelection ? 0 : remembered; + const nextSelection = clamp(options.resetSelection ? 0 : remembered, (itemCountsRef.current[destination] ?? 0) - 1); + // Input can arrive in one batch before React renders the next screen. + screenRef.current = destination; + selectedRef.current = nextSelection; setScreen(destination); - setSelected(clamp(nextSelection, (itemCountsRef.current[destination] ?? 0) - 1)); + setSelected(nextSelection); if (options.clearMessage !== false) { setMessage(null); } - }, []); + }, [airPlaySupported]); const openSettingsPage = useCallback((next: SettingsPage) => { selectedBySettingsPageRef.current[settingsPageRef.current] = selectedRef.current; @@ -647,12 +700,12 @@ export function App({store: providedStore, providers: providedProviders, alarmSe setSettingsPage(next); const nextSelection = clamp( selectedBySettingsPageRef.current[next] ?? 0, - settingsItemsForPage(next).length - 1 + settingsItemsForPage(next, airPlaySupported).length - 1 ); selectedRef.current = nextSelection; setSelected(nextSelection); setMessage(null); - }, []); + }, [airPlaySupported]); const closeSettingsPage = useCallback(() => { if (settingsPageRef.current === 'root') { @@ -664,12 +717,12 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const openAirPlayCode = useCallback(() => { if (!airPlaySupported) { - showTransientMessage(airPlayMacOSOnlyMessage); + showTransientMessage(airPlayUnavailableMessage); return; } setAirPlayCode(''); go('airplay-code', {resetSelection: true, clearMessage: false}); - }, [airPlaySupported, go, showTransientMessage]); + }, [airPlaySupported, airPlayUnavailableMessage, go, showTransientMessage]); useEffect(() => { if (isAirPlayCodePromptActive(playback) && screenRef.current !== 'airplay-code') { @@ -679,14 +732,19 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const shutdown = useCallback(() => { tuneRequestRef.current += 1; - persistLibrary(() => store.finishActiveListeningSession()); - void player.stop().catch(reportActionError).finally(exit); - }, [exit, persistLibrary, player, reportActionError, store]); + desktopSuspendedRef.current = true; + desktopMediaRef.current?.close(); + cancelPendingTune(); + void engine.close().finally(() => store.close()).catch(reportActionError).finally(exit); + }, [cancelPendingTune, engine, exit, reportActionError, store]); useEffect(() => { const finishForSignal = () => { - persistLibrary(() => store.finishActiveListeningSession()); - void player.stop().catch(reportActionError).finally(() => process.exit(0)); + tuneRequestRef.current += 1; + desktopSuspendedRef.current = true; + desktopMediaRef.current?.close(); + cancelPendingTune(); + void engine.close().finally(() => store.close()).catch(reportActionError).finally(() => process.exit(0)); }; process.once('SIGTERM', finishForSignal); process.once('SIGHUP', finishForSignal); @@ -694,24 +752,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe process.off('SIGTERM', finishForSignal); process.off('SIGHUP', finishForSignal); }; - }, [persistLibrary, player, reportActionError, store]); + }, [cancelPendingTune, engine, reportActionError, store]); - const showStationContext = useCallback( - (context: StationContext, next: Screen = 'stations', options: NavigationOptions = {}) => { - setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context); - go(next, {resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage}); - }, - [go, setStationContextFor] - ); - - const loadExploreAt = useCallback(async (cursor: ExploreCursor, options: NavigationOptions = {}) => { + const loadExploreAt = useCallback(async (cursor: ExploreCursor) => { const requestId = exploreRequestRef.current + 1; exploreRequestRef.current = requestId; - setLoadingStations(true); - setMessage(null); - if (screenRef.current !== 'explore') { - go('explore', {resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage}); - } + const signal = beginDiscovery('explore'); + setStationLoading('explore', true); + if (screenRef.current === 'explore') setMessage(null); exploreCursorRef.current = cursor; setExploreCursor(cursor); const previousExploreStations = stationContextsRef.current.explore.stations; @@ -722,7 +770,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe error: undefined }); try { - const stations = await providers.nearby(exploreCursorLocation(cursor), 90); + const stations = await providers.nearby(exploreCursorLocation(cursor), 90, {signal}); if (requestId !== exploreRequestRef.current) { return; } @@ -735,7 +783,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (screenRef.current === 'explore') { setSelected(0); } - if (stations.length === 0) { + if (stations.length === 0 && screenRef.current === 'explore') { setMessage(`No geotagged stations found near ${formatExploreCursor(cursor)}.`); } } catch (error) { @@ -748,25 +796,26 @@ export function App({store: providedStore, providers: providedProviders, alarmSe subtitle: `World station directory unavailable near ${formatExploreCursor(cursor)}`, error: loadError }); - setMessage(loadError); + if (screenRef.current === 'explore') setMessage(loadError); } finally { if (requestId === exploreRequestRef.current) { - setLoadingStations(false); + setStationLoading('explore', false); } } - }, [go, providers, setStationContextFor]); + }, [beginDiscovery, providers, setStationContextFor, setStationLoading]); const loadExplore = useCallback(async () => { - await loadExploreAt(exploreCursorRef.current, {resetSelection: true}); + await loadExploreAt(exploreCursorRef.current); }, [loadExploreAt]); const moveExploreMapCursor = useCallback( (direction: ExploreMoveDirection, fast = false) => { const next = shiftExploreCursor(exploreCursorRef.current, direction, fast); + exploreRequestRef.current += 1; exploreCursorRef.current = next; setExploreCursor(next); setSelected(0); - setLoadingStations(true); + setStationLoading('explore', true); setStationContextFor('explore', { title: 'Explore world', subtitle: `Move cursor: ${formatExploreCursor(next)}`, @@ -776,10 +825,11 @@ export function App({store: providedStore, providers: providedProviders, alarmSe clearTimeout(exploreMoveTimerRef.current); } exploreMoveTimerRef.current = setTimeout(() => { - void loadExploreAt(next, {resetSelection: true, clearMessage: false}); + exploreMoveTimerRef.current = null; + void loadExploreAt(next); }, 220); }, - [loadExploreAt, setStationContextFor] + [loadExploreAt, setStationContextFor, setStationLoading] ); const moveExploreMapCursorToCell = useCallback( @@ -791,91 +841,114 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (exploreMoveTimerRef.current) { clearTimeout(exploreMoveTimerRef.current); + exploreMoveTimerRef.current = null; } - void loadExploreAt(next, {resetSelection: true, clearMessage: false}); + void loadExploreAt(next); }, [frameWidth, layout, loadExploreAt] ); const loadCountry = useCallback( - async (country: Country) => { + async (country: Country, navigate = true) => { + const previous = stationContextsRef.current.stations; + if (previous.country?.code === country.code && loadingStationContextsRef.current.stations && !countryPageRequestRef.current) { + if (navigate) go('stations'); + return; + } + const signal = beginDiscovery('stations'); const requestId = countryRequestRef.current + 1; countryRequestRef.current = requestId; - setLoadingStations(true); - setMessage(null); + setStationLoading('stations', true); countryPageRequestRef.current = null; + const retainedStations = previous.country?.code === country.code ? previous.stations : []; + setStationContextFor('stations', { + title: country.name, + subtitle: formatCountryStationsSubtitle(country, retainedStations.length, false), + stations: retainedStations, + country + }); + if (navigate) go('stations', {resetSelection: true}); try { - const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0); + const page = typeof providers.byCountryPage === 'function' + ? await providers.byCountryPage(country.code, {limit: COUNTRY_STATIONS_PAGE_SIZE, signal}) + : legacyDiscoveryPage(await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0), COUNTRY_STATIONS_PAGE_SIZE); + const stations = page.items; if (requestId !== countryRequestRef.current) return; - showStationContext({ + setStationContextFor('stations', { title: country.name, - subtitle: formatCountryStationsSubtitle(country, stations.length, stations.length < country.stationCount), + subtitle: formatCountryStationsSubtitle(country, stations.length, !page.exhausted), stations, country, - hasMore: stations.length >= COUNTRY_STATIONS_PAGE_SIZE && stations.length < country.stationCount - }, 'stations'); + hasMore: !page.exhausted, nextCursor: page.nextCursor, sources: page.sources, + error: discoverySourceWarning(page.sources) + }); + selectedByScreenRef.current.stations = 0; + if (screenRef.current === 'stations') setSelected(0); } catch (error) { if (requestId !== countryRequestRef.current) return; - setMessage(error instanceof Error ? error.message : `Could not load ${country.name}.`); + const loadError = error instanceof Error ? error.message : `Could not load ${country.name}.`; + setStationContextFor('stations', {...stationContextsRef.current.stations, error: loadError}); + if (screenRef.current === 'stations') setMessage(loadError); } finally { - if (requestId === countryRequestRef.current) setLoadingStations(false); + if (requestId === countryRequestRef.current) setStationLoading('stations', false); } }, - [providers, showStationContext] + [beginDiscovery, go, providers, setStationContextFor, setStationLoading] ); const loadMoreCountryStations = useCallback(async () => { const context = stationContextsRef.current.stations; const country = context.country; - if (!country || !context.hasMore || loadingStationsRef.current) { + if (!country || !context.hasMore || loadingStationContextsRef.current.stations) { return; } const offset = context.stations.length; - const requestKey = `${country.code}:${offset}`; + const requestId = countryRequestRef.current; + const requestKey = `${requestId}:${country.code}:${context.nextCursor ?? offset}`; if (countryPageRequestRef.current === requestKey) { return; } countryPageRequestRef.current = requestKey; - setLoadingStations(true); + setStationLoading('stations', true); try { - const page = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, offset); + const page = typeof providers.byCountryPage === 'function' + ? await providers.byCountryPage(country.code, {limit: COUNTRY_STATIONS_PAGE_SIZE, cursor: context.nextCursor, signal: discoveryAbortRef.current.stations?.signal}) + : legacyDiscoveryPage(await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, offset), COUNTRY_STATIONS_PAGE_SIZE); const latest = stationContextsRef.current.stations; - if (latest.country?.code !== country.code || latest.stations.length !== offset) { + if (requestId !== countryRequestRef.current || latest.country?.code !== country.code || latest.stations.length !== offset) { return; } - const stations = appendUniqueStations(latest.stations, page); - const hasMore = - page.length >= COUNTRY_STATIONS_PAGE_SIZE && - stations.length > latest.stations.length && - stations.length < country.stationCount; + const stations = appendUniqueStations(latest.stations, page.items); + const hasMore = !page.exhausted; setStationContextFor('stations', { ...latest, subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore), stations, - hasMore + hasMore, nextCursor: page.nextCursor, sources: page.sources, + error: discoverySourceWarning(page.sources) }); } catch (error) { - if (stationContextsRef.current.stations.country?.code === country.code) { + if (requestId === countryRequestRef.current && screenRef.current === 'stations' && stationContextsRef.current.stations.country?.code === country.code) { setMessage(error instanceof Error ? error.message : `Could not load more ${country.name} stations.`); } } finally { if (countryPageRequestRef.current === requestKey) { countryPageRequestRef.current = null; - setLoadingStations(false); + setStationLoading('stations', false); } } - }, [providers, setStationContextFor]); + }, [providers, setStationContextFor, setStationLoading]); useEffect(() => { if ( screen === 'stations' && stationContexts.stations.country && stationContexts.stations.hasMore && - stationContexts.stations.stations.length - selected <= COUNTRY_STATIONS_LOAD_AHEAD + stationCounts.stations - selected <= COUNTRY_STATIONS_LOAD_AHEAD ) { void loadMoreCountryStations(); } @@ -885,7 +958,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe selected, stationContexts.stations.country, stationContexts.stations.hasMore, - stationContexts.stations.stations.length + stationCounts.stations, + stationContexts.stations.nextCursor ]); const runSearch = useCallback( @@ -895,95 +969,119 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return; } + const signal = beginDiscovery('search'); const requestId = searchRequestRef.current + 1; searchRequestRef.current = requestId; - setLoadingStations(true); - setMessage(null); + activeSearchRef.current = {query: query.trim(), fingerprint: searchFingerprint}; + searchPageRequestRef.current = null; + setStationLoading('search', true); + if (screenRef.current === 'search') setMessage(null); try { - const stations = await providers.search(query, settingsRef.current, { + const pageOptions = { limit: SEARCH_RESULTS_PAGE_SIZE, - offset: 0, + signal, codec: filters.codec ?? undefined, language: filters.language ?? undefined, minBitrate: filters.minBitrate ?? undefined - }); + }; + const page = typeof providers.searchPage === 'function' + ? await providers.searchPage(query, settingsRef.current, pageOptions) + : legacyDiscoveryPage(await providers.search(query, settingsRef.current, pageOptions), SEARCH_RESULTS_PAGE_SIZE); + const stations = page.items; if (requestId !== searchRequestRef.current) return; + searchContextFingerprintRef.current = searchFingerprint; setStationContextFor('search', { title: `Search: ${query}`, - subtitle: formatSearchSubtitle(stations.length, stations.length >= SEARCH_RESULTS_PAGE_SIZE), + subtitle: formatSearchSubtitle(stations.length, !page.exhausted), stations, query: query.trim(), - hasMore: stations.length >= SEARCH_RESULTS_PAGE_SIZE + hasMore: !page.exhausted, nextCursor: page.nextCursor, sources: page.sources, + error: discoverySourceWarning(page.sources) }); selectedByScreenRef.current.search = 0; lastSubmittedSearchRef.current = query.trim(); persistLibrary(() => store.addSearch(query)); searchHistoryRef.current = {cursor: -1, draft: ''}; - setSelected(0); - setEditingSearch(true); + if (screenRef.current === 'search') { + setSelected(0); + setEditingSearch(true); + } } catch (error) { if (requestId !== searchRequestRef.current) return; - setMessage(error instanceof Error ? error.message : 'Search failed.'); + if (screenRef.current === 'search') setMessage(error instanceof Error ? error.message : 'Search failed.'); } finally { - if (requestId === searchRequestRef.current) setLoadingStations(false); + if (requestId === searchRequestRef.current) setStationLoading('search', false); } }, - [filters, persistLibrary, providers, searchQuery, setStationContextFor, store] + [beginDiscovery, filters, persistLibrary, providers, searchFingerprint, searchQuery, setStationContextFor, setStationLoading, store] ); + useEffect(() => { + const active = activeSearchRef.current; + // A cursor belongs to the exact server filters and enabled directories. + // Reissue the submitted query, including when a previous request is pending. + if (active && active.fingerprint !== searchFingerprint) void runSearch(active.query); + }, [runSearch, searchFingerprint]); + const loadMoreSearchResults = useCallback(async () => { const context = stationContextsRef.current.search; const query = context.query; - if (!query || !context.hasMore || loadingStationsRef.current) { + if (!query || !context.hasMore || loadingStationContextsRef.current.search || searchContextFingerprintRef.current !== searchFingerprint) { return; } const offset = context.stations.length; - const requestKey = `${query}:${offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`; + const requestId = searchRequestRef.current; + const requestKey = `${requestId}:${query}:${context.nextCursor ?? offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`; if (searchPageRequestRef.current === requestKey) { return; } searchPageRequestRef.current = requestKey; - setLoadingStations(true); + setStationLoading('search', true); try { - const page = await providers.search(query, settingsRef.current, { + const pageOptions = { limit: SEARCH_RESULTS_PAGE_SIZE, - offset, + cursor: context.nextCursor, + signal: discoveryAbortRef.current.search?.signal, codec: filters.codec ?? undefined, language: filters.language ?? undefined, minBitrate: filters.minBitrate ?? undefined - }); + }; + const page = typeof providers.searchPage === 'function' + ? await providers.searchPage(query, settingsRef.current, pageOptions) + : legacyDiscoveryPage(await providers.search(query, settingsRef.current, {...pageOptions, offset}), SEARCH_RESULTS_PAGE_SIZE); const latest = stationContextsRef.current.search; - if (latest.query !== query || latest.stations.length !== offset) { + if (requestId !== searchRequestRef.current || latest.query !== query || latest.stations.length !== offset) { return; } - const stations = appendUniqueStations(latest.stations, page); - const hasMore = page.length >= SEARCH_RESULTS_PAGE_SIZE && stations.length > latest.stations.length; + const stations = appendUniqueStations(latest.stations, page.items); + const hasMore = !page.exhausted; setStationContextFor('search', { ...latest, subtitle: formatSearchSubtitle(stations.length, hasMore), stations, - hasMore + hasMore, nextCursor: page.nextCursor, sources: page.sources, + error: discoverySourceWarning(page.sources) }); } catch (error) { - if (stationContextsRef.current.search.query === query) { + if (requestId === searchRequestRef.current && screenRef.current === 'search' && stationContextsRef.current.search.query === query) { setMessage(error instanceof Error ? error.message : 'Could not load more search results.'); } } finally { if (searchPageRequestRef.current === requestKey) { searchPageRequestRef.current = null; - setLoadingStations(false); + setStationLoading('search', false); } } - }, [filters, providers, setStationContextFor]); + }, [filters, providers, searchFingerprint, setStationContextFor, setStationLoading]); useEffect(() => { if ( screen === 'search' && stationContexts.search.hasMore && - stationContexts.search.stations.length - selected <= SEARCH_RESULTS_LOAD_AHEAD + stationCounts.search - selected <= SEARCH_RESULTS_LOAD_AHEAD ) { void loadMoreSearchResults(); } @@ -992,7 +1090,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe screen, selected, stationContexts.search.hasMore, - stationContexts.search.stations.length + stationCounts.search, + stationContexts.search.nextCursor ]); const recallSearchHistory = useCallback( @@ -1022,19 +1121,19 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ); const loadNearby = useCallback(async () => { + const signal = beginDiscovery('nearby'); const requestId = nearbyRequestRef.current + 1; nearbyRequestRef.current = requestId; - setLoadingStations(true); - setMessage(null); + setStationLoading('nearby', true); + if (screenRef.current === 'nearby') setMessage(null); setStationContextFor('nearby', { ...stationContextsRef.current.nearby, error: undefined }); - go('nearby', {resetSelection: stationContextsRef.current.nearby.stations.length === 0}); try { if (!settingsRef.current.enableNearbyLocation) { if (stationContextsRef.current.nearby.stations.length > 0) { - setMessage('Nearby location lookup is off. Showing the last nearby station list.'); + if (screenRef.current === 'nearby') setMessage('Nearby location lookup is off. Showing the last nearby station list.'); return; } @@ -1047,12 +1146,12 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return; } - const detected = location ?? (await providers.detectLocation()); + const detected = location ?? (await providers.detectLocation({signal})); if (requestId !== nearbyRequestRef.current) return; setLocation(detected); if (!detected) { if (stationContextsRef.current.nearby.stations.length > 0) { - setMessage('Location detection is unavailable. Showing the last nearby station list.'); + if (screenRef.current === 'nearby') setMessage('Location detection is unavailable. Showing the last nearby station list.'); return; } @@ -1065,7 +1164,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return; } - const stations = await providers.nearby(detected, 90); + const stations = await providers.nearby(detected, 90, {signal}); if (requestId !== nearbyRequestRef.current) return; setStationContextFor('nearby', { title: 'Nearby', @@ -1079,17 +1178,19 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ...stationContextsRef.current.nearby, error: loadError }); - setMessage(loadError); + if (screenRef.current === 'nearby') setMessage(loadError); } finally { - if (requestId === nearbyRequestRef.current) setLoadingStations(false); + if (requestId === nearbyRequestRef.current) setStationLoading('nearby', false); } - }, [go, location, providers, setStationContextFor]); + }, [beginDiscovery, location, providers, setStationContextFor, setStationLoading]); const queueFromCurrentList = useCallback( (station: Station): PlaybackQueue => { const sourceScreen = screenRef.current; const sourceContextKey = stationContextKeyForScreen(sourceScreen); - const currentList = displayStationsRef.current; + const currentList = sourceContextKey + ? applyStationFilters(stationContextsRef.current[sourceContextKey].stations, filters) + : displayStationsRef.current; if (currentList.some(item => stationMatches(item, station))) { return { title: sourceContextKey ? stationContextsRef.current[sourceContextKey].title : 'Current station list', @@ -1110,7 +1211,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe stations: [station] }; }, - [queueContainsStation, stationMatches] + [filters, queueContainsStation, stationMatches] ); const rememberQueueSelection = useCallback((queue: PlaybackQueue, index: number) => { @@ -1124,70 +1225,35 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } }, []); - const playStation = useCallback( - async (station: Station, options: PlayStationOptions = {}) => { - const requestId = tuneRequestRef.current + 1; - tuneRequestRef.current = requestId; - if (skipBrokenTimerRef.current) { - clearTimeout(skipBrokenTimerRef.current); - skipBrokenTimerRef.current = null; - } - const queue = options.queue ?? queueFromCurrentList(station); - setTuningStation(station); - setNowPlaying(null); - - try { - // Cancel any in-flight backend startup before resolving the next tune. - // This prevents a slower, older request from becoming audible after a - // newer station was selected. - await player.stop(); - if (requestId !== tuneRequestRef.current) return; - const resolved = await providers.resolve(station); - if (requestId !== tuneRequestRef.current) return; - await player.play(station, resolved.url); - if (requestId !== tuneRequestRef.current) return; - playingStationRef.current = station; - setPlayingStation(station); - setTuningStation(null); - playbackQueueRef.current = queue; - 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) { - selectedByScreenRef.current.library = nextLibraryIndex; - setSelected(nextLibraryIndex); - } - } - - if (options.openNowPlaying) { - go('now-playing'); - } - - setMessage(null); - } catch (error) { - if (requestId !== tuneRequestRef.current) return; - const message = error instanceof Error ? error.message : 'Could not tune station.'; - const currentList = queue.stations; - const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station)); - const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined; - if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) { - setMessage(`${message} Skipping to ${nextStation.name}.`); - setTuningStation(nextStation); - rememberQueueSelection(queue, currentIndex + 1); - skipBrokenTimerRef.current = setTimeout(() => { - skipBrokenTimerRef.current = null; - playStationRef.current(nextStation, {...options, queue}); - }, BROKEN_STREAM_AUTO_SKIP_DELAY_MS); - return; - } - - setTuningStation(null); - setMessage(message); + const playStation = useCallback(async (station: Station, options: PlayStationOptions & {outputChange?: boolean} = {}) => { + if (options.signal?.aborted) return; + if (!options.outputChange) outputCancellationRef.current += 1; + const requestId = ++tuneRequestRef.current; + const pendingSettings = settingsQueueRef.current; + const queue = options.queue ?? queueFromCurrentList(station); + try { + await pendingSettings; + if (requestId !== tuneRequestRef.current || options.signal?.aborted) return; + desktopSuspendedRef.current = false; + playbackQueueRef.current = queue; + const tuned = await engine.tune(station, {queue: queue.stations, startPaused: options.startPaused, signal: options.signal}); + if (requestId !== tuneRequestRef.current) return; + const index = queue.stations.findIndex(item => stationMatches(item, tuned)); + if (index >= 0 && !stationMatches(tuned, station)) rememberQueueSelection(queue, index); + desktopMediaRef.current?.refresh(); + if (screenRef.current === 'library') { + const nextLibraryStations = applyStationFilters(buildLibraryStations(store.getSnapshot()), filters); + const nextIndex = nextLibraryStations.findIndex(item => stationMatches(item, tuned)); + if (nextIndex >= 0) { selectedByScreenRef.current.library = nextIndex; setSelected(nextIndex); } } - }, - [filters, go, persistLibrary, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store] - ); + if (options.openNowPlaying) go('now-playing'); + setMessage(null); + return tuned; + } catch (error) { + if (requestId !== tuneRequestRef.current || options.signal?.aborted || (error as Error).name === 'AbortError') return; + setMessage(error instanceof Error ? error.message : 'Could not tune station.'); + } + }, [engine, filters, go, queueFromCurrentList, rememberQueueSelection, stationMatches, store]); // Resume the most recent station on launch when opted in, like a radio // powering back on to its last frequency. Runs once and never auto-navigates. @@ -1198,6 +1264,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } didResumeRef.current = true; + if (playbackSession?.status.station || playbackSession?.status.tui?.tuningStation) return; if (!settingsRef.current.resumeOnLaunch) { return; } @@ -1206,16 +1273,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (last) { void playStation(last); } - }, [playStation, store]); - playStationRef.current = playStation; + }, [playStation, store, playbackSession]); const cancelPendingAutoSkip = useCallback(() => { - if (!skipBrokenTimerRef.current) return; - clearTimeout(skipBrokenTimerRef.current); - skipBrokenTimerRef.current = null; - setTuningStation(null); + if (!engine.snapshot().message?.includes('Skipping to')) return; + cancelPendingTune(); setMessage('Auto-skip canceled. Choose a station and press Enter.'); - }, []); + }, [cancelPendingTune, engine]); const showTransientFooterMessage = useCallback((nextMessage: string, durationMs = TRANSIENT_NOTICE_MS) => { if (transientFooterMessageTimerRef.current) { @@ -1241,15 +1305,17 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, [showTransientFooterMessage, shutdown]); const toggleFavorite = useCallback( - (station: Station | null) => { + (station: Station | null): Promise => { + const operation = (async () => { if (!station) { setMessage('Select or play a station before pressing f.'); return; } const wasFavorite = store.isFavorite(station); - setLibrary(store.toggleFavorite(station)); + setLibrary(localSessionLibrary(await store.toggleFavorite(station), platform, player.airPlaySelectionRequired())); setPersistenceWarning(null); + engine.clearWarning(); const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`; if (screenRef.current === 'library' || screenRef.current === 'search') { showTransientFooterMessage(favoriteMessage); @@ -1260,8 +1326,11 @@ export function App({store: providedStore, providers: providedProviders, alarmSe // Best-effort upvote back to the directory; never blocks favoriting. void providers.vote(station).catch(reportActionError); } + })(); + void operation.catch(reportActionError); + return operation; }, - [providers, reportActionError, showTransientFooterMessage, showTransientMessage, store] + [engine, platform, player, providers, reportActionError, showTransientFooterMessage, showTransientMessage, store] ); const showControlResult = useCallback((result: PlaybackControlResult) => { @@ -1314,18 +1383,21 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ); const submitAirPlayCode = useCallback( - (code: string) => { - const result = player.submitAirPlayPasscode(code); - if (result.ok) { - setAirPlayCode(''); - setMessage(result.message ?? 'AirPlay code sent.'); - go('now-playing', {clearMessage: false}); - return; + async (code: string) => { + try { + const result = await player.submitAirPlayPasscode(code); + if (result.ok) { + setAirPlayCode(''); + setMessage(result.message ?? 'AirPlay code sent.'); + go('now-playing', {clearMessage: false}); + return; + } + showControlResult(result); + } catch (error) { + reportActionError(error); } - - showControlResult(result); }, - [go, player, showControlResult] + [go, player, showControlResult, reportActionError] ); const setVolume = useCallback( @@ -1366,27 +1438,26 @@ export function App({store: providedStore, providers: providedProviders, alarmSe }, [player, reportActionError, showControlResult]); const cycleDisplayColor = useCallback(() => { - const theme = nextTheme(settingsRef.current.theme); - updateSettings({theme}); - showTransientMessage(`Display color: ${theme}`); + void updateSettings(current => ({theme: nextTheme(current.theme)})) + .then(saved => showTransientMessage(`Display color: ${saved.settings.theme}`)).catch(() => undefined); }, [showTransientMessage, updateSettings]); const cycleReceiverStyle = useCallback(() => { - const receiverStyle = nextReceiverStyle(settingsRef.current.receiverStyle); - updateSettings({receiverStyle}); - showTransientMessage(`Receiver style: ${receiverStyle}`); + void updateSettings(current => ({receiverStyle: nextReceiverStyle(current.receiverStyle)})) + .then(saved => showTransientMessage(`Receiver style: ${saved.settings.receiverStyle}`)).catch(() => undefined); }, [showTransientMessage, updateSettings]); const toggleRadioGarden = useCallback(() => { - const enableRadioGarden = !settingsRef.current.enableRadioGarden; - updateSettings({enableRadioGarden}); + void (async () => { + const {settings: {enableRadioGarden}} = await updateSettings(current => ({enableRadioGarden: !current.enableRadioGarden})); setMessage(`Radio Garden ${enableRadioGarden ? 'enabled' : 'disabled'}.`); setTimeout(refreshProviderHealth, 0); - }, [refreshProviderHealth, updateSettings]); + })().catch(reportActionError); + }, [refreshProviderHealth, reportActionError, updateSettings]); const toggleNearbyLocation = useCallback(() => { - const enableNearbyLocation = !settingsRef.current.enableNearbyLocation; - updateSettings({enableNearbyLocation}); + void (async () => { + const {settings: {enableNearbyLocation}} = await updateSettings(current => ({enableNearbyLocation: !current.enableNearbyLocation})); setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`); if (enableNearbyLocation && screenRef.current === 'nearby') { setTimeout(() => void loadNearby(), 0); @@ -1398,19 +1469,25 @@ export function App({store: providedStore, providers: providedProviders, alarmSe error: undefined }); } - }, [loadNearby, setStationContextFor, updateSettings]); + })().catch(reportActionError); + }, [loadNearby, reportActionError, setStationContextFor, updateSettings]); const toggleDirectoryVoting = useCallback(() => { - const shareDirectoryVotes = !settingsRef.current.shareDirectoryVotes; - updateSettings({shareDirectoryVotes}); + void (async () => { + const {settings: {shareDirectoryVotes}} = await updateSettings(current => ({shareDirectoryVotes: !current.shareDirectoryVotes})); setMessage(`Radio Browser favorite votes ${shareDirectoryVotes ? 'enabled' : 'disabled'}.`); - }, [updateSettings]); + })().catch(reportActionError); + }, [reportActionError, updateSettings]); const refreshAirPlayTargets = useCallback(async (announce = true): Promise => { if (!airPlaySupported) { - if (announce) showTransientMessage(airPlayMacOSOnlyMessage); + if (announce) showTransientMessage(airPlayUnavailableMessage); return []; } + if (platform === 'linux' && !player.refreshDetectedBackends().includes('airplay')) { + setMessage(airPlaySetupRequiredMessage); + throw new PlaybackOutputError(airPlaySetupRequiredMessage); + } if (networkPolicy().offline) { setMessage('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.'); return []; @@ -1431,89 +1508,97 @@ export function App({store: providedStore, providers: providedProviders, alarmSe } return devices; - } catch { + } catch (error) { setAvailableAirPlayDevices([]); - if (announce) { - setMessage('AirPlay receiver refresh failed.'); - } - - return []; + throw error instanceof Error ? error : new Error('AirPlay receiver refresh failed.'); } - }, [airPlaySupported, player, showTransientMessage]); + }, [airPlaySupported, airPlayUnavailableMessage, platform, player, showTransientMessage]); const openAirPlaySettings = useCallback(() => { if (!airPlaySupported) { - showTransientMessage(airPlayMacOSOnlyMessage); + showTransientMessage(airPlayUnavailableMessage); 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); - }, [airPlaySupported, availableAirPlayDevices, go, refreshAirPlayTargets, showTransientMessage]); + void refreshAirPlayTargets(false).catch(reportActionError); + }, [airPlaySupported, airPlayUnavailableMessage, availableAirPlayDevices, go, refreshAirPlayTargets, reportActionError, showTransientMessage]); const selectAirPlayDeviceAt = useCallback( (index: number) => { + pendingOutputChangesRef.current += 1; + void (async () => { + const requestId = outputCancellationRef.current; const device = availableAirPlayDevices[index]; if (!device) { setMessage('No AirPlay receiver selected.'); return; } - updateSettings({preferredAirPlayDevice: device.id}); + const preferredBackend = device.local + ? preferredLocalPlaybackBackend(availableBackends) + : isAirPlayBackendAvailable(availableBackends) ? 'airplay' : null; + await updateSettings({preferredAirPlayDevice: device.id, ...(preferredBackend ? {preferredBackend} : {})}); + if (requestId !== outputCancellationRef.current) return; selectedByScreenRef.current['airplay-settings'] = index; if (device.local) { - const preferredBackend = preferredLocalPlaybackBackend(availableBackends); if (!preferredBackend) { - setMessage(`${device.name} is this Mac. Install mpv to use local playback instead of AirPlay.`); + setMessage(`${device.name} is ${airPlayLocalDeviceLabel(platform)}. Install mpv to use local playback instead of AirPlay.`); return; } - updateSettings({preferredBackend}); if (playingStation && shouldRetuneForAudioOutput(playback.state)) { const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation); - setMessage(`${device.name} is this Mac. Switching audio to ${audioOutputLabel(preferredBackend)}...`); - void playStation(playingStation, {queue}); + setMessage(`${device.name} is ${airPlayLocalDeviceLabel(platform)}. Switching audio to ${audioOutputLabel(preferredBackend)}...`); + await playStation(playingStation, {queue, outputChange: true}); return; } - setMessage(`${device.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`); + setMessage(`${device.name} is ${airPlayLocalDeviceLabel(platform)}. Audio output: ${audioOutputLabel(preferredBackend)}.`); return; } - if (!isAirPlayBackendAvailable(availableBackends)) { + if (!preferredBackend) { setMessage(`AirPlay receiver: ${device.name}. AirPlay playback is unavailable; run radiocli doctor.`); return; } - updateSettings({preferredBackend: 'airplay'}); if (playingStation && shouldRetuneForAudioOutput(playback.state)) { const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation); setMessage(`Switching audio to AirPlay: ${device.name}...`); - void playStation(playingStation, {queue}); + await playStation(playingStation, {queue, outputChange: true}); return; } setMessage(`AirPlay receiver: ${device.name}. Audio output: AirPlay.`); + })().catch(reportActionError).finally(() => {pendingOutputChangesRef.current -= 1;}); }, - [availableAirPlayDevices, availableBackends, playback.state, playStation, playingStation, queueFromCurrentList, updateSettings] + [availableAirPlayDevices, availableBackends, platform, playback.state, player, playStation, playingStation, queueFromCurrentList, reportActionError, updateSettings] ); const cycleAudioOutput = useCallback(() => { - const currentOutput = settingsRef.current.preferredBackend; - const activeNeedsSelectedOutput = Boolean( - playingStation && - shouldRetuneForAudioOutput(playback.state) && - audioOutputCanApply(currentOutput, settingsRef.current) && - audioOutputNeedsActiveSwitch(currentOutput, playback.backend, availableBackends) - ); - const preferredBackend = activeNeedsSelectedOutput - ? currentOutput - : nextAvailablePlaybackBackend(currentOutput, availableBackends); - - updateSettings({preferredBackend}); + const precedingOutputChange = pendingOutputChangesRef.current > 0; + pendingOutputChangesRef.current += 1; + void (async () => { + const requestId = outputCancellationRef.current; + let preferredBackend!: AppSettings['preferredBackend']; + await updateSettings(current => { + const currentPlayback = player.getState(); + const activeNeedsSelectedOutput = Boolean( + !precedingOutputChange && playingStation && + shouldRetuneForAudioOutput(currentPlayback.state) && + audioOutputCanApply(current.preferredBackend, current) && + audioOutputNeedsActiveSwitch(current.preferredBackend, currentPlayback.backend, availableBackends) + ); + preferredBackend = activeNeedsSelectedOutput + ? current.preferredBackend + : nextAvailablePlaybackBackend(current.preferredBackend, platform === 'linux' && airPlaySupported ? [...new Set([...availableBackends, 'airplay'])] : availableBackends); + return {preferredBackend}; + }); + if (requestId !== outputCancellationRef.current) return; - if (preferredBackend === 'airplay' && !settingsRef.current.preferredAirPlayDevice) { + if (preferredBackend === 'airplay' && (!settingsRef.current.preferredAirPlayDevice || player.airPlaySelectionRequired())) { setMessage('Choose an AirPlay receiver first. Your current station will keep playing until you pick one.'); openAirPlaySettings(); return; @@ -1522,37 +1607,36 @@ export function App({store: providedStore, providers: providedProviders, alarmSe if (playingStation && shouldRetuneForAudioOutput(playback.state)) { const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation); setMessage(`Switching audio to ${audioOutputSwitchLabel(preferredBackend, availableBackends)}...`); - void playStation(playingStation, {queue}); + await playStation(playingStation, {queue, outputChange: true}); return; } setMessage(`Audio output: ${audioOutputSwitchLabel(preferredBackend, availableBackends)}.`); - }, [availableBackends, openAirPlaySettings, playback.backend, playback.state, playStation, playingStation, queueFromCurrentList, updateSettings]); + })().catch(reportActionError).finally(() => {pendingOutputChangesRef.current -= 1;}); + }, [airPlaySupported, availableBackends, openAirPlaySettings, platform, playback.state, player, playStation, playingStation, queueFromCurrentList, reportActionError, updateSettings]); const toggleSkipBrokenStreams = useCallback(() => { - const skipBrokenStreams = !settingsRef.current.skipBrokenStreams; - updateSettings({skipBrokenStreams}); - setMessage(`Skip broken streams ${skipBrokenStreams ? 'enabled' : 'disabled'}.`); + void updateSettings(current => ({skipBrokenStreams: !current.skipBrokenStreams})) + .then(saved => setMessage(`Skip broken streams ${saved.settings.skipBrokenStreams ? 'enabled' : 'disabled'}.`)) + .catch(() => undefined); }, [updateSettings]); const toggleSetting = useCallback( (key: BooleanSetting) => { - const current = key === 'mouseSupport' - ? settingsRef.current.mouseSupport !== false - : Boolean(settingsRef.current[key]); - const next = !current; - updateSettings({[key]: next}); - setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`); + void updateSettings(current => ({[key]: key === 'mouseSupport' ? current.mouseSupport === false : !current[key]})) + .then(saved => setMessage(`${settingToggleLabel[key]} ${saved.settings[key] ? 'on' : 'off'}.`)) + .catch(() => undefined); }, [updateSettings] ); const toggleAgentSetting = useCallback( (key: 'openUiOnPlay' | 'focusNowPlaying') => { - const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings; - const next = !agentControl[key]; - updateSettings({agentControl: {...agentControl, [key]: next}}); - setMessage(`Agent ${key === 'openUiOnPlay' ? 'TUI launch' : 'Now Playing focus'} ${next ? 'on' : 'off'}.`); + void updateSettings(current => { + const agentControl = current.agentControl ?? defaultAgentControlSettings; + return {agentControl: {...agentControl, [key]: !agentControl[key]}}; + }).then(saved => setMessage(`Agent ${key === 'openUiOnPlay' ? 'TUI launch' : 'Now Playing focus'} ${saved.settings.agentControl?.[key] ? 'on' : 'off'}.`)) + .catch(() => undefined); }, [updateSettings] ); @@ -1563,18 +1647,17 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return; } configuringMcpRef.current = true; + await settingsQueueRef.current; const enabled = !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled; setMessage(enabled ? 'Setting up agent and Codex Voice control...' : 'Disabling agent and voice control...'); try { const results = await mcpConfigurator(enabled, mcpRuntime(), null); const failed = results.filter(item => item.status === 'failed'); - const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings; - updateSettings({agentControl: {...agentControl, enabled}}); + await updateSettings(current => ({agentControl: {...(current.agentControl ?? defaultAgentControlSettings), enabled}})); setMessage(integrationResultMessage(enabled, results, failed)); } catch (error) { if (!enabled) { - const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings; - updateSettings({agentControl: {...agentControl, enabled: false}}); + await updateSettings(current => ({agentControl: {...(current.agentControl ?? defaultAgentControlSettings), enabled: false}})); } setMessage(`Agent integration ${enabled ? 'setup' : 'removal'} failed: ${error instanceof Error ? error.message : String(error)}`); } finally { @@ -1592,8 +1675,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe try { const results = await mcpConfigurator(true, mcpRuntime(), null); const failed = results.filter(item => item.status === 'failed'); - const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings; - updateSettings({agentControl: {...agentControl, enabled: true}}); + await updateSettings(current => ({agentControl: {...(current.agentControl ?? defaultAgentControlSettings), enabled: true}})); setMessage(integrationResultMessage(true, results, failed)); } catch (error) { setMessage(`MCP repair failed: ${error instanceof Error ? error.message : String(error)}`); @@ -1606,7 +1688,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null; const next = nextSleepTimerMinutes(currentMinutes); setSleepUntil(next ? Date.now() + next * 60_000 : null); - }, [sleepUntil]); + }, [sleepUntil, setSleepUntil]); const openLibrary = useCallback(() => { go('library', {resetSelection: false}); @@ -1614,8 +1696,12 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const selectedStation = displayStations[selected] ?? null; const selectedStationForInput = useCallback( - () => displayStationsRef.current[selectedRef.current] ?? null, - [] + () => { + const key = stationContextKeyForScreen(screenRef.current); + const stations = key ? applyStationFilters(stationContextsRef.current[key].stations, filters) : displayStationsRef.current; + return stations[selectedRef.current] ?? null; + }, + [filters] ); const alarmTui = useAlarmTui({ @@ -1624,12 +1710,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe library, setLibrary, screen, + screenRef, selected, + selectedRef, setSelected, go, setMessage, playingStation, - previewStation: alarmPreview ?? (station => playStation(station)) + previewStation: alarmPreview ?? (async station => { await playStation(station); }) }); for (const alarmScreen of ['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing'] as const) { itemCountsRef.current[alarmScreen] = alarmTui.itemCount(alarmScreen) ?? 0; @@ -1638,15 +1726,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const openScreen = useCallback( (target: Screen) => { if (target === 'explore') { - if (stationContextsRef.current.explore.stations.length > 0) { - go('explore'); - } else { + go('explore'); + if (stationContextsRef.current.explore.stations.length === 0 && !loadingStationContextsRef.current.explore) { void loadExplore(); } } else if (target === 'nearby') { - if (stationContextsRef.current.nearby.stations.length > 0) { - go('nearby'); - } else { + go('nearby'); + if (stationContextsRef.current.nearby.stations.length === 0 && !loadingStationContextsRef.current.nearby) { void loadNearby(); } } else if (target === 'search') { @@ -1665,7 +1751,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const openAdjacentTab = useCallback( (direction: 1 | -1) => { - const active = activeTabForScreen(screen); + const active = activeTabForScreen(screenRef.current); const currentIndex = topTabs.findIndex(tab => tab.screen === active); const nextIndex = (currentIndex + direction + topTabs.length) % topTabs.length; const next = topTabs[nextIndex]; @@ -1673,7 +1759,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe openScreen(next.screen); } }, - [openScreen, screen] + [openScreen] ); const handleUpdateCommand = useCallback(async () => { @@ -1730,12 +1816,12 @@ export function App({store: providedStore, providers: providedProviders, alarmSe try { const repaired = await mcpConfigurator(true, mcpRuntime(), null); const suffix = repaired.some(item => item.status === 'failed') ? ' Some MCP clients need manual repair.' : ' MCP integrations repaired.'; - setMessage(`Update installed.${suffix} Restart RadioCLI and open agent clients.`); + setMessage(`Update installed.${suffix} Quit and reopen RadioCLI; restart MCP clients to load updated tools.`); } catch { - setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then restart RadioCLI and open agent clients.'); + setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then quit and reopen RadioCLI and restart MCP clients.'); } } else { - setMessage('Update installed. Restart RadioCLI to use the new version.'); + setMessage('Update installed. Quit and reopen RadioCLI to use the new version.'); } return; } @@ -1746,10 +1832,12 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const executeCommand = useCommandExecutor({ beginLearningTransportKey, + cancelPendingTune, countries, go, loadCountry, openAirPlaySettings, + openAirPlayCode, openLibrary, persistLibrary, player, @@ -1758,6 +1846,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe resetLearnedTransportKeys, runSearch, screen, + screenRef, selectedStation, setCountries, setFilters, @@ -1804,11 +1893,13 @@ export function App({store: providedStore, providers: providedProviders, alarmSe [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection] ); - agentHandlerRef.current = async (command: RadioSessionCommand): Promise => { + agentHandlerRef.current = async (command: RadioSessionCommand, signal?: AbortSignal): Promise => { const sessionStatus = (): RadioSessionStatus => ({ owner: 'tui', playback: player.getState(), station: playingStationRef.current, + nowPlaying: engine.snapshot().nowPlaying, + persistenceWarning: persistenceWarning ?? engine.snapshot().warning, queue: playbackQueueRef.current?.stations ?? [], output: { preferredBackend: settingsRef.current.preferredBackend, @@ -1816,8 +1907,12 @@ 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 (command.type === 'desktop-transport') { + const resolved = resolveDesktopTransport(command.action, sessionStatus()); + return resolved ? agentHandlerRef.current(resolved, signal) : respond('Playback state unchanged.'); + } if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !airPlaySupported) { - return respond(airPlayMacOSOnlyMessage, false, command.type === 'airplay-list' ? [] : undefined); + return respond(airPlayUnavailableMessage, 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') { @@ -1825,22 +1920,29 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return respond(`Kept current station ${playingStationRef.current?.name ?? ''}.`); } const queue: PlaybackQueue = {title: 'Agent selection', sourceScreen: 'now-playing', sourceContextKey: null, stations: command.queue?.length ? command.queue : [command.station]}; - await playStation(command.station, {queue, openNowPlaying: command.openNowPlaying}); + const tuned = await playStation(command.station, {queue, openNowPlaying: command.openNowPlaying, signal}); const state = player.getState(); - const playingRequestedStation = playingStationRef.current - && stationKey(playingStationRef.current) === stationKey(command.station) + const playingRequestedStation = tuned && playingStationRef.current + && stationKey(playingStationRef.current) === stationKey(tuned) && state.state === 'playing' && state.ready; return playingRequestedStation - ? respond(`Playing ${command.station.name}.`) + ? respond(`Playing ${tuned.name}.`) : respond(state.state === 'error' ? state.message ?? `Could not play ${command.station.name}.` : `Could not play ${command.station.name}.`, false); } - if (command.type === 'pause') { const control = await player.pause(); return respond(control.message ?? 'Paused.', control.ok); } - if (command.type === 'resume') { const control = await player.resume(); return respond(control.message ?? 'Resumed.', control.ok); } + if (command.type === 'pause') { const control = await engine.pause(); return respond(control.message ?? 'Paused.', control.ok); } + if (command.type === 'resume') { + const station = playingStationRef.current; + if (command.restartStopped && station && ['idle', 'stopped'].includes(player.getState().state)) { + await playStation(station, {queue: playbackQueueRef.current ?? queueFromCurrentList(station), signal}); + return respond(`Playing ${station.name}.`, player.getState().ready && player.getState().state === 'playing'); + } + const control = await engine.resume(); return respond(control.message ?? 'Resumed.', control.ok); + } if (command.type === 'stop') { - tuneRequestRef.current += 1; - persistLibrary(() => store.finishActiveListeningSession()); - await player.stop(); + cancelPendingTune(); + await engine.stop({retainSelection: command.retainSelection}); + if (command.retainSelection) { setTuningStation(null); desktopMediaRef.current?.refresh(); return respond('Stopped playback.'); } playingStationRef.current = null; setPlayingStation(null); setTuningStation(null); @@ -1848,9 +1950,10 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return respond('Stopped RadioCLI.'); } if (command.type === 'alarm-preempt') { - tuneRequestRef.current += 1; - persistLibrary(() => store.finishActiveListeningSession()); - await player.stop(); + desktopSuspendedRef.current = true; + desktopMediaRef.current?.refresh(); + cancelPendingTune(); + await engine.stop(); playingStationRef.current = null; setPlayingStation(null); setTuningStation(null); @@ -1858,8 +1961,14 @@ export function App({store: providedStore, providers: providedProviders, alarmSe return respond('Interactive playback yielded to the alarm.'); } if (command.type === 'set-volume') { - const control = await player.setVolume(clampVolume(command.volume)); - if (control.ok) updateSettings({volume: player.getState().volume}); + const control = command.unmute + ? await player.setVolume(clampVolume(command.volume), {allowStopped: true}) + : await player.setVolume(clampVolume(command.volume)); + if (control.ok) await updateSettings({volume: player.getState().volume}); + if (control.ok && command.unmute && command.volume > 0) { + const unmuted = await player.setMuted(false, {allowStopped: true}); + if (!unmuted.ok) return respond(unmuted.message ?? 'The output could not be unmuted.', false); + } return respond(control.message ?? `Volume ${player.getState().volume}.`, control.ok); } if (command.type === 'set-muted') { const control = await player.setMuted(command.muted); return respond(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok); } @@ -1867,53 +1976,67 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const station = command.station ?? playingStationRef.current; if (!station) return respond('No active station to favorite.', false); const current = store.isFavorite(station); - if (current !== command.favorite) toggleFavorite(station); + if (current !== command.favorite) await toggleFavorite(station); 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); + let devices: AirPlayDevice[]; + try { devices = await refreshAirPlayTargets(false); } catch (error) { + if (error instanceof PlaybackOutputError) return respond(error.message, false, []); + throw error; + } 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); + let devices: AirPlayDevice[]; + try { devices = await refreshAirPlayTargets(false); } catch (error) { + if (error instanceof PlaybackOutputError) return respond(error.message, false, []); + throw error; + } + signal?.throwIfAborted(); 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); if (device.local) { const backend = preferredLocalPlaybackBackend(availableBackends); if (!backend) return respond('No local playback backend is available. Run radiocli setup to install mpv.', false); - updateSettings({preferredBackend: backend}); + await updateSettings({preferredAirPlayDevice: device.id, preferredBackend: backend}); + signal?.throwIfAborted(); if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) { - await playStation(playingStationRef.current, {queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current)}); + await playStation(playingStationRef.current, {queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal}); } return respond(`Audio output set to this device (${backend}).`); } if (!isAirPlayBackendAvailable(availableBackends)) return respond('AirPlay playback is unavailable; run radiocli doctor.', false); - updateSettings({preferredAirPlayDevice: device.id}); - updateSettings({preferredBackend: 'airplay'}); + await updateSettings({preferredAirPlayDevice: device.id, preferredBackend: 'airplay'}); + signal?.throwIfAborted(); if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) { - await playStation(playingStationRef.current, {queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current)}); + await playStation(playingStationRef.current, {queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal}); } return respond(`Audio output set to AirPlay receiver ${device.name}.`); } if (command.type === 'airplay-local') { const backend = preferredLocalPlaybackBackend(availableBackends); if (!backend) return respond('No local playback backend is available. Run radiocli setup to install mpv.', false); - updateSettings({preferredBackend: backend}); + await updateSettings({preferredBackend: backend}); + signal?.throwIfAborted(); if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) { - await playStation(playingStationRef.current, {queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current)}); + await playStation(playingStationRef.current, {queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal}); } return respond(`Audio output set to this device (${backend}).`); } if (command.type === 'airplay-passcode') { - const control = player.submitAirPlayPasscode(command.code); + const control = await player.submitAirPlayPasscode(command.code); return respond(control.message ?? 'AirPlay code sent.', control.ok); } if (command.type === 'update-settings') { - updateSettings(command.settings); + if (!allowedRadioSessionSettings(command.settings)) return respond('Session settings only accept appearance and agent control. Use radiocli setup --airplay to enable Linux AirPlay.', false); + await updateSettings(command.settings); return respond('RadioCLI settings updated.'); } + if (command.type !== 'next' && command.type !== 'previous') throw new Error('Unsupported radio command.'); + signal?.throwIfAborted(); const active = playingStationRef.current; const queue = playbackQueueRef.current; if (!active || !queue?.stations.length) return respond('No playback queue is available.', false); @@ -1921,32 +2044,76 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const delta = command.type === 'next' ? 1 : -1; const next = queue.stations[(Math.max(0, currentIndex) + delta + queue.stations.length) % queue.stations.length]; if (!next) return respond('No adjacent station is available.', false); - await playStation(next, {queue}); - return respond(`Playing ${next.name}.`); + if (command.preserveStop && ['idle', 'stopped'].includes(player.getState().state)) { + engine.select(next, queue.stations); + playingStationRef.current = next; + setPlayingStation(next); + setNowPlaying(null); + desktopMediaRef.current?.refresh(); + return respond(`Selected ${next.name}.`); + } + const startPaused = command.preservePause && player.getState().state === 'paused'; + await playStation(next, {queue, startPaused, signal}); + const state = player.getState(); + const ok = playingStationRef.current && stationKey(playingStationRef.current) === stationKey(next) && state.ready && ['playing', 'paused'].includes(state.state); + return respond(`${startPaused ? 'Selected' : 'Playing'} ${next.name}.`, Boolean(ok)); }; + activeAlarmRef.current = alarmTui.activeAlarms.length > 0; + useEffect(() => { + if (playbackSession || !stdin.isTTY || !desktopPolicy.enabled || !controlSessionReady) return; + const media = startDesktopMediaControls({ + player, identity: store.filePath, policy: desktopPolicy, + status: () => ({owner: 'tui', playback: player.getState(), station: playingStationRef.current, queue: playbackQueueRef.current?.stations ?? []}), + command: (command, permitted) => dispatchOwnerCommand(command, () => permitted?.() !== false && !desktopSuspendedRef.current && !activeAlarmRef.current), + suspended: () => desktopSuspendedRef.current || activeAlarmRef.current + }); + desktopMediaRef.current = media; + return () => { media.close(); if (desktopMediaRef.current === media) desktopMediaRef.current = null; }; + }, [controlSessionReady, desktopPolicy, dispatchOwnerCommand, player, stdin, store, playbackSession]); + useEffect(() => { desktopMediaRef.current?.refresh(); }, [alarmTui.activeAlarms.length]); + useEffect(() => { - if (!stdin.isTTY || !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled) return; + if (!stdin.isTTY) return; + if (playbackSession) { + if (initialAgentCommand && (settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled) { + void playbackSession.call(initialAgentCommand).catch(reportActionError); + } + return; + } let disposed = false; let close: (() => Promise) | undefined; - void startRadioSession(command => agentHandlerRef.current(command)).then(async session => { + void startRadioSession(async (command, source) => { + const permitted = async () => { + if (source !== 'cli' && command.type !== 'status' && command.type !== 'alarm-preempt') await store.readFresh(); + if (disposed) throw new Error('RadioCLI control session is closed.'); + if (source !== 'cli' && !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled && command.type !== 'status' && command.type !== 'alarm-preempt') { + throw new Error('Agent controls are disabled.'); + } + return true; + }; + return dispatchOwnerCommand(command, permitted); + }, undefined, {queue: 'owner'}).then(async session => { if (disposed) { await session.close(); return; } close = session.close; - if (initialAgentCommand) await agentHandlerRef.current(initialAgentCommand); + setControlSessionReady(true); + if (initialAgentCommand && (settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled) await dispatchOwnerCommand(initialAgentCommand); }).catch(error => setMessage(error instanceof Error ? error.message : 'Agent controls could not start.')); return () => { disposed = true; + setControlSessionReady(false); if (close) void close(); }; - }, [initialAgentCommand, library.settings.agentControl?.enabled, stdin]); + }, [dispatchOwnerCommand, initialAgentCommand, stdin, store, playbackSession, reportActionError]); useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, + airPlaySupported, cancelPendingAutoSkip, copyStationUrl, openStationHomepage, @@ -1986,7 +2153,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe moveExploreCursorToCell: moveExploreMapCursorToCell, refreshProviderHealth, refreshAirPlayTargets: () => { - void refreshAirPlayTargets(); + void refreshAirPlayTargets().catch(reportActionError); }, resetLearnedTransportKeys, repairMcpIntegrations, @@ -1994,7 +2161,8 @@ export function App({store: providedStore, providers: providedProviders, alarmSe runSearch, saveLearnedTransportKey, screen, - settingsPage, + screenRef, + settingsPageRef, searchQuery, selectedRef, selectedStationForInput, @@ -2067,7 +2235,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe const hasActiveMicroPlayback = Boolean( footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused') ); - const statusMessage = message ?? persistenceWarning ?? presenceWarning; + const statusMessage = message ?? persistenceWarning ?? presenceWarning ?? (screen === 'search' ? stationContext.error ?? null : null); const microFooter = footerMessage ?? statusMessage ?? ( pageFooterOwnsCompactRow ? pageFooter @@ -2080,10 +2248,16 @@ 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, statusMessage, footerMessage, playbackFooter); - const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve); + const fullLegendRows = useMemo( + () => balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve), + [pageFooter, globalFooter, frameWidth, versionReserve] + ); const compactStatus = footerMessage ?? statusMessage ?? playbackFooter; const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0)); - const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve); + const compactLegendRows = useMemo( + () => balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve), + [pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve] + ); const microLegendWidth = Math.max(1,frameWidth-versionReserve); const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage ? microFooter @@ -2119,6 +2293,7 @@ export function App({store: providedStore, providers: providedProviders, alarmSe ) : null} source.status === 'unavailable').map(source => + source.provider === 'radio-browser' ? 'Radio Browser' : source.provider === 'radio-garden' ? 'Radio Garden' : source.provider + ))]; + return unavailable.length ? `Some sources are unavailable (${unavailable.join(', ')}). Showing available results.` : undefined; +} + function formatCountryStationsSubtitle(country: Country, loaded: number, hasMore: boolean): string { const total = country.stationCount.toLocaleString(); const loadedLabel = loaded.toLocaleString(); @@ -2367,3 +2549,12 @@ function mouseVisibleRows(screen: Screen, layout: TerminalLayout): number { if (['stations', 'nearby', 'explore', 'library'].includes(screen)) return layout.stationRows; return layout.contentRows; } + +function localSessionLibrary(library: LibraryState, platform: NodeJS.Platform, selectionRequired = true): LibraryState { + return platform === 'linux' && selectionRequired && library.settings.preferredBackend === 'airplay' + ? {...library, settings: {...library.settings, preferredBackend: 'auto'}} : library; +} + +function legacyDiscoveryPage(items: Station[], limit: number): DiscoveryPage { + return {items, exhausted: items.length < limit, sources: []}; +} diff --git a/src/ui/AppContent.tsx b/src/ui/AppContent.tsx index 4e704e0..14e8a9a 100644 --- a/src/ui/AppContent.tsx +++ b/src/ui/AppContent.tsx @@ -33,6 +33,7 @@ import type {AlarmTuiController} from './use-alarm-tui.js'; import type {SettingsPage} from './screen-items.js'; type AppContentProps = { + platform?: NodeJS.Platform; airPlayDevices: AirPlayDevice[]; airPlayCode: string; appVersion: string; @@ -72,6 +73,7 @@ type AppContentProps = { }; export function AppContent({ + platform = process.platform, airPlayDevices, airPlayCode, appVersion, @@ -116,6 +118,7 @@ export function AppContent({ if (screen === 'alarm-ringing') return ; return ( ; + return ; } return Unknown screen.; diff --git a/src/ui/__snapshots__/visual-baseline.test.tsx.snap b/src/ui/__snapshots__/visual-baseline.test.tsx.snap index a96083b..dcc3805 100644 --- a/src/ui/__snapshots__/visual-baseline.test.tsx.snap +++ b/src/ui/__snapshots__/visual-baseline.test.tsx.snap @@ -78,7 +78,7 @@ exports[`palette baseline > 'amber/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;255;176;0m2/371[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" @@ -134,7 +134,7 @@ exports[`palette baseline > 'blue/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;83;168;255m2/371[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" @@ -190,7 +190,7 @@ exports[`palette baseline > 'copper/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;208;135;112m2/371[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" @@ -246,7 +246,7 @@ exports[`palette baseline > 'coral/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;255;126;107m2/371[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" @@ -302,7 +302,7 @@ exports[`palette baseline > 'cyan/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;34;211;238m2/371[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" @@ -315,20 +315,20 @@ exports[`palette baseline > 'default receiver' 1`] = ` [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 [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 @@ -386,7 +386,7 @@ exports[`palette baseline > 'green/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;116;242;138m2/371[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" @@ -442,7 +442,7 @@ exports[`palette baseline > 'ice/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;185;246;255m2/371[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" @@ -498,7 +498,7 @@ exports[`palette baseline > 'lime/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;163;230;53m2/371[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" @@ -582,7 +582,7 @@ exports[`palette baseline > 'mono/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;208;208;208m2/371[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" @@ -666,7 +666,7 @@ exports[`palette baseline > 'rose/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;255;143;199m2/371[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" @@ -722,7 +722,7 @@ exports[`palette baseline > 'ruby/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;255;95;135m2/371[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" @@ -834,7 +834,7 @@ exports[`palette baseline > 'slate/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;159;180;207m2/371[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" @@ -890,7 +890,7 @@ exports[`palette baseline > 'teal/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;94;234;212m2/371[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" @@ -946,7 +946,7 @@ exports[`palette baseline > 'violet/stats' 1`] = ` [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│[38;2;170;180;194mActive days: [38;2;140;80;240m2/371[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" @@ -973,12 +973,12 @@ exports[`receiver baseline / compact / ascii > gallop-fine 1`] = ` KEXP 90.3 FM playing . vol 70 - .:-:. - :..:#*.-+. - .-+-:-: -# - .+:- ++*-:-@@-- - %*++*-. * - **+ + :-:: + .+-.++.++: + . :-*:: .%-. + .--. %%--%*-* + +#::. .: + ... Bjork - Joga " @@ -1053,12 +1053,12 @@ exports[`receiver baseline / compact / unicode > gallop-fine 1`] = ` KEXP 90.3 FM playing · vol 70 - ⢀⣀⣄⣀⡀ - ⣀⢀⡀⠰⡯⠟⢀⠦⢕⠄ - ⠠⡤⢖⠋⠉⡉⠉ ⢰⣼ - ⠐⢦⣀⡤ ⠳⣆⡧⠦⠤⠦⣿⣿⠲⣂ - ⢿⡺⢴⣌⠻⠚⠁ ⢼ - ⣬⢧⡎ + ⣀⡤⠤⣀ + ⢀⣤⣠⡀⠼⠞⠁⡜⠮⠃ + ⡀ ⠒⠋⢧⡄⡄ ⢀⣷⡃⡀ + ⠈⠓⠚⠁ ⣽⣯⡉⠩⣽⠟⠋⢧ + ⣘⣮⠉⠃⠈ ⠈⠃ + ⠈⠈⠁ Bjork - Joga " @@ -1147,20 +1147,20 @@ exports[`receiver baseline / full / ascii > gallop-fine 1`] = ` | KEXP 90.3 FM . SEATTLE, UNITED STATES PLAYING | | | | | -| .+-+- | -| +#*++%:+-#+ | -| :#:.*%: *-.+++: | -| #@@#*: .%-#*+ # | -| -%*--+-+#+.:-. :. #: . | -| :.-+*+: . :#.-* | -| :*::%#::%... :- +@%+ | -| *#*:. -* :%@: @#%%**+*%#--*@-++@- | -| *#@#+- -@% # ...--%@%: .#% | -| **%%*%+-. ++%@%%%-. #@- | -| .+*+:-*%##. *+- +@@ | -| :@+. .#.- + | -| *@%+. | -| -+-+-+-+. | +| :.-#*#:. | +| :+@#+*.:.##. | +| .%#--*. #*--:-: | +| .:++#+---.-#+:- -+..--:. | +| ..--#: . *::% | +| .-++@#:* + %%* | +| : :+. . .**# + . +@@.-+:*: | +| :-#+-: #@*-: :::+-#@* :@* | +| *#**#%*-.:-%@#*: :@@. | +| +%+ :*#** .: :#+ | +| -##: :. | +| -+-+#**. | +| | +| | | | | | | Bjork - Joga * Favorite | @@ -1763,20 +1763,20 @@ exports[`receiver baseline / full / unicode > gallop-fine 1`] = ` │ KEXP 90.3 FM · SEATTLE, UNITED STATES PLAYING │ │ │ │ │ -│ ⢀⣤⣠⣤⣄ │ -│ ⣤⢾⣓⡋⣰⣾⠉⠛⠙⢻⣤ │ -│ ⢠⣞⠃⠈⣱⡿⠃ ⢳⡆⠈⠹⠳⢦⡄ │ -│ ⠿⣿⣿⣶⠻⠉ ⠠⣽⠓⠿⢶⣆ ⢽ │ -│ ⣠⣾⠾⠙⠚⡙⠚⠳⠿⠳⠂⠘⠋⠁ ⠘⠂ ⣼⠃ ⠈ │ -│ ⣀⡀⣄⣤⣦⠞⠃ ⠁ ⠘⣳⠄⢨⡏ │ -│ ⠰⠟⠉⠉⣽⡟⠉⠰⣷⠄⢀⡀ ⢠⡆ ⢸⣿⣾⣅ │ -│ ⠻⢷⣩⣀⡀ ⣠⡏ ⠘⢿⣿⡄ ⣿⠿⣾⡿⣴⢶⡴⠾⢿⣗⣠⡌⢹⣿⠚⠛⠺⣿⠍ │ -│ ⠟⠿⣿⢾⠞⠋ ⢰⣿⣯ ⢯ ⢀⡀⢀⠙⠓⢿⣿⢿⠆ ⠈⣹⣷ │ -│ ⢼⠟⢿⣷⣜⣷⣤⣄⡀ ⠹⠞⢿⣿⣷⣷⡿⠋⠈ ⢽⣿⡆ │ -│ ⠈⠳⣇⣤⡈⠙⢹⣟⣹⢷⡀ ⠟⠛⠋ ⢩⣿⣿ │ -│ ⠘⣿⢇⡀ ⠈⠿⠂⠙ ⠛ │ -│ ⠻⣿⣷⣆⡀ │ -│ ⠓⠛⠓⠛⠙⠛⠙⠛⠁ │ +│ ⣀⢀⣠⣶⣴⣶⣀⡀ │ +│ ⢠⠗⣿⢯⣰⠟⠁⡁⠈⢻⣧⠄ │ +│ ⢀⣾⣏⣠⡤⠟⠁ ⢻⣦⣄⣈⠉⠲⡄ │ +│ ⢀⢠⣤⡦⣶⢦⣠⢤⣠⠄⠩⡟⠛⠃⢅ ⣠⠏⠈⠈⠙⠖⠘⠁ │ +│ ⢀⢀⣠⡤⠿⠉ ⠐ ⢶⣀⢠⣯ │ +│ ⠠⠴⠛⢚⣿⡟⠉⣖ ⣤ ⣾⣾⡏ │ +│ ⣀ ⣀⣤⡀ ⠈ ⠈⢳⣬⣛ ⢸ ⡀ ⣤⣿⣿⠁⠙⠛⢠⣦⡄ │ +│ ⠘⠙⠿⠞⠚⠉ ⢻⣿⣇⠸⡄ ⣀⢠⣀⣌⠙⣹⣿⢛ ⠘⣿⣥ │ +│ ⢹⣛⠻⠷⢽⣾⣴⣄⡀⠘⠋⢿⣿⡷⠟⠃ ⠘⣿⣿⡀ │ +│ ⠳⣾⡴ ⠉⢛⣞⠷⣦ ⠈⠉ ⠨⠿⡇ │ +│ ⠩⢯⣶⣀ ⠉⠁ │ +│ ⠴⠶⠴⠞⠿⠟⠷⠄ │ +│ │ +│ │ │ │ │ │ │ Bjork - Joga ★ Favorite │ @@ -3029,10 +3029,10 @@ exports[`receiver baseline / micro / ascii > braille-wave 1`] = ` exports[`receiver baseline / micro / ascii > gallop-fine 1`] = ` "KEXP 90.3 FM . playing - .-+- - :-::.:#: - -: .**-*++ - +#:. .. + --+. + .-+:..*. + -: .%*+++- + -. " `; @@ -3079,10 +3079,10 @@ exports[`receiver baseline / micro / unicode > braille-wave 1`] = ` exports[`receiver baseline / micro / unicode > gallop-fine 1`] = ` "KEXP 90.3 FM · playing - ⢀⢤⢖⢤ - ⣀⡔⠒⠒⠈⠡⣼⠉ - ⠲⠤ ⠈⢧⣖⢒⣲⠗⢣ - ⣘⣮⠃⠈ ⠈⠁ + ⣠⠖⣢⡀ + ⠠⠴⣊⠉⠁⠁⣴⢀ + ⠙⠊ ⠈⡿⢭⠛⠏⠹⡆ + ⠚⠂ " `; @@ -6606,7 +6606,7 @@ Local listening history . never leaves this machine |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 | +|Active days: 2/371 | | | |Less . More | +------------------------------------------------------------------------------------------------+" @@ -7670,7 +7670,7 @@ Local listening history . never leaves this machine |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 | +|Active days: 2/371 | | | |Less . More | +------------------------------------------------------------------------------------------------+" @@ -8734,7 +8734,7 @@ Local listening history · never leaves this machine │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 │ +│Active days: 2/371 │ │ │ │Less · More │ └────────────────────────────────────────────────────────────────────────────────────────────────┘" @@ -9798,7 +9798,7 @@ Local listening history · never leaves this machine │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 │ +│Active days: 2/371 │ │ │ │Less · More │ └────────────────────────────────────────────────────────────────────────────────────────────────┘" diff --git a/src/ui/airplay-settings.test.ts b/src/ui/airplay-settings.test.ts index f3a71ed..a7d334e 100644 --- a/src/ui/airplay-settings.test.ts +++ b/src/ui/airplay-settings.test.ts @@ -21,7 +21,7 @@ const device: AirPlayDevice = { describe('AirPlay settings helpers', () => { it('reports availability from detected playback backends', () => { expect(isAirPlayBackendAvailable(['mpv', 'airplay'])).toBe(true); - expect(airPlayAvailability(['mpv'], [device])).toMatchObject({ + expect(airPlayAvailability(['mpv'], [device], 'darwin')).toMatchObject({ ready: false, label: 'Not ready' }); @@ -30,16 +30,16 @@ describe('AirPlay settings helpers', () => { it('summarizes the selected receiver without an automatic target state', () => { expect(airPlayReceiverSettingValue(settings(), [device], ['airplay'])).toBe('Choose receiver... · 1 receiver'); expect(airPlayReceiverSettingValue(settings({preferredAirPlayDevice: device.id}), [device], ['airplay'])).toBe('Office · 1 receiver'); - expect(airPlayReceiverSettingValue(settings({preferredAirPlayDevice: device.id}), [{...device, local: true}], ['airplay'])).toBe('Office · this Mac'); + expect(airPlayReceiverSettingValue(settings({preferredAirPlayDevice: device.id}), [{...device, local: true}], ['airplay'], 'darwin')).toBe('Office · this Mac'); expect(airPlayReceiverSettingValue(settings({preferredAirPlayDevice: 'missing'}), [device], ['airplay'])).toBe('Saved receiver missing · 1 receiver'); expect(airPlayReceiverSettingValue(settings(), [], ['airplay'])).toBe('No receivers found'); - expect(airPlayReceiverSettingValue(settings({preferredAirPlayDevice: device.id}), [device], ['mpv'])).toBe('Office · playback unavailable'); + expect(airPlayReceiverSettingValue(settings({preferredAirPlayDevice: device.id}), [device], ['mpv'], 'darwin')).toBe('Office · playback unavailable'); }); it('formats receiver metadata for the picker list', () => { expect(selectedAirPlayDevice(settings({preferredAirPlayDevice: device.id}), [device])).toEqual(device); expect(airPlayDeviceDetail(device)).toBe('Sonos-5CAAFD0046D4.local:7000 · AirPlay 2 · no code'); - expect(airPlayDeviceDetail({...device, host: '192.168.1.27', local: true})).toBe('192.168.1.27:7000 · this Mac · use local output'); + expect(airPlayDeviceDetail({...device, host: '192.168.1.27', local: true}, 'darwin')).toBe('192.168.1.27:7000 · this Mac · use local output'); expect(airPlayDeviceDetail({...device, requiresPassword: true, airplay2: false})).toContain('code shown on receiver'); }); }); @@ -60,3 +60,16 @@ function settings(overrides: Partial = {}): AppSettings { ...overrides }; } + +describe('Linux receiver labels and unverified prerequisites', () => { + it('keeps Setup required visible after Linux opt-in when prerequisites are missing', () => { + expect(airPlayAvailability(['mpv'], [], 'linux')).toMatchObject({ready: false, label: 'Setup required', detail: expect.stringContaining('setup --airplay')}); + expect(airPlayReceiverSettingValue(settings({linuxAirPlayEnabled: true, preferredAirPlayDevice: device.id}), [device], ['mpv'], 'linux')).toBe('Setup required'); + }); + it('labels local Linux receivers as this device and retains macOS labels', () => { + expect(airPlayDeviceDetail({...device, local: true}, 'linux')).toContain('this device'); + expect(airPlayReceiverSettingValue(settings({preferredAirPlayDevice: device.id}), [{...device, local: true}], ['airplay'], 'linux')).toBe('Office · this device'); + expect(airPlayAvailability(['airplay'], [], 'linux')).toMatchObject({ready: false, label: 'Unverified'}); + expect(airPlayAvailability(['airplay'], [], 'darwin')).toMatchObject({ready: true, label: 'Ready'}); + }); +}); diff --git a/src/ui/airplay-settings.ts b/src/ui/airplay-settings.ts index 67abd95..a302e99 100644 --- a/src/ui/airplay-settings.ts +++ b/src/ui/airplay-settings.ts @@ -1,3 +1,4 @@ +import {airPlayLocalDeviceLabel} from '../player/backend-install.js'; import type {AirPlayDevice, AppSettings} from '../types.js'; export type AirPlayAvailability = { @@ -17,13 +18,15 @@ export function selectedAirPlayDevice(settings: AppSettings, devices: readonly A export function airPlayReceiverSettingValue( settings: AppSettings, devices: readonly AirPlayDevice[], - backends: readonly string[] + backends: readonly string[], + platform: NodeJS.Platform = process.platform ): string { + if (platform === 'linux' && !isAirPlayBackendAvailable(backends)) return 'Setup required'; const device = selectedAirPlayDevice(settings, devices); const count = airPlayDeviceCountLabel(devices.length); const playbackStatus = isAirPlayBackendAvailable(backends) ? undefined : 'playback unavailable'; if (device) { - return [device.name, device.local ? 'this Mac' : playbackStatus ?? count].join(' · '); + return [device.name, device.local ? airPlayLocalDeviceLabel(platform) : playbackStatus ?? count].join(' · '); } if (settings.preferredAirPlayDevice) { @@ -37,8 +40,9 @@ export function airPlayReceiverSettingValue( return playbackStatus ? `No receivers found · ${playbackStatus}` : 'No receivers found'; } -export function airPlayAvailability(backends: readonly string[], devices: readonly AirPlayDevice[]): AirPlayAvailability { +export function airPlayAvailability(backends: readonly string[], devices: readonly AirPlayDevice[], platform: NodeJS.Platform = process.platform): AirPlayAvailability { if (!isAirPlayBackendAvailable(backends)) { + if (platform === 'linux') return {ready: false, label: 'Setup required', detail: 'Run radiocli setup --airplay, then radiocli doctor to check FFmpeg, Avahi, and the optional sender.'}; const detail = devices.length > 0 ? 'RadioCLI can see receivers, but this install cannot start AirPlay yet. Run radiocli doctor.' : 'AirPlay is not ready on this install. Run radiocli doctor.'; @@ -51,6 +55,7 @@ export function airPlayAvailability(backends: readonly string[], devices: readon } if (devices.length === 0) { + if (platform === 'linux') return {ready: false, label: 'Unverified', detail: 'Refresh to find IPv4 receivers and verify experimental AirPlay. Select a receiver each session.'}; return { ready: true, label: 'Ready', @@ -65,9 +70,9 @@ export function airPlayAvailability(backends: readonly string[], devices: readon }; } -export function airPlayDeviceDetail(device: AirPlayDevice): string { +export function airPlayDeviceDetail(device: AirPlayDevice, platform: NodeJS.Platform = process.platform): string { if (device.local) { - return `${device.host}:${device.port} · this Mac · use local output`; + return `${device.host}:${device.port} · ${airPlayLocalDeviceLabel(platform)} · use local output`; } const security = device.requiresPassword ? 'code shown on receiver' : 'no code'; diff --git a/src/ui/alarm-tui-integration.test.tsx b/src/ui/alarm-tui-integration.test.tsx index dd48a4f..96eadb8 100644 --- a/src/ui/alarm-tui-integration.test.tsx +++ b/src/ui/alarm-tui-integration.test.tsx @@ -10,10 +10,13 @@ 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 {TuiSession} from '../agent/tui-session.js'; +import * as desktopMedia from '../platform/desktop-media.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 {appVersion} from '../version.js'; import type {Alarm, IcyNowPlaying, PlaybackState, Station} from '../types.js'; import type {AlarmTuiService, TuiActiveAlarm} from './alarm-tui-service.js'; import {App} from './App.js'; @@ -40,6 +43,7 @@ beforeEach(() => { vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv']); vi.spyOn(airplayDiscovery, 'discoverAirPlayDevices').mockResolvedValue([]); vi.spyOn(ProviderManager.prototype, 'health').mockResolvedValue({}); + vi.spyOn(ProviderManager.prototype, 'searchPage').mockImplementation(async function(this: ProviderManager, query, settings, options) { const items = await this.search(query, settings, options); return {items, exhausted: true, sources: []}; }); vi.spyOn(updates, 'checkForUpdate').mockImplementation(async ({currentVersion = '0.2.4'} = {}) => ({ checkedAt: new Date().toISOString(), currentVersion, updateAvailable: false })); @@ -130,8 +134,8 @@ function simulatedPlayer() { 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 play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (selected, url, options) => { + change({state: options?.startPaused ? 'paused' : 'playing', ready: true, stationName: selected.name, streamUrl: url}); }); const stop = vi.spyOn(PlayerController.prototype, 'stop').mockImplementation(async () => { change({state: 'idle', ready: false}); @@ -140,6 +144,10 @@ function simulatedPlayer() { change({volume: state.volume + delta}); return {ok: true}; }); + vi.spyOn(PlayerController.prototype, 'pause').mockImplementation(async () => { change({state: 'paused'}); return {ok: true}; }); + vi.spyOn(PlayerController.prototype, 'resume').mockImplementation(async () => { change({state: 'playing'}); return {ok: true}; }); + vi.spyOn(PlayerController.prototype, 'setVolume').mockImplementation(async volume => { change({volume}); return {ok: true}; }); + vi.spyOn(PlayerController.prototype, 'setMuted').mockImplementation(async muted => { change({muted}); 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); @@ -153,7 +161,291 @@ async function tuneLibrary(app: ReturnType): Promise { await settle(); } +describe('Receiver style cycling', () => { + it('wraps directly from Crossfade to Ultracode', async () => { + const {store, service} = fixture(); + store.updateSettings({receiverStyle: 'crossfade'}); + simulatedPlayer(); + const app = render(); + await settle(); + await tuneLibrary(app); + app.stdin.write('v'); + await settle(); + expect(store.settings().receiverStyle).toBe('ultracode'); + expect(app.lastFrame()).toContain('Receiver style: ultracode'); + app.unmount(); + }); +}); + +describe('Canceled station retries', () => { + it.each(['desktop Stop', 'CLI stop', 'TUI :stop', 'alarm preemption'] as const)('cancels a broken-stream retry on %s while the backend is idle', async action => { + const {store, service} = fixture(); + store.updateSettings({skipBrokenStreams: true}); + const broken = {...station, id: 'broken', name: 'Broken station'}; + const next = {...station, id: 'next', name: 'Next station'}; + vi.spyOn(desktopMedia, 'desktopMediaPolicy').mockReturnValue({enabled: true, preference: 'auto', reason: 'Verified Quattro', mpvArgs: []}); + const start = vi.spyOn(desktopMedia, 'startDesktopMediaControls').mockReturnValue({close: vi.fn(), refresh: vi.fn()}); + const player = simulatedPlayer(); + const app = render(); + await settle(); + const desktop = start.mock.calls[0]![0]; + const handle = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + await tuneLibrary(app); + player.play.mockClear(); + vi.mocked(ProviderManager.prototype.resolve).mockRejectedValueOnce(new Error('Stream unavailable.')); + let tuning!: Promise; + await act(async () => { tuning = handle({type: 'play', station: broken, queue: [broken, next]}, 'cli'); void tuning.catch(() => undefined); }); + expect(player.state().state).toBe('idle'); + expect(app.lastFrame()).toContain('Skipping to Next station.'); + if (action === 'TUI :stop') { + app.stdin.write(':stop\r'); + await settle(); + } + await act(async () => { + const stopped = action === 'desktop Stop' + ? await desktop.command({type: 'desktop-transport', action: 'stop'}) + : await handle({type: action === 'CLI stop' ? 'stop' : action === 'TUI :stop' ? 'status' : 'alarm-preempt'}, 'cli'); + expect(stopped.status.station).toEqual(action === 'desktop Stop' || action === 'TUI :stop' ? station : null); + }); + await settle(1100); + if (action === 'TUI :stop') await tuning; + else await expect(tuning).rejects.toThrow('canceled'); + expect(player.play).not.toHaveBeenCalled(); + expect(player.state().state).toBe('idle'); + expect(desktop.suspended()).toBe(action === 'alarm preemption'); + + await act(async () => { + if (action === 'desktop Stop') await desktop.command({type: 'desktop-transport', action: 'play'}); + else await handle({type: 'play', station}, 'cli'); + }); + expect(player.play).toHaveBeenCalledExactlyOnceWith(station, station.streamUrl, expect.objectContaining({signal: expect.any(AbortSignal)})); + expect(desktop.suspended()).toBe(false); + app.unmount(); + }); + + it.each(['desktop Stop', 'CLI stop', 'TUI :stop', 'alarm preemption'] as const)('invalidates an unresolved interactive tune on %s', async action => { + const {store, service} = fixture(); + store.updateSettings({skipBrokenStreams: true}); + vi.spyOn(desktopMedia, 'desktopMediaPolicy').mockReturnValue({enabled: true, preference: 'auto', reason: 'Verified Quattro', mpvArgs: []}); + const start = vi.spyOn(desktopMedia, 'startDesktopMediaControls').mockReturnValue({close: vi.fn(), refresh: vi.fn()}); + const player = simulatedPlayer(); + let resolveTune: (() => void) | undefined; + vi.mocked(ProviderManager.prototype.resolve).mockImplementationOnce(async selected => { + await new Promise(resolve => { resolveTune = resolve; }); + return {url: selected.streamUrl!}; + }); + const app = render(); + await settle(); + const desktop = start.mock.calls[0]![0]; + const handle = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + await tuneLibrary(app); + expect(resolveTune).toBeDefined(); + if (action === 'TUI :stop') { + app.stdin.write(':stop\r'); + await settle(); + } + await act(async () => { + if (action === 'desktop Stop') await desktop.command({type: 'desktop-transport', action: 'stop'}); + else if (action !== 'TUI :stop') await handle({type: action === 'CLI stop' ? 'stop' : 'alarm-preempt'}, 'cli'); + resolveTune!(); + }); + await settle(1100); + expect(player.play).not.toHaveBeenCalled(); + expect(player.state().state).toBe('idle'); + expect(desktop.suspended()).toBe(action === 'alarm preemption'); + app.unmount(); + }); + + it('ignores an old retry when the sleep timer invalidates its tune request', async () => { + const {store, service} = fixture(); + store.updateSettings({skipBrokenStreams: true}); + const next = {...station, id: 'next', name: 'Next station'}; + const player = simulatedPlayer(); + const app = render(); + await settle(); + const handle = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + await tuneLibrary(app); + app.stdin.write(':sleep 1\r'); + await settle(); + await settle(59_900); + player.play.mockClear(); + vi.mocked(ProviderManager.prototype.resolve).mockRejectedValueOnce(new Error('Stream unavailable.')); + let tuning!: Promise; + await act(async () => { tuning = handle({type: 'play', station, queue: [station, next]}, 'cli'); }); + await settle(1100); + await tuning; + expect(player.play).not.toHaveBeenCalled(); + expect(player.state().state).toBe('idle'); + app.unmount(); + }); + + it('still skips a failed station automatically and lets a deliberate tune replace a later retry', async () => { + const {store, service} = fixture(); + store.updateSettings({skipBrokenStreams: true}); + const next = {...station, id: 'next', name: 'Next station'}; + const player = simulatedPlayer(); + const app = render(); + await settle(); + const handle = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + vi.mocked(ProviderManager.prototype.resolve).mockRejectedValueOnce(new Error('Stream unavailable.')); + let tuning!: Promise; + await act(async () => { tuning = handle({type: 'play', station, queue: [station, next]}, 'cli'); }); + await settle(1100); + await tuning; + expect(player.play).toHaveBeenCalledExactlyOnceWith(next, next.streamUrl, expect.objectContaining({signal: expect.any(AbortSignal)})); + player.play.mockClear(); + vi.mocked(ProviderManager.prototype.resolve).mockRejectedValueOnce(new Error('Stream unavailable.')); + let replaced!: Promise; + let cancellation!: Promise; + await act(async () => { + replaced = handle({type: 'play', station, queue: [station, next]}, 'cli'); + cancellation = expect(replaced).rejects.toMatchObject({name: 'AbortError'}); + }); + await act(async () => { await handle({type: 'play', station}, 'cli'); }); + await cancellation; + await settle(1100); + expect(player.play).toHaveBeenCalledExactlyOnceWith(station, station.streamUrl, expect.objectContaining({signal: expect.any(AbortSignal)})); + app.unmount(); + }); +}); + describe('Persistence failure boundaries', () => { + it('rechecks agent consent when an accepted command reaches the shared owner queue', async () => { + const {store, service} = fixture(); + store.updateSettings({agentControl: {...store.snapshot().settings.agentControl!, enabled: true}}); + vi.spyOn(desktopMedia, 'desktopMediaPolicy').mockReturnValue({enabled: true, preference: 'auto', reason: 'Verified Quattro', mpvArgs: []}); + vi.spyOn(desktopMedia, 'startDesktopMediaControls').mockReturnValue({close: vi.fn(), refresh: vi.fn()}); + simulatedPlayer(); + const app = render(); + await settle(); + const [agent, , options] = vi.mocked(session.startRadioSession).mock.calls[0]!; + expect(options).toEqual({queue: 'owner'}); + await act(async () => { + let release: (() => void) | undefined; + vi.mocked(ProviderManager.prototype.resolve).mockImplementationOnce(async next => { + await new Promise(resolve => { release = resolve; }); + return {url: next.streamUrl!}; + }); + const busy = agent({type: 'play', station}); + for (let count = 0; count < 12; count++) await Promise.resolve(); + expect(release).toBeDefined(); + const disabled = agent({type: 'update-settings', settings: {agentControl: {...store.snapshot().settings.agentControl!, enabled: false}}}); + const pause = expect(agent({type: 'pause'})).rejects.toThrow('Agent controls are disabled.'); + release!(); + await Promise.all([busy, disabled, pause]); + expect(PlayerController.prototype.pause).not.toHaveBeenCalled(); + expect((await agent({type: 'status'})).status.playback.state).toBe('playing'); + }); + app.unmount(); + }); + + it.each([ + ['Pause then Play', ['pause', 'play'], 'kexp'], + ['two toggles', ['toggle', 'toggle'], 'kexp'], + ['Stop then Play', ['stop', 'play'], 'kexp'], + ['Stop then Next then Play', ['stop', 'next', 'play'], 'second'] + ] as const)('resolves desktop %s after an in-flight agent tune', async (_label, actions, expectedStation) => { + const {store, service} = fixture(); + store.updateSettings({agentControl: {...store.snapshot().settings.agentControl!, enabled: true}}); + const second = {...station, id: 'second', name: 'Second'}; + vi.spyOn(desktopMedia, 'desktopMediaPolicy').mockReturnValue({enabled: true, preference: 'auto', reason: 'Verified Quattro', mpvArgs: []}); + const start = vi.spyOn(desktopMedia, 'startDesktopMediaControls').mockReturnValue({close: vi.fn(), refresh: vi.fn()}); + simulatedPlayer(); + const app = render(); + await settle(); + const desktop = start.mock.calls[0]![0]; + const agent = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + await act(async () => { + await agent({type: 'play', station, queue: [station, second]}); + let release: (() => void) | undefined; + vi.mocked(ProviderManager.prototype.resolve).mockImplementationOnce(async next => { + await new Promise(resolve => { release = resolve; }); + return {url: next.streamUrl!}; + }); + const busyAgent = agent({type: 'play', station, queue: [station, second]}); + for (let count = 0; count < 12; count++) await Promise.resolve(); + expect(release).toBeDefined(); + const requests = actions.map(action => desktop.command({type: 'desktop-transport', action})); + release!(); + const results = await Promise.allSettled([busyAgent, ...requests]); + if ((actions as readonly string[]).includes('stop')) expect(results[0]).toMatchObject({status: 'rejected', reason: expect.objectContaining({name: 'AbortError'})}); + else expect(results[0]).toMatchObject({status: 'fulfilled', value: {ok: true}}); + expect(results.slice(1).every(result => result.status === 'fulfilled' && result.value.ok)).toBe(true); + expect((await agent({type: 'status'})).status).toMatchObject({station: {id: expectedStation}, playback: {state: 'playing'}}); + }); + app.unmount(); + }); + + it('allows local CLI playback with MCP and desktop media disabled', async () => { + const {store, service} = fixture(); + vi.spyOn(desktopMedia, 'desktopMediaPolicy').mockReturnValue({enabled: false, preference: 'off', reason: 'Off', mpvArgs: []}); + const player = simulatedPlayer(); + const app = render(); + await settle(); + const handle = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + await expect(handle({type: 'play', station}, 'agent')).rejects.toThrow('disabled'); + await act(async () => { + const result = await handle({type: 'play', station}, 'cli'); + expect(result.ok).toBe(true); + expect(result.status.owner).toBe('tui'); + await handle({type: 'pause'}, 'cli'); + }); + expect(player.state().state).toBe('paused'); + expect(store.snapshot().settings.agentControl?.enabled).toBe(false); + app.unmount(); + }); + + it('supports desktop queue controls independently of agent consent and yields to alarms', async () => { + const {store, service} = fixture(); + const second = {...station, id: 'second', name: 'Second station'}; + store.toggleFavorite(second); + vi.spyOn(desktopMedia, 'desktopMediaPolicy').mockReturnValue({enabled: true, preference: 'auto', reason: 'Verified Quattro', mpvArgs: []}); + const close = vi.fn(); + const start = vi.spyOn(desktopMedia, 'startDesktopMediaControls').mockReturnValue({close, refresh: vi.fn()}); + const player = simulatedPlayer(); + const app = render(); + await settle(); + const desktop = start.mock.calls[0]![0]; + const agent = vi.mocked(session.startRadioSession).mock.calls[0]![0]; + await expect(agent({type: 'play', station})).rejects.toThrow('disabled'); + await tuneLibrary(app); + const expectedNext = player.play.mock.calls.at(-1)![0].id === station.id ? second : station; + await act(async () => { + await desktop.command({type: 'desktop-transport', action: 'pause'}); + const result = await desktop.command({type: 'desktop-transport', action: 'next'}); + expect(result.ok).toBe(true); + }); + expect(player.play).toHaveBeenLastCalledWith(expectedNext, expectedNext.streamUrl, {startPaused: true, signal: expect.any(AbortSignal)}); + expect(player.state().state).toBe('paused'); + await act(async () => { + const stopped = await desktop.command({type: 'desktop-transport', action: 'stop'}); + expect(stopped.status.station).toEqual(expectedNext); + const playCount = player.play.mock.calls.length; + await desktop.command({type: 'desktop-transport', action: 'next'}); + expect(player.play.mock.calls).toHaveLength(playCount); + const resumed = await desktop.command({type: 'desktop-transport', action: 'play'}); + expect(resumed.status.playback.state).toBe('playing'); + await desktop.command({type: 'set-muted', muted: true}); + await desktop.command({type: 'set-volume', volume: 37, unmute: true}); + expect(player.state()).toMatchObject({volume: 37, muted: false}); + }); + await act(async () => { await agent({type: 'alarm-preempt'}); }); + expect(desktop.suspended()).toBe(true); + await expect(desktop.command({type: 'desktop-transport', action: 'next'})).rejects.toThrow('suspended'); + app.unmount(); + expect(close).toHaveBeenCalledOnce(); + }); + + it('does not register desktop media if another process owns the control session', async () => { + const {store, service} = fixture(); + vi.spyOn(desktopMedia, 'desktopMediaPolicy').mockReturnValue({enabled: true, preference: 'auto', reason: 'Verified Quattro', mpvArgs: []}); + const start = vi.spyOn(desktopMedia, 'startDesktopMediaControls'); + vi.mocked(session.startRadioSession).mockRejectedValueOnce(new Error('Another playback owner is active.')); + const app = render(); + await settle(); + expect(start).not.toHaveBeenCalled(); + app.unmount(); + }); 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; @@ -363,6 +655,50 @@ describe('Persistence failure boundaries', () => { }); describe('Settings TUI integration', () => { + it.each([true, false])('keeps recovered playback controllable during an update (success: %s) and closes the host on quit', async ok => { + const {store, service} = fixture(); + store.updateSettings({automaticUpdateChecks: false, agentControl: {...store.settings().agentControl!, enabled: true}}); + store.updateCheckState({checkedAt: new Date().toISOString(), currentVersion: '0.2.4', latestVersion: '9.0.0', updateAvailable: true}); + const status: session.RadioSessionStatus = { + owner: 'tui', station, queue: [station], + playback: {backend: 'mpv', state: 'playing', volume: 70, muted: false, ready: true}, + tui: {tuningStation: null, devices: [], selectionRequired: false, desktopMediaCompatible: true, + diagnostics: {backend: 'mpv', availableBackends: ['mpv'], preferredBackend: 'mpv', active: true, ready: true, volume: 70, muted: false}} + }; + const call = vi.fn(async (_command: session.RadioSessionCommand) => ({ok: true, message: 'ok', status: structuredClone(status)})); + vi.spyOn(session, 'ensureRadioSession').mockResolvedValue({call, status: async () => structuredClone(status)}); + const playbackSession = await TuiSession.connect(); + let finish!: (result: updates.UpdateInstallResult) => void; + const install = vi.spyOn(updates, 'installUpdate').mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const configure = vi.fn(async () => []); + const app = render(); + try { + await settle(); + app.stdin.write('b'); await settle(); + app.stdin.write('9'); await settle(); + await moveDown(app, 6); + app.stdin.write('\r'); await settle(); + expect(app.lastFrame()).toContain('Settings › Updates'); + await moveDown(app, 1); + app.stdin.write('\r'); await settle(); + expect(install).toHaveBeenCalledOnce(); + expect(playbackSession.status.playback.state).toBe('playing'); + app.stdin.write('\r'); await settle(); + expect(install).toHaveBeenCalledOnce(); + expect(app.lastFrame()).toContain('already running'); + await act(async () => { finish({ok, command: 'fixture-update', output: ok ? '' : 'fixture failure'}); }); + await settle(); + expect(app.lastFrame()).toContain(ok ? 'restart MCP clients' : 'Update install failed'); + expect(configure).toHaveBeenCalledTimes(ok ? 1 : 0); + expect(call.mock.calls.some(([command]) => ['stop', 'tui-detach'].includes(command.type))).toBe(false); + expect(playbackSession.status).toMatchObject({station, queue: [station], playback: {state: 'playing'}}); + app.stdin.write('q'); await settle(); + expect(call).toHaveBeenCalledWith({type: 'tui-detach', clientId: playbackSession.clientId}); + } finally { + await act(async () => { await playbackSession.close(); app.unmount(); }); + } + }); + it('checks once at launch and shows the available version beside the installed version', async () => { vi.stubEnv('CI', 'false'); vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '0'); @@ -378,7 +714,7 @@ describe('Settings TUI integration', () => { await settle(); expect(updateChecker).toHaveBeenCalledOnce(); - expect(app.lastFrame()).toContain('v0.3.0 available v0.2.4'); + expect(app.lastFrame()).toContain(`v0.3.0 available v${appVersion()}`); app.unmount(); }); diff --git a/src/ui/app-state.ts b/src/ui/app-state.ts index 31e30a3..ef3ec00 100644 --- a/src/ui/app-state.ts +++ b/src/ui/app-state.ts @@ -1,4 +1,4 @@ -import type {AppSettings, Country, MediaKeyBindings, PlaybackState, Screen, Station} from '../types.js'; +import type {AppSettings, Country, DiscoverySourceStatus, MediaKeyBindings, PlaybackState, Screen, Station} from '../types.js'; import {isPlaybackOutputError} from '../player/player-controller.js'; import {isProviderUnavailableError} from '../providers/radio-browser.js'; import type {TopTab} from './components/TopTabs.js'; @@ -14,6 +14,8 @@ export type StationContext = { query?: string; country?: Country; hasMore?: boolean; + nextCursor?: string; + sources?: DiscoverySourceStatus[]; }; export type StationContextKey = 'explore' | 'stations' | 'search' | 'nearby' | 'library'; @@ -37,6 +39,8 @@ export type NavigationOptions = { }; export type PlayStationOptions = { + signal?: AbortSignal; + startPaused?: boolean; openNowPlaying?: boolean; queue?: PlaybackQueue; }; diff --git a/src/ui/bidi-ranges.ts b/src/ui/bidi-ranges.ts new file mode 100644 index 0000000..ef432db --- /dev/null +++ b/src/ui/bidi-ranges.ts @@ -0,0 +1,45 @@ +// Generated by scripts/generate-bidi-ranges.mjs; do not edit. +// Unicode 17.0.0: https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt +/* +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2026 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. +*/ +export const strongDirectionPattern = /[\u{41}-\u{5a}\u{61}-\u{7a}\u{aa}\u{b5}\u{ba}\u{c0}-\u{d6}\u{d8}-\u{f6}\u{f8}-\u{2b8}\u{2bb}-\u{2c1}\u{2d0}-\u{2d1}\u{2e0}-\u{2e4}\u{2ee}\u{370}-\u{373}\u{376}-\u{37d}\u{37f}-\u{383}\u{386}\u{388}-\u{3f5}\u{3f7}-\u{482}\u{48a}-\u{589}\u{58b}-\u{58c}\u{590}\u{5be}\u{5c0}\u{5c3}\u{5c6}\u{5c8}-\u{5ff}\u{608}\u{60b}\u{60d}\u{61b}-\u{64a}\u{66d}-\u{66f}\u{671}-\u{6d5}\u{6e5}-\u{6e6}\u{6ee}-\u{6ef}\u{6fa}-\u{710}\u{712}-\u{72f}\u{74b}-\u{7a5}\u{7b1}-\u{7ea}\u{7f4}-\u{7f5}\u{7fa}-\u{7fc}\u{7fe}-\u{815}\u{81a}\u{824}\u{828}\u{82e}-\u{858}\u{85c}-\u{88f}\u{892}-\u{896}\u{8a0}-\u{8c9}\u{903}-\u{939}\u{93b}\u{93d}-\u{940}\u{949}-\u{94c}\u{94e}-\u{950}\u{958}-\u{961}\u{964}-\u{980}\u{982}-\u{9bb}\u{9bd}-\u{9c0}\u{9c5}-\u{9cc}\u{9ce}-\u{9e1}\u{9e4}-\u{9f1}\u{9f4}-\u{9fa}\u{9fc}-\u{9fd}\u{9ff}-\u{a00}\u{a03}-\u{a3b}\u{a3d}-\u{a40}\u{a43}-\u{a46}\u{a49}-\u{a4a}\u{a4e}-\u{a50}\u{a52}-\u{a6f}\u{a72}-\u{a74}\u{a76}-\u{a80}\u{a83}-\u{abb}\u{abd}-\u{ac0}\u{ac6}\u{ac9}-\u{acc}\u{ace}-\u{ae1}\u{ae4}-\u{af0}\u{af2}-\u{af9}\u{b00}\u{b02}-\u{b3b}\u{b3d}-\u{b3e}\u{b40}\u{b45}-\u{b4c}\u{b4e}-\u{b54}\u{b57}-\u{b61}\u{b64}-\u{b81}\u{b83}-\u{bbf}\u{bc1}-\u{bcc}\u{bce}-\u{bf2}\u{bfb}-\u{bff}\u{c01}-\u{c03}\u{c05}-\u{c3b}\u{c3d}\u{c41}-\u{c45}\u{c49}\u{c4e}-\u{c54}\u{c57}-\u{c61}\u{c64}-\u{c77}\u{c7f}-\u{c80}\u{c82}-\u{cbb}\u{cbd}-\u{ccb}\u{cce}-\u{ce1}\u{ce4}-\u{cff}\u{d02}-\u{d3a}\u{d3d}-\u{d40}\u{d45}-\u{d4c}\u{d4e}-\u{d61}\u{d64}-\u{d80}\u{d82}-\u{dc9}\u{dcb}-\u{dd1}\u{dd5}\u{dd7}-\u{e30}\u{e32}-\u{e33}\u{e3b}-\u{e3e}\u{e40}-\u{e46}\u{e4f}-\u{eb0}\u{eb2}-\u{eb3}\u{ebd}-\u{ec7}\u{ecf}-\u{f17}\u{f1a}-\u{f34}\u{f36}\u{f38}\u{f3e}-\u{f70}\u{f7f}\u{f85}\u{f88}-\u{f8c}\u{f98}\u{fbd}-\u{fc5}\u{fc7}-\u{102c}\u{1031}\u{1038}\u{103b}-\u{103c}\u{103f}-\u{1057}\u{105a}-\u{105d}\u{1061}-\u{1070}\u{1075}-\u{1081}\u{1083}-\u{1084}\u{1087}-\u{108c}\u{108e}-\u{109c}\u{109e}-\u{135c}\u{1360}-\u{138f}\u{139a}-\u{13ff}\u{1401}-\u{167f}\u{1681}-\u{169a}\u{169d}-\u{1711}\u{1715}-\u{1731}\u{1734}-\u{1751}\u{1754}-\u{1771}\u{1774}-\u{17b3}\u{17b6}\u{17be}-\u{17c5}\u{17c7}-\u{17c8}\u{17d4}-\u{17da}\u{17dc}\u{17de}-\u{17ef}\u{17fa}-\u{17ff}\u{1810}-\u{1884}\u{1887}-\u{18a8}\u{18aa}-\u{191f}\u{1923}-\u{1926}\u{1929}-\u{1931}\u{1933}-\u{1938}\u{193c}-\u{193f}\u{1941}-\u{1943}\u{1946}-\u{19dd}\u{1a00}-\u{1a16}\u{1a19}-\u{1a1a}\u{1a1c}-\u{1a55}\u{1a57}\u{1a5f}\u{1a61}\u{1a63}-\u{1a64}\u{1a6d}-\u{1a72}\u{1a7d}-\u{1a7e}\u{1a80}-\u{1aaf}\u{1ade}-\u{1adf}\u{1aec}-\u{1aff}\u{1b04}-\u{1b33}\u{1b35}\u{1b3b}\u{1b3d}-\u{1b41}\u{1b43}-\u{1b6a}\u{1b74}-\u{1b7f}\u{1b82}-\u{1ba1}\u{1ba6}-\u{1ba7}\u{1baa}\u{1bae}-\u{1be5}\u{1be7}\u{1bea}-\u{1bec}\u{1bee}\u{1bf2}-\u{1c2b}\u{1c34}-\u{1c35}\u{1c38}-\u{1ccf}\u{1cd3}\u{1ce1}\u{1ce9}-\u{1cec}\u{1cee}-\u{1cf3}\u{1cf5}-\u{1cf7}\u{1cfa}-\u{1dbf}\u{1e00}-\u{1fbc}\u{1fbe}\u{1fc2}-\u{1fcc}\u{1fd0}-\u{1fdc}\u{1fe0}-\u{1fec}\u{1ff0}-\u{1ffc}\u{1fff}\u{200e}-\u{200f}\u{2071}-\u{2073}\u{207f}\u{208f}-\u{209f}\u{20f1}-\u{20ff}\u{2102}\u{2107}\u{210a}-\u{2113}\u{2115}\u{2119}-\u{211d}\u{2124}\u{2126}\u{2128}\u{212a}-\u{212d}\u{212f}-\u{2139}\u{213c}-\u{213f}\u{2145}-\u{2149}\u{214e}-\u{214f}\u{2160}-\u{2188}\u{218c}-\u{218f}\u{2336}-\u{237a}\u{2395}\u{242a}-\u{243f}\u{244b}-\u{245f}\u{249c}-\u{24e9}\u{26ac}\u{2800}-\u{28ff}\u{2b74}-\u{2b75}\u{2c00}-\u{2ce4}\u{2ceb}-\u{2cee}\u{2cf2}-\u{2cf8}\u{2d00}-\u{2d7e}\u{2d80}-\u{2ddf}\u{2e5e}-\u{2e7f}\u{2e9a}\u{2ef4}-\u{2eff}\u{2fd6}-\u{2fef}\u{3005}-\u{3007}\u{3021}-\u{3029}\u{302e}-\u{302f}\u{3031}-\u{3035}\u{3038}-\u{303c}\u{3040}-\u{3098}\u{309d}-\u{309f}\u{30a1}-\u{30fa}\u{30fc}-\u{31bf}\u{31e6}-\u{31ee}\u{31f0}-\u{321c}\u{321f}-\u{324f}\u{3260}-\u{327b}\u{327f}-\u{32b0}\u{32c0}-\u{32cb}\u{32d0}-\u{3376}\u{337b}-\u{33dd}\u{33e0}-\u{33fe}\u{3400}-\u{4dbf}\u{4e00}-\u{a48f}\u{a4c7}-\u{a60c}\u{a610}-\u{a66e}\u{a680}-\u{a69d}\u{a6a0}-\u{a6ef}\u{a6f2}-\u{a6ff}\u{a722}-\u{a787}\u{a789}-\u{a801}\u{a803}-\u{a805}\u{a807}-\u{a80a}\u{a80c}-\u{a824}\u{a827}\u{a82d}-\u{a837}\u{a83a}-\u{a873}\u{a878}-\u{a8c3}\u{a8c6}-\u{a8df}\u{a8f2}-\u{a8fe}\u{a900}-\u{a925}\u{a92e}-\u{a946}\u{a952}-\u{a97f}\u{a983}-\u{a9b2}\u{a9b4}-\u{a9b5}\u{a9ba}-\u{a9bb}\u{a9be}-\u{a9e4}\u{a9e6}-\u{aa28}\u{aa2f}-\u{aa30}\u{aa33}-\u{aa34}\u{aa37}-\u{aa42}\u{aa44}-\u{aa4b}\u{aa4d}-\u{aa7b}\u{aa7d}-\u{aaaf}\u{aab1}\u{aab5}-\u{aab6}\u{aab9}-\u{aabd}\u{aac0}\u{aac2}-\u{aaeb}\u{aaee}-\u{aaf5}\u{aaf7}-\u{ab69}\u{ab6c}-\u{abe4}\u{abe6}-\u{abe7}\u{abe9}-\u{abec}\u{abee}-\u{fb1d}\u{fb1f}-\u{fb28}\u{fb2a}-\u{fbc2}\u{fbd3}-\u{fd3d}\u{fd50}-\u{fd8f}\u{fd92}-\u{fdc7}\u{fdf0}-\u{fdfc}\u{fe1a}-\u{fe1f}\u{fe53}\u{fe67}\u{fe6c}-\u{fefe}\u{ff00}\u{ff21}-\u{ff3a}\u{ff41}-\u{ff5a}\u{ff66}-\u{ffdf}\u{ffe7}\u{ffef}\u{10000}-\u{10100}\u{10102}-\u{1013f}\u{1018d}-\u{1018f}\u{1019d}-\u{1019f}\u{101a1}-\u{101fc}\u{101fe}-\u{102df}\u{102fc}-\u{10375}\u{1037b}-\u{1091e}\u{10920}-\u{10a00}\u{10a04}\u{10a07}-\u{10a0b}\u{10a10}-\u{10a37}\u{10a3b}-\u{10a3e}\u{10a40}-\u{10ae4}\u{10ae7}-\u{10b38}\u{10b40}-\u{10d23}\u{10d28}-\u{10d2f}\u{10d3a}-\u{10d3f}\u{10d4a}-\u{10d68}\u{10d6f}-\u{10e5f}\u{10e7f}-\u{10eaa}\u{10ead}-\u{10ecf}\u{10ed9}-\u{10ef9}\u{10f00}-\u{10f45}\u{10f51}-\u{10f81}\u{10f86}-\u{11000}\u{11002}-\u{11037}\u{11047}-\u{11051}\u{11066}-\u{1106f}\u{11071}-\u{11072}\u{11075}-\u{1107e}\u{11082}-\u{110b2}\u{110b7}-\u{110b8}\u{110bb}-\u{110c1}\u{110c3}-\u{110ff}\u{11103}-\u{11126}\u{1112c}\u{11135}-\u{11172}\u{11174}-\u{1117f}\u{11182}-\u{111b5}\u{111bf}-\u{111c8}\u{111cd}-\u{111ce}\u{111d0}-\u{1122e}\u{11232}-\u{11233}\u{11235}\u{11238}-\u{1123d}\u{1123f}-\u{11240}\u{11242}-\u{112de}\u{112e0}-\u{112e2}\u{112eb}-\u{112ff}\u{11302}-\u{1133a}\u{1133d}-\u{1133f}\u{11341}-\u{11365}\u{1136d}-\u{1136f}\u{11375}-\u{113ba}\u{113c1}-\u{113cd}\u{113cf}\u{113d1}\u{113d3}-\u{113e0}\u{113e3}-\u{11437}\u{11440}-\u{11441}\u{11445}\u{11447}-\u{1145d}\u{1145f}-\u{114b2}\u{114b9}\u{114bb}-\u{114be}\u{114c1}\u{114c4}-\u{115b1}\u{115b6}-\u{115bb}\u{115be}\u{115c1}-\u{115db}\u{115de}-\u{11632}\u{1163b}-\u{1163c}\u{1163e}\u{11641}-\u{1165f}\u{1166d}-\u{116aa}\u{116ac}\u{116ae}-\u{116af}\u{116b6}\u{116b8}-\u{1171c}\u{1171e}\u{11720}-\u{11721}\u{11726}\u{1172c}-\u{1182e}\u{11838}\u{1183b}-\u{1193a}\u{1193d}\u{1193f}-\u{11942}\u{11944}-\u{119d3}\u{119d8}-\u{119d9}\u{119dc}-\u{119df}\u{119e1}-\u{11a00}\u{11a07}-\u{11a08}\u{11a0b}-\u{11a32}\u{11a39}-\u{11a3a}\u{11a3f}-\u{11a46}\u{11a48}-\u{11a50}\u{11a57}-\u{11a58}\u{11a5c}-\u{11a89}\u{11a97}\u{11a9a}-\u{11b5f}\u{11b61}\u{11b65}\u{11b67}-\u{11c2f}\u{11c37}\u{11c3e}-\u{11c91}\u{11ca8}-\u{11ca9}\u{11cb1}\u{11cb4}\u{11cb7}-\u{11d30}\u{11d37}-\u{11d39}\u{11d3b}\u{11d3e}\u{11d46}\u{11d48}-\u{11d8f}\u{11d92}-\u{11d94}\u{11d96}\u{11d98}-\u{11ef2}\u{11ef5}-\u{11eff}\u{11f02}-\u{11f35}\u{11f3b}-\u{11f3f}\u{11f41}\u{11f43}-\u{11f59}\u{11f5b}-\u{11fd4}\u{11ff2}-\u{1343f}\u{13441}-\u{13446}\u{13456}-\u{1611d}\u{1612a}-\u{1612c}\u{16130}-\u{16aef}\u{16af5}-\u{16b2f}\u{16b37}-\u{16f4e}\u{16f50}-\u{16f8e}\u{16f93}-\u{16fe1}\u{16fe3}\u{16fe5}-\u{1bc9c}\u{1bc9f}\u{1bca4}-\u{1cbff}\u{1ccd6}-\u{1ccef}\u{1ccfd}-\u{1ccff}\u{1ceb4}-\u{1ceb9}\u{1ced1}-\u{1cedf}\u{1cef1}-\u{1ceff}\u{1cf2e}-\u{1cf2f}\u{1cf47}-\u{1d166}\u{1d16a}-\u{1d172}\u{1d183}-\u{1d184}\u{1d18c}-\u{1d1a9}\u{1d1ae}-\u{1d1e8}\u{1d1eb}-\u{1d1ff}\u{1d246}-\u{1d2ff}\u{1d357}-\u{1d6c0}\u{1d6c2}-\u{1d6da}\u{1d6dc}-\u{1d6fa}\u{1d6fc}-\u{1d714}\u{1d716}-\u{1d734}\u{1d736}-\u{1d74e}\u{1d750}-\u{1d76e}\u{1d770}-\u{1d788}\u{1d78a}-\u{1d7a8}\u{1d7aa}-\u{1d7c2}\u{1d7c4}-\u{1d7cd}\u{1d800}-\u{1d9ff}\u{1da37}-\u{1da3a}\u{1da6d}-\u{1da74}\u{1da76}-\u{1da83}\u{1da85}-\u{1da9a}\u{1daa0}\u{1dab0}-\u{1dfff}\u{1e007}\u{1e019}-\u{1e01a}\u{1e022}\u{1e025}\u{1e02b}-\u{1e08e}\u{1e090}-\u{1e12f}\u{1e137}-\u{1e2ad}\u{1e2af}-\u{1e2eb}\u{1e2f0}-\u{1e2fe}\u{1e300}-\u{1e4eb}\u{1e4f0}-\u{1e5ed}\u{1e5f0}-\u{1e6e2}\u{1e6e4}-\u{1e6e5}\u{1e6e7}-\u{1e6ed}\u{1e6f0}-\u{1e6f4}\u{1e6f6}-\u{1e8cf}\u{1e8d7}-\u{1e943}\u{1e94b}-\u{1eeef}\u{1eef2}-\u{1efff}\u{1f02c}-\u{1f02f}\u{1f094}-\u{1f09f}\u{1f0af}-\u{1f0b0}\u{1f0c0}\u{1f0d0}\u{1f0f6}-\u{1f0ff}\u{1f110}-\u{1f12e}\u{1f130}-\u{1f169}\u{1f170}-\u{1f1ac}\u{1f1ae}-\u{1f25f}\u{1f266}-\u{1f2ff}\u{1f6d9}-\u{1f6db}\u{1f6ed}-\u{1f6ef}\u{1f6fd}-\u{1f6ff}\u{1f7da}-\u{1f7df}\u{1f7ec}-\u{1f7ef}\u{1f7f1}-\u{1f7ff}\u{1f80c}-\u{1f80f}\u{1f848}-\u{1f84f}\u{1f85a}-\u{1f85f}\u{1f888}-\u{1f88f}\u{1f8ae}-\u{1f8af}\u{1f8bc}-\u{1f8bf}\u{1f8c2}-\u{1f8cf}\u{1f8d9}-\u{1f8ff}\u{1fa58}-\u{1fa5f}\u{1fa6e}-\u{1fa6f}\u{1fa7d}-\u{1fa7f}\u{1fa8b}-\u{1fa8d}\u{1fac7}\u{1fac9}-\u{1facc}\u{1fadd}-\u{1fade}\u{1faeb}-\u{1faee}\u{1faf9}-\u{1faff}\u{1fb93}\u{1fbfb}-\u{1fffd}\u{20000}-\u{2fffd}\u{30000}-\u{3fffd}\u{40000}-\u{4fffd}\u{50000}-\u{5fffd}\u{60000}-\u{6fffd}\u{70000}-\u{7fffd}\u{80000}-\u{8fffd}\u{90000}-\u{9fffd}\u{a0000}-\u{afffd}\u{b0000}-\u{bfffd}\u{c0000}-\u{cfffd}\u{d0000}-\u{dfffd}\u{e1000}-\u{efffd}\u{f0000}-\u{ffffd}\u{100000}-\u{10fffd}]/u; +export const rightToLeftPattern = /[\u{590}\u{5be}\u{5c0}\u{5c3}\u{5c6}\u{5c8}-\u{5ff}\u{608}\u{60b}\u{60d}\u{61b}-\u{64a}\u{66d}-\u{66f}\u{671}-\u{6d5}\u{6e5}-\u{6e6}\u{6ee}-\u{6ef}\u{6fa}-\u{710}\u{712}-\u{72f}\u{74b}-\u{7a5}\u{7b1}-\u{7ea}\u{7f4}-\u{7f5}\u{7fa}-\u{7fc}\u{7fe}-\u{815}\u{81a}\u{824}\u{828}\u{82e}-\u{858}\u{85c}-\u{88f}\u{892}-\u{896}\u{8a0}-\u{8c9}\u{200f}\u{fb1d}\u{fb1f}-\u{fb28}\u{fb2a}-\u{fbc2}\u{fbd3}-\u{fd3d}\u{fd50}-\u{fd8f}\u{fd92}-\u{fdc7}\u{fdf0}-\u{fdfc}\u{fe70}-\u{fefe}\u{10800}-\u{1091e}\u{10920}-\u{10a00}\u{10a04}\u{10a07}-\u{10a0b}\u{10a10}-\u{10a37}\u{10a3b}-\u{10a3e}\u{10a40}-\u{10ae4}\u{10ae7}-\u{10b38}\u{10b40}-\u{10d23}\u{10d28}-\u{10d2f}\u{10d3a}-\u{10d3f}\u{10d4a}-\u{10d68}\u{10d6f}-\u{10e5f}\u{10e7f}-\u{10eaa}\u{10ead}-\u{10ecf}\u{10ed9}-\u{10ef9}\u{10f00}-\u{10f45}\u{10f51}-\u{10f81}\u{10f86}-\u{10fff}\u{1e800}-\u{1e8cf}\u{1e8d7}-\u{1e943}\u{1e94b}-\u{1eeef}\u{1eef2}-\u{1efff}]/u; diff --git a/src/ui/components/AdaptiveMarquee.test.tsx b/src/ui/components/AdaptiveMarquee.test.tsx index 92fa79c..38c3e06 100644 --- a/src/ui/components/AdaptiveMarquee.test.tsx +++ b/src/ui/components/AdaptiveMarquee.test.tsx @@ -1,14 +1,26 @@ -import {act} from 'react'; -import {render} from 'ink-testing-library'; -import {afterEach, describe, expect, it, vi} from 'vitest'; +import {act, type ComponentProps} from 'react'; +import stringWidth from 'string-width'; +import {cleanup, render} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {DisplayContext, resolveDisplayMode} from '../display-context.js'; +import {displayWidth} from '../format.js'; import {AdaptiveMarquee} from './AdaptiveMarquee.js'; describe('AdaptiveMarquee', () => { - afterEach(() => vi.useRealTimers()); + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubEnv('INK_SCREEN_READER', 'false'); + }); + afterEach(() => { + act(() => cleanup()); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); it('waits before moving the selected text and loops without growing its width', async () => { - vi.useFakeTimers(); - const view = render(); + const view = renderMarquee({text: 'ABCDEFGHIJ'}); expect(view.lastFrame()).toBe('ABCDE'); await act(async () => vi.advanceTimersByTimeAsync(999)); @@ -17,10 +29,193 @@ describe('AdaptiveMarquee', () => { expect(view.lastFrame()).toBe('BCDEF'); await act(async () => vi.advanceTimersByTimeAsync(180)); expect(view.lastFrame()).toBe('CDEFG'); + await steps(3); + expect(view.lastFrame()).toBe('FGHIJ'); + await advance(999); + expect(view.lastFrame()).toBe('FGHIJ'); + await advance(1); + expect(view.lastFrame()).toBe('ABCDE'); + }); + + it.each([ + {text: 'אבגדהוזחטי', first: 'וזחטי', next: 'הוזחט', last: 'אבגדה'}, + {text: 'ابتثجحخدذر', first: 'حخدذر', next: 'جحخدذ', last: 'ابتثج'}, + {text: 'אABCDEFGHI', first: 'EFGHI', next: 'DEFGH', last: 'אABCD'} + ])('scrolls RTL text toward the beginning, pauses and loops: $text', async ({text, first, next, last}) => { + const view = renderMarquee({text}); + expect(view.lastFrame()).toBe(first); + await advance(999); + expect(view.lastFrame()).toBe(first); + await advance(1); + expect(view.lastFrame()).toBe(next); + await steps(4); + expect(view.lastFrame()).toBe(last); + await advance(999); + expect(view.lastFrame()).toBe(last); + await advance(1); + expect(view.lastFrame()).toBe(first); + }); + + it('retains LTR scrolling when the name starts with Latin before RTL text', async () => { + const view = renderMarquee({text: 'FM אבגדהוזחטי'}); + expect(view.lastFrame()).toBe('FM אב'); + await advance(1000); + expect(view.lastFrame()).toBe('M אבג'); + }); + + it('preserves RTL combining marks and original character order in every frame', async () => { + const text = 'אָבּגּדּהָוֹ'; + const view = renderMarquee({text, width: 3}); + expect(view.lastFrame()).toBe('דּהָוֹ'); + await advance(1000); + expect(view.lastFrame()).toBe('גּדּהָ'); + await advance(180); + expect(view.lastFrame()).toBe('בּגּדּ'); + await advance(180); + expect(view.lastFrame()).toBe('אָבּגּ'); + for (const frame of view.frames) { + expect(displayWidth(frame)).toBeLessThanOrEqual(3); + expect(text).toContain(frame); + expect(frame).not.toMatch(/^\p{Mark}/u); + } + }); + + it('keeps joined emoji and wide characters intact in RTL windows', async () => { + const text = 'אבג👩‍👩‍👧‍👦東京דה'; + const view = renderMarquee({text, width: 4}); + await advance(1000); + await steps(6); + for (const frame of view.frames) { + expect(displayWidth(frame)).toBeLessThanOrEqual(4); + expect(text).toContain(frame); + expect(frame.replaceAll('👩‍👩‍👧‍👦', '')).not.toMatch(/[👩👧👦\u200d]/u); + } + }); + + it.each(['🇯🇵1️⃣ Radio', 'אב🇯🇵1️⃣גד'])('keeps flag and keycap clusters within every marquee frame: %s', async text => { + const view = renderMarquee({text, width: 4}); + await advance(1000); + await steps(12); + for (const frame of view.frames) { + expect(stringWidth(frame)).toBeLessThanOrEqual(4); + expect(text).toContain(frame); + expect(frame.replaceAll('🇯🇵', '').replaceAll('1️⃣', '')).not.toMatch(/[🇯🇵\ufe0f\u20e3]/u); + } + }); + + it('restarts the edge pause even when an equally long name changes at offset zero', async () => { + const view = renderMarquee({text: 'ABCDEFGHIJ'}); + await advance(999); + act(() => view.rerender()); + await advance(999); + expect(view.lastFrame()).toBe('וזחטי'); + await advance(1); + expect(view.lastFrame()).toBe('הוזחט'); + }); + + it('resets progress after text, width and selection changes', async () => { + const view = renderMarquee({text: 'אבגדהוזחטי'}); + await advance(1180); + act(() => view.rerender()); + expect(view.lastFrame()).toBe('חטי'); + await advance(1000); + expect(view.lastFrame()).toBe('זחט'); + act(() => view.rerender()); + expect(view.lastFrame()).toBe('אב…'); + act(() => view.rerender()); + expect(view.lastFrame()).toBe('ABC'); + await advance(999); + expect(view.lastFrame()).toBe('ABC'); + await advance(1); + expect(view.lastFrame()).toBe('BCD'); + }); + + it('never emits a frame with stale progress when changing direction', async () => { + const view = renderMarquee({text: 'ABCDEFGHIJ'}); + await advance(1000); + await steps(2); + const frames = view.frames.length; + act(() => view.rerender()); + expect(view.frames.slice(frames)).toEqual(['וזחטי']); + }); + + it.each([0, -1, 20])('does not schedule animation for empty or fitting widths: %s', async width => { + const view = renderMarquee({text: 'אבגדהוזחטי', width}); + const frames = view.frames.length; + await advance(5000); + expect(view.frames).toHaveLength(frames); + expect(vi.getTimerCount()).toBe(0); + expect(view.lastFrame()).toBe(width <= 0 ? '' : 'אבגדהוזחטי'); + }); + + it.each([{TERM: 'dumb'}, {RADIOCLI_SCREEN_READER: '1'}, {RADIOCLI_DISABLE_ANIMATION: '1'}])('keeps RTL output static with %j', async env => { + const display = resolveDisplayMode({}, env); + let view!: ReturnType; + act(() => { view = render( + + + + ); }); + const frames = view.frames.length; + await advance(5000); + expect(view.frames).toHaveLength(frames); + }); + + it('preserves international names and direction in ASCII and no-color modes', async () => { + const display = resolveDisplayMode({asciiMode: true}, {NO_COLOR: '1', LC_ALL: 'C'}); + let view!: ReturnType; + act(() => { view = render( + + + + ); }); + expect(view.lastFrame()).toBe('וזחטי'); + await advance(1000); + expect(view.lastFrame()).toBe('הוזחט'); + }); + + it('exposes the complete RTL name to screen readers without animation', async () => { + vi.stubEnv('INK_SCREEN_READER', 'true'); + const display = resolveDisplayMode({}, {INK_SCREEN_READER: 'true'}); + const text = 'راديو العربية International'; + let view!: ReturnType; + act(() => { view = render( + + + + ); }); + expect(view.lastFrame()).toBe(text); + const frames = view.frames.length; + await advance(5000); + expect(view.frames).toHaveLength(frames); + expect(vi.getTimerCount()).toBe(0); + }); + + it('cancels the timer when unmounted', async () => { + const view = renderMarquee({text: 'אבגדהוזחטי'}); + act(() => view.unmount()); + await advance(5000); + expect(vi.getTimerCount()).toBe(0); }); it('uses ordinary truncation when inactive or Reduce Motion is enabled', () => { - expect(render().lastFrame()).toBe('ABCD…'); - expect(render().lastFrame()).toBe('ABCD…'); + expect(renderMarquee({text: 'ABCDEFGHIJ', active: false}).lastFrame()).toBe('ABCD…'); + expect(renderMarquee({text: 'ABCDEFGHIJ', reduceMotion: true}).lastFrame()).toBe('ABCD…'); + expect(renderMarquee({text: 'אבגדהוזחטי', active: false}).lastFrame()).toBe('אבגד…'); + expect(renderMarquee({text: 'אבגדהוזחטי', reduceMotion: true}).lastFrame()).toBe('אבגד…'); }); }); + +function renderMarquee(props: Partial> & {text: string}): ReturnType { + let view!: ReturnType; + act(() => { view = render(); }); + return view; +} + +async function advance(ms: number): Promise { + await act(async () => vi.advanceTimersByTimeAsync(ms)); +} + +async function steps(count: number): Promise { + for (let index = 0; index < count; index++) await advance(180); +} diff --git a/src/ui/components/AdaptiveMarquee.tsx b/src/ui/components/AdaptiveMarquee.tsx index 02c1af0..ac879c9 100644 --- a/src/ui/components/AdaptiveMarquee.tsx +++ b/src/ui/components/AdaptiveMarquee.tsx @@ -1,8 +1,9 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {Text} from 'ink'; -import {displayWidth, sliceDisplay, truncate} from '../format.js'; +import {displayWidth, sliceDisplay, sliceDisplayFromEnd, truncate} from '../format.js'; import {useDisplay} from '../display-context.js'; import {toAsciiSafe} from '../ascii.js'; +import {textDirection} from '../text-direction.js'; type AdaptiveMarqueeProps = { text: string; @@ -16,14 +17,24 @@ 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 safeWidth = Math.max(0, Math.floor(width)); const maxOffset = Math.max(0, displayWidth(text) - safeWidth); - const animated = active && !reduceMotion && !terminalReduceMotion && maxOffset > 0; - const [offset, setOffset] = useState(0); + const animated = active && !reduceMotion && !terminalReduceMotion && safeWidth > 0 && maxOffset > 0; - useEffect(() => { - setOffset(0); - }, [active, reduceMotion, terminalReduceMotion, safeWidth, text]); + // Reset position and cancel the old timer before rendering a changed name or + // viewport. An effect-based reset can emit a frame using the previous offset. + return ; +} + +function MarqueeText({text, width, maxOffset, animated, ascii}: { + text: string; + width: number; + maxOffset: number; + animated: boolean; + ascii: boolean; +}): React.ReactElement { + const direction = useMemo(() => textDirection(text), [text]); + const [offset, setOffset] = useState(0); useEffect(() => { if (!animated) return; @@ -34,8 +45,11 @@ export function AdaptiveMarquee({text, width, active, reduceMotion}: AdaptiveMar return () => clearTimeout(timer); }, [animated, maxOffset, offset]); + const progress = Math.min(offset, maxOffset); + // Move the window toward the beginning for RTL text, keeping the original + // character order. Glyph shaping and bidi rendering remain the terminal's job. const visible = animated - ? sliceDisplay(text, Math.min(offset, maxOffset), safeWidth) - : truncate(text, safeWidth); + ? (direction === 'rtl' ? sliceDisplayFromEnd : sliceDisplay)(text, progress, width) + : truncate(text, width); return {ascii ? toAsciiSafe(visible) : visible}; } diff --git a/src/ui/format.test.ts b/src/ui/format.test.ts index 984e03f..0603c46 100644 --- a/src/ui/format.test.ts +++ b/src/ui/format.test.ts @@ -1,12 +1,34 @@ -import {describe, expect, it} from 'vitest'; -import {displayWidth, padDisplayEnd, removeLastGrapheme, sliceDisplay, truncate} from './format.js'; +import {describe, expect, it, vi} from 'vitest'; +import stringWidth from 'string-width'; +import {displayWidth, padDisplayEnd, removeLastGrapheme, sliceDisplay, sliceDisplayFromEnd, truncate} from './format.js'; describe('terminal display formatting', () => { + it('preserves spaces after a wide cluster intersecting the scrolling edge', () => { + expect(sliceDisplay('🇯🇵1️⃣ Radio', 3, 4)).toBe('1️⃣ R'); + expect(sliceDisplay('東京 Radio', 1, 4)).toBe('東京'); + }); + it('measures CJK and emoji graphemes as double-width terminal cells', () => { expect(displayWidth('FM 東京 📻')).toBe(10); expect(padDisplayEnd('東京', 6)).toBe('東京 '); }); + it.each([ + ['🇯🇵', 2], ['🇺🇸 FM', 5], ['1️⃣', 2], ['#\u20e3', 2], + ['♥︎', 1], ['©', 1], ['©️', 2], ['👩🏽‍💻', 2], ['Café', 4] + ] as const)('measures %s consistently with terminal rendering', (value, width) => { + expect(displayWidth(value)).toBe(width); + expect(stringWidth(padDisplayEnd(value, 8))).toBe(8); + expect(stringWidth(truncate(`${value} Station`, 4))).toBeLessThanOrEqual(4); + }); + + it('clips flag and keycap clusters without splitting them or exceeding the viewport', () => { + expect(truncate('🇯🇵 Station', 4)).toBe('🇯🇵 …'); + expect(truncate('1️⃣ Station', 4)).toBe('1️⃣ …'); + expect(sliceDisplay('A🇯🇵1️⃣Z', 1, 4)).toBe('🇯🇵1️⃣'); + expect(sliceDisplayFromEnd('A🇯🇵1️⃣Z', 1, 4)).toBe('🇯🇵1️⃣'); + }); + it('truncates and deletes whole grapheme clusters', () => { expect(truncate('東京 Radio', 6)).toBe('東京 …'); expect(removeLastGrapheme('Café📻')).toBe('Café'); @@ -16,4 +38,36 @@ describe('terminal display formatting', () => { expect(sliceDisplay('AB東京CD', 2, 4)).toBe('東京'); expect(sliceDisplay('AB東京CD', 4, 4)).toBe('京CD'); }); + + it('slices from the end without dropping the tail at a wide-character boundary', () => { + expect(sliceDisplayFromEnd('אבג東京ד', 0, 4)).toBe('京ד'); + expect(sliceDisplayFromEnd('אבג東京ד', 1, 4)).toBe('東京'); + expect(sliceDisplayFromEnd('אבג東京ד', 2, 4)).toBe('בג東'); + expect(sliceDisplayFromEnd('אבג東京ד', 4, 4)).toBe('אבג'); + expect(sliceDisplayFromEnd('אבג東京ד', 20, 4)).toBe(''); + expect(sliceDisplayFromEnd('אבג東京ד', 0, 0)).toBe(''); + expect(sliceDisplayFromEnd('אבג東京ד', 0, 20)).toBe('אבג東京ד'); + }); + + it('keeps combining marks, surrogate pairs and joined emoji intact from the end', () => { + expect(sliceDisplayFromEnd('אָבּגּ', 0, 2)).toBe('בּגּ'); + expect(sliceDisplayFromEnd('אב𞤀𞤣', 1, 2)).toBe('ב𞤀'); + expect(sliceDisplayFromEnd('אב👩‍👩‍👧‍👦ג', 1, 3)).toBe('ב👩‍👩‍👧‍👦'); + expect(sliceDisplayFromEnd('אב👩‍👩‍👧‍👦ג', 2, 3)).toBe('אב'); + }); + + it('retains code-point clipping on runtimes without Intl.Segmenter', async () => { + vi.stubGlobal('Intl', new Proxy(Intl, {get: (target, property) => property === 'Segmenter' ? undefined : Reflect.get(target, property)})); + vi.resetModules(); + try { + const fallback = await import('./format.js'); + expect(fallback.sliceDisplayFromEnd('אבגדהוזחטי', 0, 5)).toBe('וזחטי'); + expect(fallback.sliceDisplayFromEnd('אבגדהוזחטי', 1, 5)).toBe('הוזחט'); + expect(fallback.sliceDisplayFromEnd('אב𞤀𞤣', 1, 2)).toBe('ב𞤀'); + expect(fallback.sliceDisplay('ABCDEFGHIJ', 1, 5)).toBe('BCDEF'); + } finally { + vi.unstubAllGlobals(); + vi.resetModules(); + } + }); }); diff --git a/src/ui/format.ts b/src/ui/format.ts index e6a5d76..1fb5351 100644 --- a/src/ui/format.ts +++ b/src/ui/format.ts @@ -1,5 +1,10 @@ import type {Station} from '../types.js'; +// Use Ink's width rules on supported Node runtimes, including emoji sequences +// and text/emoji presentation selectors. Retain code-point fallback for hosts +// without Intl.Segmenter, where full grapheme rendering is unavailable. +const rendererWidth = typeof Intl.Segmenter === 'function' ? (await import('string-width')).default : undefined; + export function stationLocation(station: Station): string { return [station.city, station.state, station.country].filter(Boolean).join(', ') || station.countryCode || 'Unknown origin'; } @@ -41,6 +46,7 @@ export function truncate(value: string, length: number): string { } export function displayWidth(value: string): number { + if (rendererWidth) return rendererWidth(value); let width = 0; for (const grapheme of graphemes(value)) { if (/^\p{Mark}+$/u.test(grapheme)) continue; @@ -61,12 +67,14 @@ export function sliceDisplay(value: string, start: number, length: number): stri let skippedWidth = 0; let outputWidth = 0; let result = ''; + let started = false; for (const grapheme of graphemes(value)) { const graphemeWidth = displayWidth(grapheme); - if (skippedWidth + graphemeWidth <= safeStart) { + if (!started && skippedWidth + graphemeWidth <= safeStart) { skippedWidth += graphemeWidth; continue; } + started = true; if (outputWidth + graphemeWidth > safeLength) break; result += grapheme; outputWidth += graphemeWidth; @@ -74,16 +82,42 @@ export function sliceDisplay(value: string, start: number, length: number): stri return result; } +/** Clip from the logical end for rightward scrolling, without reversing text. */ +export function sliceDisplayFromEnd(value: string, start: number, length: number): string { + const safeStart = Math.max(0, Math.floor(start)); + const safeLength = Math.max(0, Math.floor(length)); + if (safeLength === 0) return ''; + + let skippedWidth = 0; + let outputWidth = 0; + const parts = graphemes(value); + const result: string[] = []; + for (let index = parts.length - 1; index >= 0; index--) { + const grapheme = parts[index]!; + const graphemeWidth = displayWidth(grapheme); + // Skip the entire cluster when the moving edge lands inside a wide glyph. + if (skippedWidth < safeStart) { + skippedWidth += graphemeWidth; + continue; + } + if (outputWidth + graphemeWidth > safeLength) break; + result.push(grapheme); + outputWidth += graphemeWidth; + } + return result.reverse().join(''); +} + export function removeLastGrapheme(value: string): string { const parts = graphemes(value); parts.pop(); return parts.join(''); } +const graphemeSegmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, {granularity: 'grapheme'}) : null; + function graphemes(value: string): string[] { - const Segmenter = Intl.Segmenter; - if (Segmenter) { - return [...new Segmenter(undefined, {granularity: 'grapheme'}).segment(value)].map(item => item.segment); + if (graphemeSegmenter) { + return [...graphemeSegmenter.segment(value)].map(item => item.segment); } return Array.from(value); } diff --git a/src/ui/help-content.test.ts b/src/ui/help-content.test.ts index 04c89bd..d023e76 100644 --- a/src/ui/help-content.test.ts +++ b/src/ui/help-content.test.ts @@ -21,3 +21,14 @@ describe('completeCommand', () => { } }); }); + +describe('AirPlay help visibility', () => { + it('uses one filtered command collection for help counts and completion', async () => { + const {commandHelpForAirPlay, commandNamesForAirPlay, helpItemCountForAirPlay} = await import('./help-content.js'); + expect(commandHelpForAirPlay(false).some(command => command.name.includes('airplay'))).toBe(false); + expect(commandNamesForAirPlay(false).some(name => name.includes('airplay'))).toBe(false); + expect(helpItemCountForAirPlay(true) - helpItemCountForAirPlay(false)).toBe(1); + expect(completeCommand('air', commandNamesForAirPlay(false))).toBe('air'); + expect(commandNamesForAirPlay(true)).toContain('airplay-code'); + }); +}); diff --git a/src/ui/help-content.ts b/src/ui/help-content.ts index 84f2c28..80cc1a6 100644 --- a/src/ui/help-content.ts +++ b/src/ui/help-content.ts @@ -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 (macOS only)'}, + {name: 'airplay', description: 'Open experimental AirPlay settings'}, {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)'}, @@ -103,7 +103,7 @@ export const commandHelp: CommandHelp[] = [ {name: 'help', description: 'Open this help'} ]; -export const helpItemCount = +const helpItemCount = keyHelpSections.reduce((total, section) => total + 1 + section.entries.length, 0) + 1 + commandHelp.length; @@ -160,3 +160,9 @@ function longestCommonPrefix(values: string[]): string { return prefix; } + +export function commandHelpForAirPlay(visible = true): CommandHelp[] { return commandHelp.filter(command => visible || !command.name.startsWith('airplay')); } + +export function commandNamesForAirPlay(visible = true): string[] { return commandNames.filter(name => visible || !name.startsWith('airplay')); } + +export function helpItemCountForAirPlay(visible = true): number { return helpItemCount - commandHelp.length + commandHelpForAirPlay(visible).length; } diff --git a/src/ui/navigation.test.tsx b/src/ui/navigation.test.tsx new file mode 100644 index 0000000..d410bad --- /dev/null +++ b/src/ui/navigation.test.tsx @@ -0,0 +1,738 @@ +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {PassThrough, Writable} from 'node:stream'; +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 backends from '../player/backend-install.js'; +import * as airplay 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 {LibraryRepository} from '../storage/repository.js'; +import type {AppSettings, DiscoveryPage, PlaybackState, Station} from '../types.js'; +import {App} from './App.js'; +import * as content from './AppContent.js'; +import type {AlarmTuiService} from './alarm-tui-service.js'; +import {nextReceiverStyle, nextTheme} from './theme.js'; +import {settingsGroups, settingsItemsForPage, type SettingsPage} from './screen-items.js'; + +const directories: string[] = []; +const instances: ReturnType[] = []; +const station: Station = {id: 'navigation-radio', provider: 'radio-browser', name: 'Navigation Radio', tags: [], streamUrl: 'https://example.test/live'}; +const country = {name: 'Canada', code: 'CA', stationCount: 1}; +const service: AlarmTuiService = { + sync: async () => null, syncAll: async () => [], remove: async () => undefined, + runtimeStatus: async () => ({capabilities: {supported: true, exactWake: false, catchUpAfterWake: true, message: 'ready'}, degradedAlarmIds: new Set(), message: 'ready'}), + activeAlarms: async () => [], prepareTerminalAccess: async () => undefined, + verifySetup: async () => ({state: 'passed', startedAt: '2026-10-03T12:00:00.000Z', steps: []}) +}; + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + for (const name of ['TERM', 'NO_COLOR', 'RADIOCLI_ASCII', 'RADIOCLI_UNICODE', 'RADIOCLI_SCREEN_READER', 'RADIOCLI_OFFLINE', 'RADIOCLI_LOW_BANDWIDTH']) vi.stubEnv(name, undefined); + vi.stubEnv('RADIOCLI_DISABLE_UPDATE_CHECK', '1'); + vi.stubEnv('INK_SCREEN_READER', 'false'); + vi.spyOn(backends, 'detectPlaybackBackends').mockReturnValue(['mpv']); + vi.spyOn(airplay, 'discoverAirPlayDevices').mockResolvedValue([]); + vi.spyOn(ProviderManager.prototype, 'health').mockResolvedValue({}); + vi.spyOn(ProviderManager.prototype, 'searchPage').mockImplementation(async function(this: ProviderManager, query, settings, options = {}) { const offset = Number(options.cursor ?? 0); const items = await this.search(query, settings, {...options, offset}); return {items, exhausted: items.length < (options.limit ?? 90), sources: [], nextCursor: String(offset + items.length)}; }); + vi.spyOn(ProviderManager.prototype, 'byCountryPage').mockImplementation(async function(this: ProviderManager, code, options = {}) { const offset = Number(options.cursor ?? 0); const items = await this.byCountry(code, options.limit, offset); return {items, exhausted: items.length < (options.limit ?? 120), sources: [], nextCursor: String(offset + items.length)}; }); + vi.spyOn(session, 'startRadioSession').mockResolvedValue({close: async () => undefined}); + vi.spyOn(presence, 'registerTuiPresence').mockReturnValue(() => undefined); + vi.spyOn(PlayerController.prototype, 'stop').mockResolvedValue(undefined); + // Observe props while retaining the real renderer and every screen component. + vi.spyOn(content, 'AppContent'); +}); + +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}); +}); + +function pending() { + let finish!: (value: T) => void; + let fail!: (error: Error) => void; + const promise = new Promise((resolve, reject) => {finish = resolve; fail = reject;}); + return {promise, finish, fail}; +} + +async function open({platform = process.platform, columns = 100, rows = 30, screenReader = false, settings = {}, favorites = [station]}: { + platform?: NodeJS.Platform; columns?: number; rows?: number; screenReader?: boolean; settings?: Partial; favorites?: Station[]; +} = {}) { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-navigation-')); + directories.push(directory); + vi.stubEnv('RADIOCLI_HOME', directory); + const store = new JsonLibraryStore(join(directory, 'library.json')); + store.updateSettings({resumeOnLaunch: false, automaticUpdateChecks: false, reduceMotion: true, mouseSupport: false, desktopMediaControls: 'off', ...settings}); + for (const favorite of favorites) store.toggleFavorite(favorite); + let frame = ''; + const stdout = Object.assign(new Writable({write(chunk, _encoding, done) {frame = String(chunk); done();}}), {columns, rows, 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, + frame: () => frame, + input: async (data: string) => {await act(async () => {(stdin as unknown as PassThrough).write(data);});}, + state: () => vi.mocked(content.AppContent).mock.calls.at(-1)![0] + }; +} + +function delayFirstSettingsSave() { + const gate = pending(); + const originalUpdate = LibraryRepository.prototype.updateSettings; + let first = true; + const updates = vi.spyOn(LibraryRepository.prototype, 'updateSettings').mockImplementation(async function(this: LibraryRepository, settings) { + if (first) {first = false; await gate.promise;} + return originalUpdate.call(this, settings); + }); + return {gate, updates}; +} + +async function selectSetting(app: Awaited>, page: Exclude, item: string) { + await app.input(':settings\r'); + await app.input('\u001B[B'.repeat(settingsGroups.findIndex(group => group.id === page)) + '\r'); + const index = settingsItemsForPage(page, app.state().airPlaySupported).indexOf(item); + expect(index).toBeGreaterThanOrEqual(0); + if (index > 0) await app.input('\u001B[B'.repeat(index)); +} + +describe('Ordered critical settings', () => { + it.each([{input: 't', field: 'theme', message: 'Display color:'}, {input: 'v', field: 'receiverStyle', message: 'Receiver style:'}] as const)( + 'retains two rapid $field changes while the first save is pending', async ({input, field, message}) => { + const {gate, updates} = delayFirstSettingsSave(); + const app = await open(); + const original = app.store.snapshot().settings; + const expected = field === 'theme' + ? [nextTheme(original.theme), nextTheme(nextTheme(original.theme))] + : [nextReceiverStyle(original.receiverStyle), nextReceiverStyle(nextReceiverStyle(original.receiverStyle))]; + await app.input(input); + await app.input('\t'); + await app.input(input); + expect(updates).toHaveBeenCalledTimes(1); + expect(app.state().library.settings[field]).toBe(original[field]); + expect(app.frame()).not.toContain(message); + await act(async () => {gate.finish();}); + expect(updates.mock.calls.map(([patch]) => patch[field])).toEqual(expected); + expect(app.store.snapshot().settings[field]).toBe(expected[1]); + expect(app.state().library.settings[field]).toBe(expected[1]); + expect(app.frame()).toContain(`${message} ${expected[1]}`); + } + ); + + it.each([ + {page: 'discovery', item: 'Toggle nearby location lookup', field: 'enableNearbyLocation'}, + {page: 'discovery', item: 'Toggle Radio Garden experimental adapter', field: 'enableRadioGarden'}, + {page: 'discovery', item: 'Share favorite votes with Radio Browser', field: 'shareDirectoryVotes'}, + {page: 'playback', item: 'Toggle skip broken streams', field: 'skipBrokenStreams'}, + {page: 'playback', item: 'Resume last station on launch', field: 'resumeOnLaunch'}, + {page: 'appearance', item: 'Transparent background', field: 'transparentBackground'}, + {page: 'appearance', item: 'ASCII-safe display', field: 'asciiMode'}, + {page: 'appearance', item: 'Reduce motion', field: 'reduceMotion'}, + {page: 'appearance', item: 'Mouse and trackpad scrolling', field: 'mouseSupport'}, + {page: 'updates', item: 'Automatically check for updates', field: 'automaticUpdateChecks'}, + {page: 'agent', item: 'Open TUI for agent playback', field: 'openUiOnPlay'}, + {page: 'agent', item: 'Show Now Playing for agent playback', field: 'focusNowPlaying'} + ] as const)('retains both pending $field toggles', async ({page, item, field}) => { + const {gate, updates} = delayFirstSettingsSave(); + const app = await open(); + const read = (settings: Partial) => field === 'openUiOnPlay' || field === 'focusNowPlaying' + ? settings.agentControl?.[field] : settings[field]; + const original = read(app.store.snapshot().settings); + await selectSetting(app, page, item); + await app.input('\r'); await app.input('\r'); + expect(updates).toHaveBeenCalledTimes(1); + expect(read(app.state().library.settings)).toBe(original); + if (field === 'skipBrokenStreams') expect(app.frame()).not.toContain('Skip broken streams disabled.'); + await act(async () => {gate.finish();}); + expect(updates.mock.calls.map(([patch]) => read(patch))).toEqual([!original, original]); + expect(read(app.store.snapshot().settings)).toBe(original); + expect(read(app.state().library.settings)).toBe(original); + }); + + it('recovers after a failed save and evaluates the next action against committed settings', async () => { + const {gate, updates} = delayFirstSettingsSave(); + const app = await open(); + const original = app.store.snapshot().settings.theme; + await app.input('t'); await app.input('t'); + await act(async () => {gate.fail(new Error('Disk temporarily unavailable.'));}); + expect(updates.mock.calls.map(([patch]) => patch.theme)).toEqual([nextTheme(original), nextTheme(original)]); + expect(app.store.snapshot().settings.theme).toBe(nextTheme(original)); + expect(app.frame()).toContain(`Display color: ${nextTheme(original)}`); + }); + + it('merges separate agent preferences when their saves overlap', async () => { + const {gate} = delayFirstSettingsSave(); + const app = await open(); + const original = app.store.snapshot().settings.agentControl!; + await selectSetting(app, 'agent', 'Open TUI for agent playback'); + await app.input('\r'); await app.input('\u001B[B\r'); + await act(async () => {gate.finish();}); + expect(app.store.snapshot().settings.agentControl).toEqual({...original, openUiOnPlay: !original.openUiOnPlay, focusNowPlaying: !original.focusNowPlaying}); + }); + + it('queues no-value command toggles while preserving explicit command values', async () => { + const {gate, updates} = delayFirstSettingsSave(); + const app = await open({settings: {asciiMode: false}}); + await app.input(':ascii\r'); await app.input(':ascii\r'); await app.input(':ascii on\r'); + expect(updates).toHaveBeenCalledTimes(1); + expect(app.frame()).not.toContain('ascii on.'); + await act(async () => {gate.finish();}); + expect(updates.mock.calls.map(([patch]) => patch.asciiMode)).toEqual([true, false, true]); + expect(app.store.snapshot().settings.asciiMode).toBe(true); + expect(app.frame()).toContain('ascii on.'); + }); + + it('appends every learned key and acknowledges learning only after saving', async () => { + const {gate} = delayFirstSettingsSave(); + const app = await open(); + await app.input(':learn next\r'); await app.input('z'); + expect(app.frame()).not.toContain('Learned next key.'); + await app.input(':learn next\r'); await app.input('y'); + await act(async () => {gate.finish();}); + expect(app.store.snapshot().settings.mediaKeys?.next).toEqual(['z', 'y']); + expect(app.frame()).toContain('Learned next key.'); + }); + + it('does not acknowledge a failed learned-key reset', async () => { + const {gate} = delayFirstSettingsSave(); + const app = await open({settings: {mediaKeys: {previous: [], playPause: [], next: ['z']}}}); + await app.input(':keys reset\r'); + expect(app.frame()).not.toContain('Learned media keys reset.'); + await act(async () => {gate.fail(new Error('Disk temporarily unavailable.'));}); + expect(app.store.snapshot().settings.mediaKeys?.next).toEqual(['z']); + expect(app.frame()).toContain('Disk temporarily unavailable.'); + expect(app.frame()).not.toContain('Learned media keys reset.'); + }); + + it('cycles through distinct audio outputs while earlier saves are pending', async () => { + vi.mocked(backends.detectPlaybackBackends).mockReturnValue(['mpv', 'ffplay']); + const {gate, updates} = delayFirstSettingsSave(); + const app = await open({settings: {preferredBackend: 'auto'}}); + await app.input('o'); await app.input('o'); + expect(updates).toHaveBeenCalledTimes(1); + expect(app.store.snapshot().settings.preferredBackend).toBe('auto'); + expect(app.frame()).not.toContain('Audio output: mpv.'); + await act(async () => {gate.finish();}); + expect(updates.mock.calls.map(([patch]) => patch.preferredBackend)).toEqual(['mpv', 'ffplay']); + expect(app.store.snapshot().settings.preferredBackend).toBe('ffplay'); + expect(app.frame()).toContain('Audio output: This device (ffplay fallback).'); + }); + + it('does not acknowledge local AirPlay correction before its save succeeds', async () => { + vi.mocked(backends.detectPlaybackBackends).mockReturnValue(['mpv', 'airplay']); + vi.mocked(airplay.discoverAirPlayDevices).mockResolvedValue([{id: 'local', name: 'This computer', host: '127.0.0.1', port: 7000, + local: true, txt: [], requiresPassword: false, airplay2: false}]); + const {gate, updates} = delayFirstSettingsSave(); + const app = await open({platform: 'darwin', favorites: [], settings: {preferredBackend: 'airplay', preferredAirPlayDevice: 'local'}}); + await app.input(':airplay\r'); + expect(updates).toHaveBeenCalledWith({preferredBackend: 'mpv'}); + expect(app.frame()).not.toContain('Audio output: This device (mpv).'); + await act(async () => {gate.fail(new Error('Disk temporarily unavailable.'));}); + expect(app.store.snapshot().settings.preferredBackend).toBe('airplay'); + expect(app.frame()).toContain('Disk temporarily unavailable.'); + expect(app.frame()).not.toContain('Audio output: This device (mpv).'); + }); +}); + +describe('Responsive tab navigation', () => { + const platforms = ['darwin', 'linux', 'win32'] as const; + const sizes = [{columns: 100, rows: 30}, {columns: 50, rows: 16}, {columns: 24, rows: 8}]; + const keys = [ + {name: 'Right', input: '\u001B[C\u001B[C', destination: 'library'}, + {name: 'Tab', input: '\t\t', destination: 'library'}, + {name: 'Shift+Tab', input: '\u001B[Z\u001B[Z', destination: 'stats'} + ]; + it.each(platforms.flatMap(platform => sizes.flatMap(size => keys.map(key => ({platform, ...size, ...key})))))( + 'preserves both $name presses on $platform at $columns x $rows', async ({platform, columns, rows, input, destination}) => { + const app = await open({platform, columns, rows}); + await app.input(input); + expect(app.state().screen).toBe(destination); + } + ); + + it('handles rapid tabs in screen reader and ASCII environments', async () => { + const app = await open({screenReader: true, settings: {asciiMode: true}}); + await app.input('\t\t'); + expect(app.state().screen).toBe('library'); + }); + + it('accepts Kitty press/repeat events and ignores releases', async () => { + const app = await open(); + await app.input('\u001B[9;1:1u\u001B[9;1:2u\u001B[9;1:3u'); + expect(app.state().screen).toBe('library'); + }); + + it('keeps a tab press immediately after scrolling', async () => { + const app = await open(); + await app.input('\u001B[<64;10;10M\t'); + expect(app.state().screen).toBe('now-playing'); + }); + + it('handles a media key once while retaining following tab navigation', async () => { + const pause = vi.spyOn(PlayerController.prototype, 'togglePause').mockResolvedValue({ok: true}); + const app = await open(); + await app.input('\u001B[19~\t'); + expect(pause).toHaveBeenCalledTimes(1); + expect(app.state().screen).toBe('now-playing'); + }); + + it('retains Library selection across a burst that leaves and returns', async () => { + const app = await open({favorites: [station, {...station, id: 'second', name: 'Second Radio'}]}); + await app.input('2'); + await app.input('\u001B[B'); + expect(app.state().selected).toBe(1); + await app.input('\u001B[D\u001B[D\u001B[C\u001B[C'); + expect(app.state().screen).toBe('library'); + expect(app.state().selected).toBe(1); + }); + + it('tunes the destination tab selection when Enter arrives in the same chunk', async () => { + vi.spyOn(ProviderManager.prototype, 'nearby').mockResolvedValue([{...station, id: 'explore', name: 'Explore Radio'}]); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async item => ({url: item.streamUrl!})); + const play = vi.spyOn(PlayerController.prototype, 'play').mockResolvedValue(undefined); + const app = await open(); + await app.input('3'); + await app.input('\u001B[D\r'); + expect(app.state().screen).toBe('library'); + expect(play).toHaveBeenCalledWith(station, station.streamUrl, expect.objectContaining({signal: expect.any(AbortSignal)})); + }); + + it('keeps Tab completion in command mode', async () => { + const app = await open(); + await app.input(':'); await app.input('sett'); await app.input('\t\t'); await app.input('\r'); + expect(app.state().screen).toBe('settings'); + }); + + it('preserves pasted queries and commands while ignoring pasted navigation bytes', async () => { + vi.spyOn(ProviderManager.prototype, 'search').mockResolvedValue([station]); + const app = await open(); + await app.input('\u001B[200~\t\t\u001B[201~'); + expect(app.state().screen).toBe('home'); + await app.input('\u001B[200~:settings\r\u001B[201~'); + expect(app.state().screen).toBe('settings'); + await app.input('\u001B[200~:search jazz\r\u001B[201~'); + expect(app.state().screen).toBe('search'); + expect(app.state().displayStations).toEqual([station]); + }); + + it('routes a following Enter to Alarms after rapid tab navigation', async () => { + vi.spyOn(ProviderManager.prototype, 'nearby').mockResolvedValue([station]); + const play = vi.spyOn(PlayerController.prototype, 'play').mockResolvedValue(undefined); + const app = await open(); + await app.input('8\u001B[13u'); + expect(app.state().screen).toBe('alarm-editor'); + expect(play).not.toHaveBeenCalled(); + }); + + it('uses the destination controls after leaving Alarms in one input chunk', async () => { + vi.spyOn(ProviderManager.prototype, 'nearby').mockResolvedValue([station]); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async item => ({url: item.streamUrl!})); + const play = vi.spyOn(PlayerController.prototype, 'play').mockResolvedValue(undefined); + const app = await open(); + await app.input('3'); await app.input('b'); await app.input('8'); + expect(app.state().screen).toBe('alarms'); + await app.input('\u001B[C\u001B[C\u001B[C\r'); + expect(app.state().screen).toBe('explore'); + expect(play).toHaveBeenCalledWith(station, station.streamUrl, expect.objectContaining({signal: expect.any(AbortSignal)})); + }); + + it('uses the current Settings page when Enter follows a rapid tab round trip', async () => { + const app = await open(); + await app.input('9'); await app.input('\r'); + expect(app.state().settingsPage).toBe('playback'); + await app.input('\u001B[D\u001B[C\r'); + expect(app.state().screen).toBe('settings'); + expect(app.state().settingsPage).toBe('playback'); + }); +}); + +describe('Independent background discovery', () => { + it('loads country stations immediately without reclaiming focus on completion', async () => { + const load = pending(); + vi.spyOn(ProviderManager.prototype, 'countries').mockResolvedValue([country]); + vi.spyOn(ProviderManager.prototype, 'byCountry').mockReturnValue(load.promise); + const app = await open(); + await app.input('5'); await app.input('\r'); + expect(app.state().screen).toBe('stations'); + expect(app.state().loadingStations).toBe(true); + await app.input(':settings\r'); + await act(async () => {load.finish([station]);}); + expect(app.state().screen).toBe('settings'); + await app.input('\u001B[D\u001B[D\u001B[D'); + expect(app.state().screen).toBe('countries'); + await app.input('\r'); + await act(async () => {load.finish([station]);}); + expect(app.state().screen).toBe('stations'); + expect(app.state().displayStations).toEqual([station]); + }); + + it('lets a delayed Explore movement finish without returning from Search', async () => { + const nearby = vi.spyOn(ProviderManager.prototype, 'nearby').mockResolvedValue([station]); + const app = await open(); + await app.input('3'); await app.input('d'); await app.input('\t'); + expect(app.state().screen).toBe('search'); + await act(async () => {await vi.advanceTimersByTimeAsync(220);}); + expect(app.state().screen).toBe('search'); + expect(app.state().loadingStations).toBe(false); + expect(nearby).toHaveBeenCalledTimes(2); + }); + + it('reuses pending Explore loads and keeps Library ready', async () => { + const load = pending(); + const nearby = vi.spyOn(ProviderManager.prototype, 'nearby').mockReturnValue(load.promise); + const app = await open(); + await app.input('3'); + expect(nearby).toHaveBeenCalledTimes(1); + await app.input('\u001B[Z'); + expect(app.state().screen).toBe('library'); + expect(app.state().loadingStations).toBe(false); + expect(app.state().displayStations).toEqual([station]); + await app.input('\t'); + expect(app.state().loadingStations).toBe(true); + expect(nearby).toHaveBeenCalledTimes(1); + await act(async () => {load.finish([station]);}); + expect(app.state().loadingStations).toBe(false); + }); + + it('reuses pending Nearby location detection without blocking other station tabs', async () => { + const location = pending<{latitude: number; longitude: number; source: string}>(); + const detect = vi.spyOn(ProviderManager.prototype, 'detectLocation').mockReturnValue(location.promise); + vi.spyOn(ProviderManager.prototype, 'nearby').mockResolvedValue([station]); + const app = await open({settings: {enableNearbyLocation: true}}); + await app.input('6'); await app.input('\t'); await app.input('\u001B[Z'); + expect(detect).toHaveBeenCalledTimes(1); + await app.input('\u001B[D\u001B[D'); + expect(app.state().screen).toBe('search'); + expect(app.state().loadingStations).toBe(false); + await act(async () => {location.finish({latitude: 48.8, longitude: 2.3, source: 'test'});}); + expect(app.state().screen).toBe('search'); + }); + + it('does not overwrite another tab selection when a Search result arrives', async () => { + const search = pending(); + vi.spyOn(ProviderManager.prototype, 'search').mockReturnValue(search.promise); + const app = await open({favorites: [station, {...station, id: 'second', name: 'Second Radio'}]}); + await app.input('2'); await app.input('\u001B[B'); + await app.input(':search jazz\r'); + await app.input('\u001B[C\u001B[C\u001B[C\u001B[C\u001B[C\u001B[C\u001B[C'); + expect(app.state().screen).toBe('library'); + expect(app.state().selected).toBe(1); + await act(async () => {search.finish([station]);}); + expect(app.state().screen).toBe('library'); + expect(app.state().selected).toBe(1); + }); + + it('retains a background failure on its own tab and permits retrying it', async () => { + const load = pending(); + const nearby = vi.spyOn(ProviderManager.prototype, 'nearby').mockReturnValueOnce(load.promise).mockResolvedValue([station]); + const app = await open(); + await app.input('3'); await app.input('\u001B[Z'); + await act(async () => {load.fail(new Error('Directory unavailable.'));}); + expect(app.state().screen).toBe('library'); + expect(app.state().stationContext.error).toBeUndefined(); + await app.input('\t'); + expect(app.state().screen).toBe('explore'); + expect(nearby).toHaveBeenCalledTimes(2); + expect(app.state().displayStations).toEqual([station]); + }); + + it('does not reclaim focus when a country command is still looking up the directory', async () => { + const countries = pending(); + vi.spyOn(ProviderManager.prototype, 'countries').mockReturnValue(countries.promise); + const byCountry = vi.spyOn(ProviderManager.prototype, 'byCountry').mockResolvedValue([station]); + const app = await open(); + await app.input(':country CA\r'); + await app.input('2'); + await act(async () => {countries.finish([country]);}); + expect(byCountry).toHaveBeenCalledWith('CA', 120, 0); + expect(app.state().screen).toBe('library'); + expect(app.state().loadingStations).toBe(false); + }); + + it('invalidates pending location detection when Nearby location is disabled', async () => { + const location = pending<{latitude: number; longitude: number; source: string}>(); + vi.spyOn(ProviderManager.prototype, 'detectLocation').mockReturnValue(location.promise); + const nearby = vi.spyOn(ProviderManager.prototype, 'nearby').mockResolvedValue([station]); + const app = await open({settings: {enableNearbyLocation: true}}); + await app.input('6'); + expect(app.state().loadingStations).toBe(true); + await app.input(':location off\r'); + expect(app.state().loadingStations).toBe(false); + await act(async () => {location.finish({latitude: 48.8, longitude: 2.3, source: 'test'});}); + expect(nearby).not.toHaveBeenCalled(); + expect(app.state().displayStations).toEqual([]); + }); + + it('ignores an old country page after explicitly refreshing the same country', async () => { + const page = pending(); + const refresh = pending(); + vi.spyOn(ProviderManager.prototype, 'countries').mockResolvedValue([{...country, stationCount: 300}]); + const byCountry = vi.spyOn(ProviderManager.prototype, 'byCountry') + .mockResolvedValueOnce(Array.from({length: 120}, (_, index) => ({...station, id: `first-${index}`}))) + .mockReturnValueOnce(page.promise) + .mockReturnValueOnce(refresh.promise); + const app = await open(); + await app.input('5'); await app.input('\r'); + await app.input('\u001B[B'.repeat(119)); + expect(byCountry).toHaveBeenLastCalledWith('CA', 120, 120); + await app.input(':country CA\r'); + expect(byCountry).toHaveBeenLastCalledWith('CA', 120, 0); + await act(async () => {page.finish([{...station, id: 'obsolete-page'}]);}); + expect(app.state().loadingStations).toBe(true); + expect(app.state().displayStations.some(station => station.id === 'obsolete-page')).toBe(false); + await act(async () => {refresh.finish([station]);}); + expect(app.state().displayStations).toEqual([station]); + expect(app.state().loadingStations).toBe(false); + }); + + it('ignores a previous query page without clearing the loading state for the new query', async () => { + const page = pending(); + const next = pending(); + const search = vi.spyOn(ProviderManager.prototype, 'search') + .mockResolvedValueOnce(Array.from({length: 90}, (_, index) => ({...station, id: `first-${index}`}))) + .mockReturnValueOnce(page.promise) + .mockReturnValueOnce(next.promise); + const app = await open(); + await app.input(':search jazz\r'); + await app.input('\u001B[B'.repeat(89)); await app.input('\u001B[27u'); + expect(search.mock.calls.at(-1)?.[2]?.offset).toBe(90); + await app.input(':search rock\r'); + expect(search.mock.calls.at(-1)?.[0]).toBe('rock'); + await act(async () => {page.finish([{...station, id: 'obsolete-page'}]);}); + expect(app.state().loadingStations).toBe(true); + expect(app.state().displayStations.some(station => station.id === 'obsolete-page')).toBe(false); + await act(async () => {next.finish([station]);}); + expect(app.state().displayStations).toEqual([station]); + expect(app.state().loadingStations).toBe(false); + }); + + it('restarts server-filtered search after changing or clearing filters and discards an obsolete page', async () => { + const oldPage = pending(); + const original = Array.from({length: 90}, (_, index) => ({...station, id: `low-${index}`, bitrate: 64})); + const high = {...station, id: 'high-quality', bitrate: 320}; + const search = vi.mocked(ProviderManager.prototype.searchPage).mockImplementation(async (_query, _settings, options = {}) => { + if (options.cursor) return oldPage.promise; + return options.minBitrate === 192 + ? {items: [high], exhausted: true, sources: []} + : {items: original, exhausted: false, nextCursor: 'unfiltered:1', sources: []}; + }); + const app = await open(); + await app.input(':search jazz\r'); + await app.input('\u001B[B'.repeat(89)); + const oldSignal = search.mock.calls.at(-1)?.[2]?.signal; + expect(search.mock.calls.at(-1)?.[2]?.cursor).toBe('unfiltered:1'); + await app.input('\u001B[27u'); + await app.input(':bitrate 192\r'); + expect(oldSignal?.aborted).toBe(true); + expect(search.mock.calls.at(-1)?.[2]).toMatchObject({minBitrate: 192}); + expect(search.mock.calls.at(-1)?.[2]?.cursor).toBeUndefined(); + expect(app.state().displayStations).toEqual([high]); + await act(async () => {oldPage.finish({items: [{...high, id: 'obsolete'}], exhausted: true, sources: []});}); + expect(app.state().displayStations).toEqual([high]); + await app.input('\u001B[27u'); + await app.input(':clear\r'); + expect(search.mock.calls.at(-1)?.[2]?.cursor).toBeUndefined(); + expect(search.mock.calls.at(-1)?.[2]?.minBitrate).toBeUndefined(); + expect(app.state().displayStations).toEqual(original); + expect(search).toHaveBeenCalledTimes(4); + }); + + it('restarts a submitted search when the enabled directory set changes', async () => { + const search = vi.mocked(ProviderManager.prototype.searchPage).mockImplementation(async (_query, settings) => ({ + items: [{...station, id: settings.enableRadioGarden ? 'with-garden' : 'browser-only'}], exhausted: true, sources: [] + })); + const app = await open(); + await app.input(':search jazz\r'); + await act(async () => { + new JsonLibraryStore(app.store.filePath).updateSettings({enableRadioGarden: true}); + await vi.advanceTimersByTimeAsync(1000); + }); + expect(search).toHaveBeenCalledTimes(2); + expect(search.mock.calls.at(-1)?.[1].enableRadioGarden).toBe(true); + expect(search.mock.calls.at(-1)?.[2]?.cursor).toBeUndefined(); + expect(app.state().displayStations[0]?.id).toBe('with-garden'); + }); + + it.each([0, 10])('continues a country cursor with only %i visible rows on the first raw page', async visibleCount => { + vi.spyOn(ProviderManager.prototype, 'countries').mockResolvedValue([{...country, stationCount: 300}]); + const first = Array.from({length: 120}, (_, index) => ({...station, id: `first-${index}`, bitrate: index < visibleCount ? 320 : 64})); + const later = {...station, id: 'later-match', bitrate: 320}; + const pages = vi.mocked(ProviderManager.prototype.byCountryPage) + .mockResolvedValueOnce({items: first, exhausted: false, nextCursor: 'raw:120', sources: []}) + .mockResolvedValueOnce({items: [later], exhausted: true, sources: []}); + const app = await open(); + await app.input(':bitrate 192\r'); + await app.input(':country CA\r'); + expect(pages).toHaveBeenCalledTimes(2); + expect(pages.mock.calls[1]?.[1]?.cursor).toBe('raw:120'); + expect(app.state().displayStations).toHaveLength(visibleCount + 1); + expect(app.state().displayStations.at(-1)).toEqual(later); + expect(app.state().stationContext.stations).toHaveLength(121); + }); + + it('shows a partial directory failure while retaining available search results', async () => { + vi.mocked(ProviderManager.prototype.searchPage).mockResolvedValue({ + items: [station], exhausted: true, sources: [ + {provider: 'radio-browser', source: 'name', consumed: 1, exhausted: true, status: 'available'}, + {provider: 'radio-garden', source: 'search-snapshot', consumed: 0, exhausted: false, status: 'unavailable', message: 'Temporary outage.'} + ] + }); + const app = await open({settings: {enableRadioGarden: true}}); + await app.input(':search jazz\r'); + expect(app.state().displayStations).toEqual([station]); + expect(app.state().stationContext.error).toContain('Radio Garden'); + expect(app.frame()).toContain('Some sources are unavailable (Radio Garden)'); + }); +}); + +describe('Output setting cancellation', () => { + it.each((['darwin', 'linux'] as const).flatMap(platform => [false, true].map(stop => ({platform, stop}))))( + 'commits the complete receiver choice before later playback on $platform with Stop=$stop', async ({platform, stop}) => { + vi.mocked(backends.detectPlaybackBackends).mockReturnValue(['mpv', 'airplay']); + vi.mocked(airplay.discoverAirPlayDevices).mockResolvedValue([{id: 'receiver', name: 'Test receiver', host: '192.0.2.1', port: 7000, + txt: [], requiresPassword: false, airplay2: false}]); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async item => ({url: item.streamUrl!})); + let playback: PlaybackState = {backend: 'mpv', state: 'idle', volume: 70, muted: false, ready: false}; + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...playback})); + vi.mocked(PlayerController.prototype.stop).mockImplementation(async () => {playback = {...playback, state: 'stopped', ready: false};}); + const choose = vi.spyOn(PlayerController.prototype, 'selectAirPlayDevice'); + const usedOutputs: string[] = []; + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async function(this: PlayerController, item, url) { + const backend = this.diagnostics().preferredBackend === 'airplay' ? 'airplay' : 'mpv'; + usedOutputs.push(backend); + playback = {...playback, backend, state: 'playing', ready: true, stationName: item.name, streamUrl: url}; + }); + const {gate, updates} = delayFirstSettingsSave(); + const second = {...station, id: 'second-station', name: 'Second Radio', streamUrl: 'https://example.test/second'}; + const app = await open({platform, settings: {linuxAirPlayEnabled: true, preferredBackend: 'mpv'}, favorites: [station, second]}); + await app.input('2'); await app.input('\r'); + const nextStation = app.state().displayStations[1]!; + await app.input(':airplay\r'); await app.input('\r'); + expect(updates).toHaveBeenCalledTimes(1); + expect(updates).toHaveBeenCalledWith({preferredBackend: 'airplay', preferredAirPlayDevice: 'receiver'}); + expect(choose).not.toHaveBeenCalledWith('receiver'); + await app.input(':library\r'); await app.input('\u001B[B\r'); + expect(app.state().screen).toBe('library'); + expect(play).toHaveBeenCalledTimes(1); + if (stop) await app.input(':stop\r'); + await act(async () => {gate.finish();}); + expect(choose).toHaveBeenCalledWith('receiver'); + expect(app.store.snapshot().settings).toMatchObject({preferredBackend: 'airplay', preferredAirPlayDevice: 'receiver'}); + expect(app.state().library.settings).toMatchObject({preferredBackend: 'airplay', preferredAirPlayDevice: 'receiver'}); + if (stop) { + expect(play).toHaveBeenCalledTimes(1); + expect(playback.state).toBe('stopped'); + } else { + expect(play).toHaveBeenCalledTimes(2); + expect(play).toHaveBeenLastCalledWith(nextStation, nextStation.streamUrl, expect.anything()); + expect(usedOutputs).toEqual(['mpv', 'airplay']); + } + } + ); + + it.each([false, true])('orders a new station after a pending output save and honors Stop=%s', async stop => { + vi.mocked(backends.detectPlaybackBackends).mockReturnValue(['mpv', 'ffplay']); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async item => ({url: item.streamUrl!})); + let playback: PlaybackState = {backend: 'mpv', state: 'idle', volume: 70, muted: false, ready: false}; + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...playback})); + vi.mocked(PlayerController.prototype.stop).mockImplementation(async () => {playback = {...playback, state: 'stopped', ready: false};}); + const usedOutputs: string[] = []; + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (item, url) => { + const backend = app.store.snapshot().settings.preferredBackend === 'ffplay' ? 'ffplay' : 'mpv'; + usedOutputs.push(backend); + playback = {...playback, backend, state: 'playing', ready: true, stationName: item.name, streamUrl: url}; + }); + const {gate} = delayFirstSettingsSave(); + const second = {...station, id: 'second-station', name: 'Second Radio', streamUrl: 'https://example.test/second'}; + const app = await open({settings: {preferredBackend: 'mpv'}, favorites: [station, second]}); + await app.input('2'); await app.input('\r'); + const nextStation = app.state().displayStations[1]!; + expect(play).toHaveBeenCalledTimes(1); + await app.input('o'); + await app.input('\u001B[B\r'); + expect(play).toHaveBeenCalledTimes(1); + if (stop) await app.input(':stop\r'); + await act(async () => {gate.finish();}); + expect(app.store.snapshot().settings.preferredBackend).toBe('ffplay'); + if (stop) { + expect(play).toHaveBeenCalledTimes(1); + expect(playback.state).toBe('stopped'); + } else { + expect(play).toHaveBeenCalledTimes(2); + expect(play).toHaveBeenLastCalledWith(nextStation, nextStation.streamUrl, expect.anything()); + expect(usedOutputs).toEqual(['mpv', 'ffplay']); + } + }); + + it('applies the final rapid output cycle while an earlier retune is still loading', async () => { + vi.mocked(backends.detectPlaybackBackends).mockReturnValue(['mpv', 'ffplay', 'vlc']); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async item => ({url: item.streamUrl!})); + let playback: PlaybackState = {backend: 'mpv', state: 'idle', volume: 70, muted: false, ready: false}; + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...playback})); + vi.mocked(PlayerController.prototype.stop).mockImplementation(async () => {playback = {...playback, state: 'stopped', ready: false};}); + const firstRetune = pending(); + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (item, url) => { + const backend = app.store.snapshot().settings.preferredBackend; + if (backend === 'ffplay') { + // Keep the old observed output while the first replacement is starting. + playback = {...playback, state: 'loading', ready: false}; + await firstRetune.promise; + return; + } + playback = {...playback, backend: backend === 'vlc' ? 'vlc' : 'mpv', state: 'playing', ready: true, stationName: item.name, streamUrl: url}; + }); + const app = await open({settings: {preferredBackend: 'mpv'}}); + await app.input('2'); await app.input('\r'); + await app.input('o'); + expect(play).toHaveBeenCalledTimes(2); + expect(playback.state).toBe('loading'); + await app.input('o'); + await act(async () => {firstRetune.finish();}); + expect(app.store.snapshot().settings.preferredBackend).toBe('vlc'); + expect(playback.backend).toBe('vlc'); + expect(playback.state).toBe('playing'); + }); + + it.each(['cycle', 'local-receiver', 'airplay-receiver'] as const)('does not resume after Stop interrupts a pending %s output save', async action => { + vi.mocked(backends.detectPlaybackBackends).mockReturnValue(['mpv', 'airplay']); + vi.mocked(airplay.discoverAirPlayDevices).mockResolvedValue([{id: 'receiver', name: 'Test receiver', host: '192.0.2.1', port: 7000, + local: action === 'local-receiver', txt: [], requiresPassword: false, airplay2: false}]); + vi.spyOn(ProviderManager.prototype, 'resolve').mockImplementation(async item => ({url: item.streamUrl!})); + let playback: PlaybackState = {backend: 'mpv', state: 'idle', volume: 70, muted: false, ready: false}; + vi.spyOn(PlayerController.prototype, 'getState').mockImplementation(() => ({...playback})); + vi.mocked(PlayerController.prototype.stop).mockImplementation(async () => {playback = {...playback, state: 'stopped', ready: false};}); + const play = vi.spyOn(PlayerController.prototype, 'play').mockImplementation(async (item, url) => { + playback = {...playback, state: 'playing', ready: true, stationName: item.name, streamUrl: url}; + }); + const saved = pending(); + const originalUpdate = LibraryRepository.prototype.updateSettings; + const updates = vi.spyOn(LibraryRepository.prototype, 'updateSettings').mockImplementation(async function(this: LibraryRepository, settings) { + const state = await originalUpdate.call(this, settings); + if (settings.preferredBackend) await saved.promise; + return state; + }); + const app = await open({platform: 'darwin'}); + await app.input('2'); await app.input('\r'); + expect(play).toHaveBeenCalledTimes(1); + if (action === 'cycle') await app.input('o'); + else {await app.input(':airplay\r'); await app.input('\r');} + expect(updates.mock.calls.some(([settings]) => Boolean(settings.preferredBackend))).toBe(true); + await app.input(':stop\r'); + expect(playback.state).toBe('stopped'); + await act(async () => {saved.finish();}); + expect(play).toHaveBeenCalledTimes(1); + expect(playback.state).toBe('stopped'); + }); +}); diff --git a/src/ui/portable-app.test.tsx b/src/ui/portable-app.test.tsx index 8f42aed..cfde033 100644 --- a/src/ui/portable-app.test.tsx +++ b/src/ui/portable-app.test.tsx @@ -10,10 +10,11 @@ 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 {TuiSession} from '../agent/tui-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 type {AppSettings, 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'; @@ -38,6 +39,12 @@ beforeEach(() => { vi.spyOn(backendInstall, 'detectPlaybackBackends').mockReturnValue(['mpv']); vi.spyOn(airplayDiscovery, 'discoverAirPlayDevices').mockResolvedValue([]); vi.spyOn(ProviderManager.prototype, 'health').mockResolvedValue({}); + vi.spyOn(ProviderManager.prototype, 'searchPage').mockImplementation(async function(this: ProviderManager, query, settings, options) { + return {items: await this.search(query, settings, options), exhausted: true, sources: []}; + }); + vi.spyOn(ProviderManager.prototype, 'byCountryPage').mockImplementation(async function(this: ProviderManager, code, options) { + return {items: await this.byCountry(code, options?.limit), exhausted: true, sources: []}; + }); vi.spyOn(session, 'startRadioSession').mockResolvedValue({close: async () => undefined}); vi.spyOn(presence, 'registerTuiPresence').mockReturnValue(() => undefined); vi.spyOn(PlayerController.prototype, 'stop').mockResolvedValue(undefined); @@ -52,6 +59,46 @@ afterEach(() => { }); describe('App terminal integration', () => { + it('restores Now Playing and sends transport controls to the surviving playback owner', async () => { + const first: Station = {id: 'recovered-first', provider: 'playlist', name: 'Recovered Radio', tags: [], streamUrl: 'https://example.test/recovered'}; + const second: Station = {...first, id: 'recovered-next', name: 'Recovered Next'}; + const status: session.RadioSessionStatus = { + owner: 'tui', station: first, queue: [first, second], nowPlaying: {title: 'Artist - Surviving Song', updatedAt: '2026-09-07T12:00:00Z'}, + playback: {backend: 'mpv', state: 'playing', volume: 40, muted: false, ready: true, stationName: first.name, streamUrl: first.streamUrl}, + tui: {tuningStation: null, selectionRequired: false, desktopMediaCompatible: true, devices: [], diagnostics: { + backend: 'mpv', availableBackends: ['mpv'], preferredBackend: 'mpv', active: true, ready: true, volume: 40, muted: false + }} + }; + const call = vi.fn(async (command: session.RadioSessionCommand): Promise => { + if (command.type === 'tui-adjust-volume') status.playback = {...status.playback, volume: status.playback.volume + command.delta}; + if (command.type === 'tui-toggle-muted') status.playback = {...status.playback, muted: !status.playback.muted}; + if (command.type === 'tui-toggle-pause') status.playback = {...status.playback, state: 'paused'}; + if (command.type === 'play') { status.station = command.station; status.queue = command.queue ?? [command.station]; status.playback = {...status.playback, state: 'playing', stationName: command.station.name}; } + return {ok: true, message: 'ok', status: structuredClone(status)}; + }); + vi.spyOn(session, 'ensureRadioSession').mockResolvedValue({call, status: async () => structuredClone(status)}); + const play = vi.spyOn(PlayerController.prototype, 'play'); + const resolve = vi.spyOn(ProviderManager.prototype, 'resolve'); + const playbackSession = await TuiSession.connect(); + try { + const {input, lastFrame} = await openApp(false, false, process.platform, {resumeOnLaunch: true}, {columns: 100, rows: 30}, playbackSession); + expect(lastFrame()).toContain('Recovered Radio'); + expect(lastFrame()).toContain('Artist - Surviving Song'); + await input('+'); + expect(playbackSession.status.playback.volume).toBe(45); + await input('m'); + expect(playbackSession.status.playback.muted).toBe(true); + await input(' '); + expect(playbackSession.status.playback.state).toBe('paused'); + await input('.'); + expect(lastFrame()).toContain('Recovered Next'); + expect(call).toHaveBeenCalledWith(expect.objectContaining({type: 'play', station: second, queue: [first, second]})); + expect(play).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + expect(session.startRadioSession).not.toHaveBeenCalled(); + } finally { await act(async () => { await playbackSession.close(); }); } + }); + 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]); @@ -69,7 +116,7 @@ describe('App terminal integration', () => { await input('\r'); expect(search).toHaveBeenCalledTimes(1); - expect(play).toHaveBeenCalledWith(result, 'https://example.test/search-result'); + expect(play).toHaveBeenCalledWith(result, 'https://example.test/search-result', expect.objectContaining({signal: expect.any(AbortSignal)})); }); it.each([ @@ -161,25 +208,75 @@ describe('App terminal integration', () => { await expectNoticeToExpire(lastFrame, 'Added to favorites: Country FM'); }, 20_000); - it('blocks AirPlay navigation with a clear macOS-only notice on unsupported systems', async () => { - const {input, lastFrame} = await openApp(false, false, 'linux'); + it.each([{platform: 'linux' as const, message: 'Experimental Linux AirPlay is disabled'}, {platform: 'win32' as const, message: 'not supported in this environment'}])('hides unavailable AirPlay while retaining explicit guidance on $platform', async ({platform, message}) => { + const {input, lastFrame} = await openApp(false, false, platform); await input('9'); expect(lastFrame()).not.toContain('a AirPlay'); - + expect(lastFrame()).not.toContain('volume, AirPlay'); + expect(lastFrame()).toContain('6 settings'); await input('a'); - expect(lastFrame()).toContain('AirPlay output is available only on macOS'); - expect(lastFrame()).toContain('Settings'); + expect(lastFrame()).toContain(message); 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'); + expect(lastFrame()).not.toContain('AirPlay receiver'); await input('\u001B[B'); - expect(lastFrame()).toContain('AirPlay receiver (macOS only)'); + // Removing the receiver row must also remove it from keyboard dispatch. + const volume = vi.spyOn(PlayerController.prototype, 'setVolume').mockResolvedValue({ok: true}); await input('\r'); - expect(lastFrame()).toContain('AirPlay output is available only on macOS'); - expect(lastFrame()).not.toContain('Choose where AirPlay playback should go'); + expect(volume).toHaveBeenCalled(); + await input(':'); await input('airplay-code'); await input('\r'); + expect(lastFrame()).toContain(message); + expect(lastFrame()).not.toContain('Type the code'); + }); + + it.each(['ffmpeg', 'avahi-browse', 'sender'])('returns actionable active TUI RPC failures when Linux lacks %s', async missing => { + vi.mocked(backendInstall.detectPlaybackBackends).mockRestore(); + const detect = backendInstall.detectPlaybackBackends; + vi.spyOn(backendInstall, 'detectPlaybackBackends').mockImplementation(options => detect({...options, + hasCommand: command => ['mpv', 'ffmpeg', 'avahi-browse'].includes(command) && command !== missing, + hasAirPlaySender: () => missing !== 'sender' + })); + const {input, lastFrame} = await openApp(false, false, 'linux', {linuxAirPlayEnabled: true}); + const handle = vi.mocked(session.startRadioSession).mock.calls.at(-1)![0]; + for (const command of [{type: 'airplay-list'}, {type: 'airplay-select', deviceId: 'saved'}] as session.RadioSessionCommand[]) { + let result!: session.RadioSessionResult; + await act(async () => { result = await handle(command, 'cli'); }); + expect(result).toMatchObject({ok: false, message: expect.stringContaining('setup --airplay')}); + expect(result.message).not.toContain('No AirPlay receivers'); + } + expect(airplayDiscovery.discoverAirPlayDevices).not.toHaveBeenCalled(); + await input('9'); await input('a'); + expect(lastFrame()).toContain('Setup required'); + expect(lastFrame()).toContain('setup --airplay'); + }); + + it('retains Linux Setup required controls when opted in without prerequisites', async () => { + const {input, lastFrame} = await openApp(false, false, 'linux', {linuxAirPlayEnabled: true}); + await input('9'); + expect(lastFrame()).toContain('a AirPlay'); + await input('\r'); + expect(lastFrame()).toContain('AirPlay receiver'); + expect(lastFrame()).toContain('Setup required'); + await input('a'); + expect(lastFrame()).toContain('Setup required'); + expect(lastFrame()).toContain('setup --airplay'); + expect(airplayDiscovery.discoverAirPlayDevices).not.toHaveBeenCalled(); + }); + + it.each([{columns: 100, rows: 30}, {columns: 60, rows: 18}, {columns: 26, rows: 10}])('keeps hidden playback rows and keyboard selection aligned at $columns × $rows', async size => { + const volume = vi.spyOn(PlayerController.prototype, 'setVolume').mockResolvedValue({ok: true}); + const {input, lastFrame} = await openApp(false, false, 'linux', {}, size); + await input('9'); await input('\r'); + expect(lastFrame()).not.toContain('AirPlay receiver'); + await input('\u001B[B'); await input('\r'); + expect(volume).toHaveBeenCalled(); + await input(':'); await input('air'); await input('\t'); + expect(lastFrame()).not.toContain('airplay'); + await input('\u001B'); + // Last visible item remains reachable with the filtered row count. + for (let index = 0; index < 12; index++) await input('\u001B[B'); + expect(lastFrame()).not.toContain('AirPlay receiver'); }); it('keeps AirPlay navigation available on macOS', async () => { @@ -190,6 +287,46 @@ describe('App terminal integration', () => { expect(lastFrame()).toContain('Choose where AirPlay playback should go'); }); + it('keeps Linux discovery explicit even with saved AirPlay preferences and supports picker, rescan, and code entry', async () => { + vi.mocked(backendInstall.detectPlaybackBackends).mockReturnValue(['mpv', 'airplay']); + const receiver = {id: 'ID@Office', name: 'Office', host: '192.168.1.10', port: 5000, txt: ['pw=true'], requiresPassword: true, airplay2: false}; + vi.mocked(airplayDiscovery.discoverAirPlayDevices).mockResolvedValue([receiver]); + const choose = vi.spyOn(PlayerController.prototype, 'selectAirPlayDevice'); + const code = vi.spyOn(PlayerController.prototype, 'submitAirPlayPasscode').mockReturnValue({ok: true, message: 'AirPlay code sent.'}); + const {store, input, lastFrame} = await openApp(false, false, 'linux', {linuxAirPlayEnabled: true, preferredBackend: 'airplay', preferredAirPlayDevice: receiver.id}); + expect(airplayDiscovery.discoverAirPlayDevices).not.toHaveBeenCalled(); + expect(choose).not.toHaveBeenCalled(); + await input('9'); + await input('a'); + expect(airplayDiscovery.discoverAirPlayDevices).toHaveBeenCalledTimes(1); + expect(lastFrame()).toContain('Automatic'); + await input('r'); + expect(airplayDiscovery.discoverAirPlayDevices).toHaveBeenCalledTimes(2); + await input('\r'); + expect(choose).toHaveBeenCalledWith(receiver.id); + expect(store.snapshot().settings.preferredBackend).toBe('airplay'); + await input('c'); + expect(lastFrame()).toContain('AirPlay Code'); + await input('1234'); + expect(lastFrame()).toContain('****'); + await input('\r'); + expect(code).toHaveBeenCalledWith('1234'); + expect(JSON.stringify(store.snapshot())).not.toContain('1234'); + }); + + it('shows an Avahi service failure on explicit refresh without breaking local library navigation', async () => { + vi.mocked(backendInstall.detectPlaybackBackends).mockReturnValue(['mpv', 'airplay']); + vi.mocked(airplayDiscovery.discoverAirPlayDevices).mockRejectedValue(new Error('The Avahi service is not ready.')); + const {input, lastFrame} = await openApp(false, false, 'linux', {linuxAirPlayEnabled: true}); + await input('9'); + await input('a'); + expect(lastFrame()).toContain('The Avahi service is not ready.'); + await input('b'); + await input('b'); + await input('2'); + expect(lastFrame()).toContain('Portable Radio'); + }); + 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); @@ -264,20 +401,20 @@ function setPlayback(state: 'playing' | 'loading'): void { vi.spyOn(PlayerController.prototype, 'onChange').mockImplementation(listener => { listener(playback); return () => undefined; }); } -async function openApp(screenReader: boolean, redirected = false, platform: NodeJS.Platform = process.platform) { +async function openApp(screenReader: boolean, redirected = false, platform: NodeJS.Platform = process.platform, initialSettings: Partial = {}, size = {columns: 100, rows: 30}, playbackSession?: TuiSession) { 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.updateSettings({resumeOnLaunch: false, automaticUpdateChecks: false, asciiMode: false, reduceMotion: false, transparentBackground: false, ...initialSettings}); 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 stdout = Object.assign(new Writable({write(chunk, _encoding, done) { frames.push(String(chunk)); done(); }}), {...size, ...(!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'}})); + instances.push(render(, {stdout, stderr, stdin, debug: true, patchConsole: false, exitOnCtrlC: false, isScreenReaderEnabled: screenReader, kittyKeyboard: {mode: 'disabled'}})); }); expect(errors.join('')).toBe(''); return { diff --git a/src/ui/receiver-animation.test.tsx b/src/ui/receiver-animation.test.tsx index a87d327..0412bc9 100644 --- a/src/ui/receiver-animation.test.tsx +++ b/src/ui/receiver-animation.test.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, {act} from 'react'; import {Box, Text} from 'ink'; -import {render} from 'ink-testing-library'; -import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import type {PlaybackState} from '../types.js'; import {ReceiverAnimationProvider, useReceiverPulse} from './receiver-animation.js'; @@ -14,7 +14,12 @@ const playing: PlaybackState = { }; describe('ReceiverAnimationProvider', () => { - afterEach(() => vi.useRealTimers()); + beforeEach(() => vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)); + afterEach(() => { + act(() => cleanup()); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); it('updates only pulse consumers instead of rerendering static application siblings', async () => { vi.useFakeTimers(); @@ -44,7 +49,8 @@ describe('ReceiverAnimationProvider', () => { expect(staticRenders).toBe(1); expect(receiverRenders).toBe(1); - await vi.advanceTimersByTimeAsync(240); + // Flush React's scheduled work before inspecting the committed Ink frame. + await act(async () => { await vi.advanceTimersByTimeAsync(240); }); expect(staticRenders).toBe(1); expect(receiverRenders).toBeGreaterThan(1); expect(view.lastFrame()).toContain('3'); @@ -64,7 +70,7 @@ describe('ReceiverAnimationProvider', () => { ); - await vi.advanceTimersByTimeAsync(500); + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); expect(view.frames).toHaveLength(1); view.unmount(); }); diff --git a/src/ui/rtl-output.test.tsx b/src/ui/rtl-output.test.tsx new file mode 100644 index 0000000..189b5de --- /dev/null +++ b/src/ui/rtl-output.test.tsx @@ -0,0 +1,306 @@ +import {createRequire} from 'node:module'; +import {PassThrough} from 'node:stream'; +import type {WriteStream} from 'node:tty'; +import {pathToFileURL} from 'node:url'; +import {stripVTControlCharacters} from 'node:util'; +import {act, type ComponentProps} from 'react'; +import {Box, render as inkRender, renderToString} from 'ink'; +import {cleanup, render} from 'ink-testing-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {rightToLeftPattern} from './bidi-ranges.js'; +import {DisplayContext, resolveDisplayMode} from './display-context.js'; +import {displayWidth} from './format.js'; +import {rtlSafeOutput} from './rtl-output.js'; +import {NowPlayingScreen} from './screens/NowPlayingScreen.js'; + +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 reportedName = 'Radio Wa3 (راديو وع)'; +const props: ComponentProps = { + station: {id: 'wa3', provider: 'radio-browser', name: reportedName, country: 'France', tags: []}, + playback: {backend: 'mpv', state: 'playing', ready: true, volume: 70, muted: false}, + metadata: {title: 'Svarog – The Way To', updatedAt: '2026-10-17T12:00:00Z'}, + theme: 'green', favorite: false, diagnostics: {backend: 'mpv', availableBackends: ['mpv'], preferredBackend: 'auto', active: true, volume: 70, muted: false, ready: true}, + showDiagnostics: false, stationTime: '12:00', receiverStyle: 'gallop-fine', trackHistory: [], width: 74, height: 16 +}; + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubEnv('INK_SCREEN_READER', 'false'); + vi.stubEnv('TERM_PROGRAM', undefined); +}); +afterEach(() => { + act(() => cleanup()); + inkChalk.level = originalColorLevel; + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +function output(isolate = true): {stream: WriteStream; written: () => string} { + const sink = new PassThrough(); + let written = ''; + sink.on('data', chunk => { written += String(chunk); }); + return {stream: rtlSafeOutput(sink as unknown as WriteStream, {TERM_PROGRAM: isolate ? 'Apple_Terminal' : undefined, TERM: 'xterm-256color'}), written: () => written}; +} + +function frame(element: Parameters[0], columns: number): string { + let value = ''; + act(() => { value = renderToString(element, {columns}); }); + return value; +} + +function visibleText(value: string): string { + return stripVTControlCharacters(value).replace(/[\u2067\u2069]/gu, ''); +} + +// Fault injection: let RTL glyph advances differ from the layout's cell widths. +// This tests containment, not any particular OS's Arabic shaping algorithm. +// An explicit column position must recover the suffix independently of that +// discrepancy. The unanchored screenshot row shifts PLAYING by seven cells. +function paintedRow(value: string, rtlAdvance = 1): string { + const row: string[] = []; + let column = 0; + for (const token of value.matchAll(/\u001B\[[\d;?]*[A-Za-z]|[^\u001B]/gu)) { + const character = token[0]; + const at = /^\u001B\[(\d*)G$/u.exec(character); + if (at) column = (Number(at[1]) || 1) - 1; + else if (character.startsWith('\u001B')) continue; + else { + row[column] = character; + const width = displayWidth(character); + column += rightToLeftPattern.test(character) ? rtlAdvance : width; + } + } + // Index cells, not UTF-16 code units from emoji/supplementary glyphs. + return Array.from({length: row.length}, (_, index) => { + const cell = row[index] ?? ' '; + return cell.length === 1 ? cell : '@'; + }).join(''); +} + +describe('RTL terminal field containment', () => { + it.each([ + {TERM_PROGRAM: 'Apple_Terminal', TERM: 'xterm-256color', isolate: true}, + {TERM_PROGRAM: 'Apple_Terminal', TERM: 'linux', isolate: false}, + {TERM_PROGRAM: undefined, TERM: 'linux', isolate: false}, + {TERM_PROGRAM: undefined, TERM: 'cons25', isolate: false}, + {TERM_PROGRAM: 'Windows_Terminal', TERM: 'xterm-256color', isolate: false}, + {TERM_PROGRAM: undefined, TERM: 'xterm-256color', isolate: false} + ])('uses compatible RTL output for %j', ({isolate, ...env}) => { + const sink = new PassThrough(); let written = ''; + sink.on('data', chunk => { written += String(chunk); }); + rtlSafeOutput(sink as unknown as WriteStream, env).write('Radio راديو PLAYING │'); + expect(written).toContain('\u001B[7G'); + expect(written.includes('\u2067')).toBe(isolate); + expect(written.includes('\u2069')).toBe(isolate); + }); + it.each([false, true])('anchors the same fields with Unicode isolation=%s', isolate => { + const capture = output(isolate); + const original = 'Radio Wa3 (راديو وع) · FRANCE PLAYING │'; + capture.stream.write(original); + expect(visibleText(capture.written())).toBe(original); + expect(capture.written().includes('\u2067')).toBe(isolate); + expect(capture.written().includes('\u2069')).toBe(isolate); + expect(paintedRow(capture.written(), 2).indexOf('PLAYING')).toBe(original.indexOf('PLAYING')); + expect(paintedRow(capture.written(), 2).lastIndexOf('│')).toBe(original.length - 1); + }); + + it('defaults to anchors without Unicode format marks for unknown terminals', () => { + const sink = new PassThrough(); let written = ''; + sink.on('data', chunk => { written += String(chunk); }); + rtlSafeOutput(sink as unknown as WriteStream).write('Radio راديو PLAYING │'); + expect(written).toContain('\u001B[7Gراديو\u001B[12G'); + expect(written).not.toMatch(/[\u2066-\u2069]/u); + }); + it('keeps PLAYING and the border fixed when the reported Arabic name shapes wider', () => { + inkChalk.level = 0; + const row = frame(, props.width) + .split('\n').find(line => line.includes('PLAYING'))!; + expect(row).toContain(reportedName); + expect(paintedRow(row, 2).indexOf('PLAYING')).toBe(70); + const capture = output(); + capture.stream.write(row); + const anchored = capture.written(); + expect(visibleText(anchored)).toBe(row); + expect(displayWidth(anchored)).toBe(displayWidth(row)); + const isolated = anchored.match(/\u2067(.*?)\u2069/u)?.[1]; + expect(isolated).toBe('راديو وع'); + expect(isolated).not.toContain('PLAYING'); + for (const advance of [1, 2, 3]) { + const painted = paintedRow(anchored, advance); + expect(painted.indexOf('PLAYING')).toBe(63); + expect(painted.lastIndexOf('│')).toBe(73); + } + }); + + it('preserves Arabic word order, combining clusters and styles between the anchors', () => { + const capture = output(); + const name = '\u001B[1mرَادِيو وَع\u001B[22m'; + capture.stream.write(`Radio (${name}) · FRANCE PLAYING │`); + expect(capture.written()).toContain(`\u001B[1m\u001B[8G\u2067رَادِيو وَع\u2069\u001B[16G\u001B[22m)`); + expect(visibleText(capture.written())).toBe(`Radio (رَادِيو وَع) · FRANCE PLAYING │`); + }); + + it('keeps a final combining mark inside the isolate across a color boundary', () => { + const capture = output(); + const original = 'Radio (وع\u001B[31mَ\u001B[39m) PLAYING │'; + capture.stream.write(original); + expect(capture.written()).toContain('\u2067وع\u001B[31mَ\u2069'); + expect(visibleText(capture.written())).toBe(stripVTControlCharacters(original)); + expect(displayWidth(capture.written())).toBe(displayWidth(original)); + expect(paintedRow(capture.written()).indexOf('PLAYING')).toBe(displayWidth(stripVTControlCharacters(original).split('PLAYING')[0]!)); + }); + + it('measures an emoji cluster across styles before positioning an RTL run', () => { + const capture = output(); + const original = 'Radio 👩\u001B[31m🏽‍\u001B[39m💻 راديو PLAYING │'; + capture.stream.write(original); + expect(capture.written()).toContain('\u001B[10G\u2067راديو\u2069\u001B[15G'); + expect(visibleText(capture.written())).toBe(stripVTControlCharacters(original)); + expect(displayWidth(capture.written())).toBe(displayWidth(original)); + expect(paintedRow(capture.written()).indexOf('PLAYING')).toBe(displayWidth(stripVTControlCharacters(original).split('PLAYING')[0]!)); + }); + + it('leaves Latin, CJK, emoji and no-color output byte-for-byte unchanged', () => { + const capture = output(); + const frame = '\u001B[38;2;116;242;138m│ Café 東京 👩🏽‍💻 FM PLAYING │\u001B[39m\n'; + capture.stream.write(frame); + expect(capture.written()).toBe(frame); + }); + + it('tracks separate writes, CRLF, incremental redraws, horizontal offsets and cursor restore', () => { + const capture = output(); + for (const chunk of ['prefix ', 'راديو', ' suffix\r\n', '\u001B[2A\u001B[4G', 'وع', '\u001B[s\u001B[10G', 'وع', '\u001B[u', 'وع', '\r', 'وع']) capture.stream.write(chunk); + expect(capture.written().replace(/[\u2067\u2069]/gu, '')).toBe('prefix \u001B[8Gراديو\u001B[13G suffix\r\n\u001B[2A\u001B[4G\u001B[4Gوع\u001B[6G\u001B[s\u001B[10G\u001B[10Gوع\u001B[12G\u001B[u\u001B[6Gوع\u001B[8G\r\u001B[1Gوع\u001B[3G'); + }); + + it('does not inspect or modify OSC titles and hyperlinks', () => { + const capture = output(); + const frame = '\u001B]0;راديو\u0007\u001B]8;;https://example.test/وع\u001B\\Radio\u001B]8;;\u001B\\'; + capture.stream.write(frame); + expect(capture.written()).toBe(frame); + }); + + it('retains write callbacks, backpressure, resize events and live stream properties', async () => { + const sink = new PassThrough({highWaterMark: 1}); + const stream = rtlSafeOutput(sink as unknown as WriteStream); + const resized = vi.fn(); + stream.on('resize', resized); + Object.assign(sink, {columns: 74, isTTY: true}); + expect(stream.columns).toBe(74); + expect(stream.isTTY).toBe(true); + expect(stream.write('راديو', 'utf8')).toBe(false); + sink.emit('resize'); + expect(resized).toHaveBeenCalledOnce(); + Object.assign(sink, {columns: 98}); + expect(stream.columns).toBe(98); + sink.resume(); + await new Promise((resolve, reject) => stream.write('done', error => error ? reject(error) : resolve())); + stream.off('resize', resized); + sink.emit('resize'); + expect(resized).toHaveBeenCalledOnce(); + }); + + it('preserves binary writes and recovers positioning at the next known row', () => { + const capture = output(); + capture.stream.write(Buffer.from('prefix ')); + capture.stream.write('وع'); + capture.stream.write('\r\nوع'); + expect(capture.written()).toBe('prefix وع\r\n\u001B[1G\u2067وع\u2069\u001B[3G'); + }); + + it('isolates separate RTL runs without pulling intervening Latin text into RTL order', () => { + const capture = output(); + capture.stream.write('Radio راديو World العربية · PLAYING │'); + const runs = [...capture.written().matchAll(/\u2067(.*?)\u2069/gu)].map(match => match[1]); + expect(runs).toEqual(['راديو', 'العربية']); + expect(visibleText(capture.written())).toBe('Radio راديو World العربية · PLAYING │'); + }); +}); + +const profiles = [ + {name: 'truecolor', asciiMode: false, transparentBackground: false, color: 3}, + {name: '256-color', asciiMode: false, transparentBackground: false, color: 2}, + {name: '16-color', asciiMode: false, transparentBackground: true, color: 1}, + {name: 'no-color', asciiMode: false, transparentBackground: true, color: 0}, + {name: 'ASCII', asciiMode: true, transparentBackground: false, color: 3}, + {name: 'ASCII no-color', asciiMode: true, transparentBackground: true, color: 0} +]; +const names = [reportedName, 'رَادِيو وَع International', 'FM שלום World', 'رادیو ایران / Radio', 'اردو ریڈیو FM', 'Radio 東京 👩🏽‍💻 راديو', reportedName.repeat(8)]; +for (const profile of profiles.flatMap(value => [false, true].map(isolate => ({...value, isolate})))) { + describe(`Now playing RTL / ${profile.name} / isolation=${profile.isolate}`, () => { + it.each([62, 74, 98])('contains all playback states and international names at %i columns', width => { + inkChalk.level = profile.color; + const display = resolveDisplayMode(profile, {FORCE_COLOR: String(profile.color)}); + for (const name of names) for (const state of ['playing', 'loading', 'paused', 'stopped', 'idle', 'error'] as const) { + const rendered = frame( + + + , width + 2 + ); + const capture = output(profile.isolate); + capture.stream.write(rendered); + expect(visibleText(capture.written())).toBe(stripVTControlCharacters(rendered)); + const row = capture.written().split('\n').find(line => line.includes(state.toUpperCase()))!; + const expected = width - 3 - state.length; + for (const advance of [1, 2]) expect(paintedRow(row, advance).indexOf(state.toUpperCase()), `${name}: ${state}`).toBe(expected); + expect(displayWidth(stripVTControlCharacters(row))).toBeLessThanOrEqual(width + 2); + expect(paintedRow(row).lastIndexOf(profile.asciiMode ? '|' : '│')).toBe(width); + } + }); + }); +} + +it('retains containment through station changes, playback updates and resizes', () => { + inkChalk.level = 3; + let view!: ReturnType; + for (const width of [74, 98, 62, 74]) for (const name of [reportedName, 'Test FM', ...names]) { + act(() => { + const element = ; + if (view) view.rerender(element); else view = render(element); + }); + const capture = output(); + capture.stream.write(view.lastFrame()!); + const row = capture.written().split('\n').find(line => line.includes('PLAYING'))!; + expect(paintedRow(row, 2).indexOf('PLAYING')).toBe(width - 11); + expect(paintedRow(row).lastIndexOf('│')).toBe(width - 1); + } +}); + +it.each([{incrementalRendering: false, isolate: false}, {incrementalRendering: true, isolate: false}, {incrementalRendering: false, isolate: true}, {incrementalRendering: true, isolate: true}])('anchors real interactive Ink updates with %j', async ({incrementalRendering, isolate}) => { + inkChalk.level = 3; + const sink = Object.assign(new PassThrough(), {isTTY: true, columns: 76, rows: 30}); + const input = Object.assign(new PassThrough(), {isTTY: true, setRawMode: () => input}); + let written = ''; + sink.on('data', chunk => { written += String(chunk); }); + let view!: ReturnType; + act(() => { + view = inkRender(, { + stdout: rtlSafeOutput(sink as unknown as WriteStream, {TERM_PROGRAM: isolate ? 'Apple_Terminal' : undefined, TERM: 'xterm-256color'}), stdin: input as unknown as NodeJS.ReadStream, + stderr: sink as unknown as WriteStream, patchConsole: false, exitOnCtrlC: false, + interactive: true, incrementalRendering, kittyKeyboard: {mode: 'disabled'} + }); + }); + try { + await view.waitUntilRenderFlush(); + for (const width of [74, 62, 98]) { + written = ''; + sink.columns = width + 2; + act(() => { + sink.emit('resize'); + view.rerender(); + }); + await view.waitUntilRenderFlush(); + const row = written.split('\n').filter(line => line.includes('PAUSED')).at(-1)!; + expect(row).toBeDefined(); + // Erase/redraw prefixes precede a fresh column-zero row. + expect(paintedRow(row, 2).indexOf('PAUSED')).toBe(width - 10); + expect(visibleText(row)).toContain(reportedName); + expect(paintedRow(row).lastIndexOf('│')).toBe(width - 1); + } + } finally { + act(() => { view.unmount(); view.cleanup(); }); + input.destroy(); sink.destroy(); + } +}); diff --git a/src/ui/rtl-output.ts b/src/ui/rtl-output.ts new file mode 100644 index 0000000..884999b --- /dev/null +++ b/src/ui/rtl-output.ts @@ -0,0 +1,124 @@ +import type {WriteStream} from 'node:tty'; +import {stripVTControlCharacters} from 'node:util'; +import {rightToLeftPattern, strongDirectionPattern} from './bidi-ranges.js'; +import {displayWidth} from './format.js'; + +// Ink writes complete VT sequences. Keep OSC payloads (titles, hyperlinks) out +// of text analysis, and treat cursor controls as boundaries between text runs. +const controls = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[78@-_])|[\u0000-\u001F\u007F]/gu; +const sgr = /^\u001B\[[\d;:]*m$/u; +const segmenter = new Intl.Segmenter(undefined, {granularity: 'grapheme'}); + +/** + * Yoga's boxes become a single terminal row, not independently positioned + * fields. Guard fixed fields and borders against terminal-side shaping in + * mixed RTL rows by explicitly positioning the columns around each RTL run. + * Apple Terminal's xterm profile also gets Unicode isolates to contain stored + * row reordering independently of cursor reports. Keep those opt-in: older + * Linux/BSD consoles may print unsupported format marks. + * Ordinary LTR output stays byte-for-byte unchanged. + * + * Apply only to Ink's interactive stdout, after its cell layout/tokenization: + * inserting zero-width bidi marks into Text would itself consume Ink cells. + */ +export function rtlSafeOutput(stdout: WriteStream, env: NodeJS.ProcessEnv = process.env): WriteStream { + const isolate = env.TERM_PROGRAM === 'Apple_Terminal' && /^xterm(?:-|$)/i.test(env.TERM ?? ''); + let column: number | undefined = 0; + let savedColumn: number | undefined; + + const position = (control: string): void => { + if (control === '\n' || control === '\r') column = 0; + else if (control === '\b' && column !== undefined) column = Math.max(0, column - 1); + else if (control === '\t' && column !== undefined) column = (Math.floor(column / 8) + 1) * 8; + else if (control === '\u001B7' || control === '\u001B[s') savedColumn = column; + else if (control === '\u001B8' || control === '\u001B[u') column = savedColumn; + else { + const csi = /^\u001B\[([\d;]*)([G`CDaHEFf])$/u.exec(control); + if (!csi) return; + const parameters = csi[1]!.split(';').map(value => Number(value) || 1); + const command = csi[2]; + if (command === 'G' || command === '`') column = parameters[0]! - 1; + else if (command === 'H' || command === 'f') column = (parameters[1] ?? 1) - 1; + else if (command === 'E' || command === 'F') column = 0; + else if (column !== undefined) column = Math.max(0, column + (command === 'D' ? -1 : 1) * parameters[0]!); + } + }; + + const text = (span: string, endsRow = false): string => { + const plain = stripVTControlCharacters(span); + const hasRtl = rightToLeftPattern.test(plain); + // Animation frames contain many art-only rows. Their next newline resets + // the column, so do not remeasure thousands of unchanged visualizer cells. + if (!hasRtl && endsRow) return span; + const startColumn = column; + if (column !== undefined) column += displayWidth(plain); + if (startColumn === undefined || !hasRtl) return span; + + type Edge = {index: number; column: number}; + const runs: Array<{first: Edge; last: Edge}> = []; + let first: Edge | undefined; + let last: Edge | undefined; + let offset = startColumn; + const finish = (): void => { + if (first && last) runs.push({first, last}); + first = undefined; last = undefined; + }; + // Segment the complete visible text: SGR can occur inside a combining or + // emoji cluster. Map its UTF-16 edges back to the original styled span. + const indices: number[] = []; + let from = 0; + for (const match of span.matchAll(new RegExp(controls))) { + for (let index = from; index < match.index; index++) indices.push(index); + from = match.index + match[0].length; + } + for (let index = from; index < span.length; index++) indices.push(index); + // Neutral characters between RTL words belong to the same run, preserving + // Arabic word order without absorbing trailing padding or fixed fields. + for (const part of segmenter.segment(plain)) { + const width = displayWidth(part.segment); + const strong = strongDirectionPattern.exec(part.segment)?.[0]; + if (strong && rightToLeftPattern.test(strong)) { + first ??= {index: indices[part.index]!, column: offset}; + last = {index: indices[part.index + part.segment.length - 1]! + 1, column: offset + width}; + } else if (strong) finish(); + offset += width; + } + finish(); + const at = (value: number): string => `\u001B[${value + 1}G`; + let result = ''; + from = 0; + for (const run of runs) { + result += span.slice(from, run.first.index) + at(run.first.column) + + (isolate ? '\u2067' : '') + span.slice(run.first.index, run.last.index) + + (isolate ? '\u2069' : '') + at(run.last.column); + from = run.last.index; + } + return result + span.slice(from); + }; + + const transform = (value: string): string => { + let result = ''; + let from = 0; + for (const match of value.matchAll(new RegExp(controls))) { + if (sgr.test(match[0])) continue; + result += text(value.slice(from, match.index), match[0] === '\n' || match[0] === '\r') + match[0]; + position(match[0]); + from = match.index + match[0].length; + } + return result + text(value.slice(from)); + }; + + const write: WriteStream['write'] = (chunk: unknown, ...args: unknown[]): boolean => { + // Ink frames are strings. Preserve other writes and their encoding/callback + // contract; resume anchoring only after a known cursor position or newline. + if (typeof chunk !== 'string') column = undefined; + return Reflect.apply(stdout.write, stdout, [typeof chunk === 'string' ? transform(chunk) : chunk, ...args]) as boolean; + }; + return new Proxy(stdout, { + get(target, key) { + if (key === 'write') return write; + const value: unknown = Reflect.get(target, key, target); + return typeof value === 'function' ? value.bind(target) : value; + } + }); +} diff --git a/src/ui/screen-items.ts b/src/ui/screen-items.ts index 6beed1c..b5fa1cc 100644 --- a/src/ui/screen-items.ts +++ b/src/ui/screen-items.ts @@ -98,12 +98,19 @@ export const settingsItems = settingsGroups.flatMap(group => group.items) as rea export const settingsRootItems = settingsGroups.map(group => group.label); -export function settingsGroup(page: SettingsPage) { - return settingsGroups.find(group => group.id === page); +export function settingsGroupsForAirPlay(visible = true) { + return settingsGroups.map(group => ({...group, + description: !visible && group.id === 'playback' ? 'Output, volume, and startup behavior' : group.description, + items: group.items.filter(item => visible || item !== 'AirPlay receiver') + })); } -export function settingsItemsForPage(page: SettingsPage): readonly string[] { - return page === 'root' ? settingsRootItems : settingsGroup(page)?.items ?? []; +export function settingsGroup(page: SettingsPage, airPlayVisible = true) { + return settingsGroupsForAirPlay(airPlayVisible).find(group => group.id === page); +} + +export function settingsItemsForPage(page: SettingsPage, airPlayVisible = true): readonly string[] { + return page === 'root' ? settingsRootItems : settingsGroup(page, airPlayVisible)?.items ?? []; } export function settingsPageForRootItem(item: string | undefined): SettingsPage | undefined { diff --git a/src/ui/screens/AirPlaySettingsScreen.tsx b/src/ui/screens/AirPlaySettingsScreen.tsx index 13bacf3..9971189 100644 --- a/src/ui/screens/AirPlaySettingsScreen.tsx +++ b/src/ui/screens/AirPlaySettingsScreen.tsx @@ -16,6 +16,7 @@ import {useDisplay} from '../display-context.js'; import {toAsciiSafe} from '../ascii.js'; type AirPlaySettingsScreenProps = { + platform?: NodeJS.Platform; selected: number; settings: AppSettings; backends: string[]; @@ -26,6 +27,7 @@ type AirPlaySettingsScreenProps = { }; export function AirPlaySettingsScreen({ + platform = process.platform, selected, settings, backends, @@ -37,7 +39,7 @@ export function AirPlaySettingsScreen({ const {ascii} = useDisplay(); const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); - const availability = airPlayAvailability(backends, devices); + const availability = airPlayAvailability(backends, devices, platform); const selectedDevice = selectedAirPlayDevice(settings, devices); const selectedMissing = Boolean(settings.preferredAirPlayDevice && !selectedDevice); const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local; @@ -90,7 +92,7 @@ export function AirPlaySettingsScreen({ const isSelected = device.id === settings.preferredAirPlayDevice; const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35))); const selectedLabel = isSelected ? ' selected' : ''; - const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5)); + const detail = truncate(airPlayDeviceDetail(device, platform), Math.max(12, lineWidth - name.length - selectedLabel.length - 5)); return ( diff --git a/src/ui/screens/AlarmsScreen.tsx b/src/ui/screens/AlarmsScreen.tsx index 1c84a75..ab7eb3f 100644 --- a/src/ui/screens/AlarmsScreen.tsx +++ b/src/ui/screens/AlarmsScreen.tsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import {Box, Text} from 'ink'; import type {Alarm, ThemeName} from '../../types.js'; import {draftNextOccurrence, type AlarmDraft, type AlarmEditorControl, type AlarmEditorField, type TimeSegment} from '../alarm-editor.js'; import type {AlarmRuntimeSummary, AlarmVerificationReport, TuiActiveAlarm} from '../alarm-tui-service.js'; -import {nextOccurrenceForAlarm} from '../../alarms/schedule.js'; +import {AlarmOccurrenceCache} from '../../alarms/occurrence-cache.js'; import {stationKey} from '../../storage/store.js'; import {visibleWindow} from '../list-window.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; @@ -22,9 +22,11 @@ export function AlarmsScreen({alarms, selected, runtime, verification, deletingI const accent = themeAccent(theme); const {ascii, reduceMotion} = useDisplay(); const a = (value:string) => ascii ? toAsciiSafe(value) : value; const mode = requestedMode ?? (width < 34 ? 'micro' : width < 68 ? 'compact' : 'full'); + const occurrenceCache = useMemo(() => new AlarmOccurrenceCache(), []); if(verification)return ; const status = schedulerStatus(runtime, mode); - const next = alarms.map(alarm => ({alarm, occurrence: nextOccurrenceForAlarm(alarm, new Date())})).filter(item => item.occurrence).sort((a, b) => a.occurrence!.getTime() - b.occurrence!.getTime())[0]; + const now = new Date(); + const next = alarms.map(alarm => ({alarm, occurrence: occurrenceCache.next(alarm, now)})).filter(item => item.occurrence).sort((a, b) => a.occurrence!.getTime() - b.occurrence!.getTime())[0]; const noticeRows=height>=11?2:1; const rows = Math.max(1, height - 5 - noticeRows); const items: Array<{kind: 'create'}|{kind:'verify'}|{kind: 'alarm'; alarm: Alarm}> = [{kind: 'create'}, ...alarms.map(alarm => ({kind: 'alarm' as const, alarm})), {kind:'verify'}]; diff --git a/src/ui/screens/HelpScreen.tsx b/src/ui/screens/HelpScreen.tsx index 7a8c679..adfdc70 100644 --- a/src/ui/screens/HelpScreen.tsx +++ b/src/ui/screens/HelpScreen.tsx @@ -3,7 +3,7 @@ import {Box, Text} from 'ink'; import type {ThemeName} from '../../types.js'; import {textMuted, themeAccent} from '../theme.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; -import {commandHelp, keyHelpSections} from '../help-content.js'; +import {commandHelpForAirPlay, keyHelpSections} from '../help-content.js'; import {padDisplayEnd, truncate} from '../format.js'; import {visibleWindow} from '../list-window.js'; import {useDisplay} from '../display-context.js'; @@ -14,11 +14,12 @@ type HelpScreenProps = { width: number; height: number; selected: number; + airPlaySupported?: boolean; }; type HelpRow = {key: string; heading?: string; keys?: string; description?: string}; -export function HelpScreen({theme, width, height, selected}: HelpScreenProps): React.ReactElement { +export function HelpScreen({theme, width, height, selected, airPlaySupported = true}: HelpScreenProps): React.ReactElement { const {ascii} = useDisplay(); const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); @@ -30,7 +31,7 @@ export function HelpScreen({theme, width, height, selected}: HelpScreenProps): R ...section.entries.map(entry => ({key: `${section.title}-${entry.keys}`, ...entry})) ]), {key: 'commands', heading: 'Commands (press : then type)'}, - ...commandHelp.map(command => ({ + ...commandHelpForAirPlay(airPlaySupported).map(command => ({ key: `command-${command.name}`, keys: `:${command.name}${command.args ? ` ${command.args}` : ''}`, description: command.description diff --git a/src/ui/screens/SettingsScreen.tsx b/src/ui/screens/SettingsScreen.tsx index 0dc4d69..b0b219b 100644 --- a/src/ui/screens/SettingsScreen.tsx +++ b/src/ui/screens/SettingsScreen.tsx @@ -4,7 +4,7 @@ import {defaultAgentControlSettings, type AirPlayDevice, type AppSettings, type import {Menu, Pointer} from '../components/Menu.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; -import {settingsGroup, settingsGroups, settingsItemsForPage, type SettingsPage} from '../screen-items.js'; +import {settingsGroup, settingsGroupsForAirPlay, settingsItemsForPage, type SettingsPage} from '../screen-items.js'; import {textMuted, themeAccent} from '../theme.js'; import {playbackBackendCapabilities} from '../../player/backend-install.js'; import {airPlayReceiverSettingValue} from '../airplay-settings.js'; @@ -14,6 +14,7 @@ import {useDisplay} from '../display-context.js'; import {toAsciiSafe} from '../ascii.js'; type SettingsScreenProps = { + platform?: NodeJS.Platform; page?: SettingsPage; selected: number; settings: AppSettings; @@ -32,6 +33,7 @@ type SettingsScreenProps = { }; export function SettingsScreen({ + platform = process.platform, page = 'root', selected, settings, @@ -52,12 +54,12 @@ export function SettingsScreen({ const a = (value: string): string => ascii ? toAsciiSafe(value) : value; const accent = themeAccent(theme); const lineWidth = Math.max(32, width - 4); - const group = settingsGroup(page); - const pageItems = settingsItemsForPage(page); + const group = settingsGroup(page, airPlaySupported); + const pageItems = settingsItemsForPage(page, airPlaySupported); const menuWindow = settingsMenuWindow(pageItems, selected, height); const selectedItem = pageItems[selected]; const detail = page === 'root' && selectedItem !== 'Check for updates' - ? settingsGroupsDescription(selectedItem) + ? settingsGroupsDescription(selectedItem, airPlaySupported) : settingDetail({ item: selectedItem, appVersion, @@ -88,8 +90,8 @@ export function SettingsScreen({ render={(item, _index, active) => { const label = settingLabel(item, updateCheck, appVersion, airPlaySupported); const value = page === 'root' - ? settingsRootValue(item) - : settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion); + ? settingsRootValue(item, airPlaySupported) + : settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion, platform); return ( @@ -128,17 +130,17 @@ function settingsMenuWindow(items: readonly string[], selected: number, height: }; } -function settingsRootValue(item: string): string | undefined { - const group = settingsGroupByLabel(item); +function settingsRootValue(item: string, airPlayVisible: boolean): string | undefined { + const group = settingsGroupByLabel(item, airPlayVisible); return group ? `${group.items.length} settings ›` : undefined; } -function settingsGroupByLabel(label: string) { - return settingsGroups.find(group => group.label === label); +function settingsGroupByLabel(label: string, airPlayVisible: boolean) { + return settingsGroupsForAirPlay(airPlayVisible).find(group => group.label === label); } -function settingsGroupsDescription(label: string | undefined): string { - return label ? settingsGroupByLabel(label)?.description ?? 'Press Enter to open this category.' : ''; +function settingsGroupsDescription(label: string | undefined, airPlayVisible: boolean): string { + return label ? settingsGroupByLabel(label, airPlayVisible)?.description ?? 'Press Enter to open this category.' : ''; } export function settingValue( @@ -148,7 +150,8 @@ export function settingValue( backends: string[], airPlayDevices: AirPlayDevice[], updateCheck: UpdateCheckState | undefined, - currentVersion?: string + currentVersion?: string, + platform: NodeJS.Platform = process.platform ): string | undefined { switch (item) { case 'Cycle display color': @@ -164,7 +167,7 @@ export function settingValue( case 'Audio output': return audioOutputSettingValue(settings, diagnostics, backends); case 'AirPlay receiver': - return airPlayReceiverSettingValue(settings, airPlayDevices, backends); + return airPlayReceiverSettingValue(settings, airPlayDevices, backends, platform); case 'Mute or unmute': if (!playbackBackendCapabilities(diagnostics.backend).supportsMute) { return 'requires mpv'; @@ -207,7 +210,7 @@ export function settingValue( export function settingLabel(item: string, updateCheck: UpdateCheckState | undefined, currentVersion?: string, airPlaySupported = true): string { if (item === 'AirPlay receiver' && !airPlaySupported) { - return 'AirPlay receiver (macOS only)'; + return 'AirPlay receiver (unavailable)'; } 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 0d29de5..4d66f09 100644 --- a/src/ui/screens/StatsScreen.tsx +++ b/src/ui/screens/StatsScreen.tsx @@ -106,7 +106,10 @@ export function StatsScreen({library, theme, width, height}: StatsScreenProps): {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)} + + Active days: + {stats.activeDays}/{stats.totalTrackedDays} + {a('Less · ')} diff --git a/src/ui/screens/screen-render.test.tsx b/src/ui/screens/screen-render.test.tsx index 8dee3f8..571c6ce 100644 --- a/src/ui/screens/screen-render.test.tsx +++ b/src/ui/screens/screen-render.test.tsx @@ -252,7 +252,7 @@ describe('SettingsScreen rendering', () => { expect(frame).not.toContain('ASCII-safe display'); }); - it('labels AirPlay as macOS-only on unsupported operating systems', () => { + it('hides AirPlay on unsupported operating systems', () => { const {lastFrame} = render( { /> ); - expect(lastFrame()).toContain('AirPlay receiver (macOS only)'); + expect(lastFrame()).not.toContain('AirPlay receiver'); + expect(lastFrame()).toContain('Volume up'); }); it('changes the update settings row when an update is available', () => { diff --git a/src/ui/text-direction.test.ts b/src/ui/text-direction.test.ts new file mode 100644 index 0000000..d5124bc --- /dev/null +++ b/src/ui/text-direction.test.ts @@ -0,0 +1,44 @@ +import {describe, expect, it} from 'vitest'; +import {sanitizeTerminalText} from '../safety.js'; +import {textDirection} from './text-direction.js'; + +describe('marquee text direction', () => { + it.each([ + 'רדיו ישראל', 'راديو العربية', 'رادیو فارسی', 'ریڈیو پاکستان', + 'ܪܕܝܘ', 'ރޭޑިއޯ', 'ߒߞߏ', '𞤀𞤣𞤤𞤢𞤥', '𐤀𐤁𐤂', + 'ﭏ רדיו', 'ﺭﺍﺩﻳﻮ', '𞸀 راديو' + ])('detects RTL scripts in %s', text => { + expect(textDirection(text)).toBe('rtl'); + }); + + it.each(['Radio', 'Café', '東京 FM', 'Москва', 'Αθήνα', 'रेडियो', 'วิทยุ', '广播'])('retains LTR for %s', text => { + expect(textDirection(text)).toBe('ltr'); + }); + + it.each(['', '123.45', '📻 🎵', '— () …', '\u0301\u05b0\u064e'])('defaults to LTR without a strong character in %s', text => { + expect(textDirection(text)).toBe('ltr'); + }); + + it.each(['101.7 — רדיו FM', '📻 (راديو) 101', '\u05b0רדיו', '\u064eراديو', '١٢٣ راديو', '۱۲۳ رادیو'])('skips weak and neutral prefixes in %s', text => { + expect(textDirection(text)).toBe('rtl'); + }); + + it('uses the first strong character, rather than the last or majority script', () => { + expect(textDirection('Radio العربية')).toBe('ltr'); + expect(textDirection('راديو International FM')).toBe('rtl'); + expect(textDirection('東京 راديو')).toBe('ltr'); + }); + + it('handles strong characters that are not letters according to their bidi class', () => { + expect(textDirection('־ Radio')).toBe('rtl'); // Hebrew maqaf: R. + expect(textDirection('߁ Radio')).toBe('rtl'); // NKo digit: R. + expect(textDirection('१ راديو')).toBe('ltr'); // Devanagari digit: L. + expect(textDirection('🇮🇱 רדיו')).toBe('ltr'); // Regional indicators: L. + }); + + it('works on sanitized content without reintroducing bidi controls', () => { + const text = sanitizeTerminalText('\u200e\u202eراديو\u202c\u2069'); + expect(text).toBe('راديو'); + expect(textDirection(text!)).toBe('rtl'); + }); +}); diff --git a/src/ui/text-direction.ts b/src/ui/text-direction.ts new file mode 100644 index 0000000..3f209d7 --- /dev/null +++ b/src/ui/text-direction.ts @@ -0,0 +1,10 @@ +import {rightToLeftPattern, strongDirectionPattern} from './bidi-ranges.js'; + +/** First-strong direction for single-line text sanitized by sanitizeTerminalText. */ +export function textDirection(text: string): 'ltr' | 'rtl' { + // UAX #9 P2/P3: numbers, combining marks, emoji and punctuation generally + // don't establish direction. Inspect the whole name, never the moving slice. + // Pinned bidi classes avoid relying on locale or the host's ICU version. + const first = strongDirectionPattern.exec(text)?.[0]; + return first && rightToLeftPattern.test(first) ? 'rtl' : 'ltr'; +} diff --git a/src/ui/use-alarm-tui.ts b/src/ui/use-alarm-tui.ts index 155b080..bc03928 100644 --- a/src/ui/use-alarm-tui.ts +++ b/src/ui/use-alarm-tui.ts @@ -1,6 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction} from 'react'; import type {Alarm, LibraryState, Screen, Station} from '../types.js'; import type {JsonLibraryStore} from '../storage/store.js'; +import type {LibraryRepository} from '../storage/repository.js'; import {applyTextInput, clamp, isEditableInput} from './app-state.js'; import { adjustTime, alarmEditorFields, alarmInputFromDraft, alarmTextFields, cycleWeekdays, defaultAlarmDraft, @@ -13,11 +14,13 @@ import type {AlarmRuntimeSummary, AlarmTuiService, AlarmVerificationReport, TuiA type InkKey = {upArrow?: boolean; downArrow?: boolean; leftArrow?: boolean; rightArrow?: boolean; return?: boolean; escape?: boolean; backspace?: boolean; delete?: boolean; ctrl?: boolean; meta?: boolean; scrollDelta?: number}; type Params = { service: AlarmTuiService; - store: JsonLibraryStore; + store: JsonLibraryStore | LibraryRepository; library: LibraryState; setLibrary: Dispatch>; screen: Screen; + screenRef: {current: Screen}; selected: number; + selectedRef: {current: number}; setSelected: Dispatch>; go(screen: Screen, options?: {resetSelection?: boolean; clearMessage?: boolean}): void; setMessage: Dispatch>; @@ -50,7 +53,12 @@ export type AlarmTuiController = { }; export function useAlarmTui(params: Params): AlarmTuiController { - const {service, store, library, setLibrary, screen, selected, setSelected, go, setMessage, playingStation, previewStation} = params; + const {service, store, library, setLibrary, screen, screenRef, selected, selectedRef, setSelected: setSelectionState, go, setMessage, playingStation, previewStation} = params; + const setSelected = useCallback>>(next => { + const value = typeof next === 'function' ? next(selectedRef.current) : next; + selectedRef.current = value; + setSelectionState(value); + }, [selectedRef, setSelectionState]); const [draft, setDraft] = useState(null); const [editingField, setEditingField] = useState(false); const [editorControl, setEditorControl] = useState(null); @@ -84,7 +92,6 @@ export function useAlarmTui(params: Params): AlarmTuiController { const activeSelectedKeyRef = useRef(undefined); const pendingActiveActionsRef = useRef(new Set()); const acknowledgedActiveKeysRef = useRef(new Set()); - const screenRef = useRef(screen); screenRef.current = screen; const alarmsRef = useRef(library.alarms); alarmsRef.current = library.alarms; const visibleFields = useMemo(() => alarmEditorFields.filter(field => { @@ -107,13 +114,15 @@ export function useAlarmTui(params: Params): AlarmTuiController { activeSelectedKeyRef.current = sessions[nextIndex]?.key; const liveKeys = new Set(sessions.map(session => session.key)); for (const key of pendingActiveActionsRef.current) if (!liveKeys.has(key)) pendingActiveActionsRef.current.delete(key); - activeAlarmsRef.current=sessions;setActiveAlarms(sessions); + if (JSON.stringify(activeAlarmsRef.current) !== JSON.stringify(sessions)) { + activeAlarmsRef.current=sessions;setActiveAlarms(sessions); + } if (sessions.length > 0 && activeCountRef.current === 0 && screenRef.current !== 'alarm-ringing') { returnScreenRef.current = screenRef.current; go('alarm-ringing', {resetSelection: true, clearMessage: false}); } activeCountRef.current = sessions.length; - },[go]); + },[go, screenRef]); const refreshActive = useCallback(async () => { if (activePollingRef.current) return; @@ -181,14 +190,14 @@ export function useAlarmTui(params: Params): AlarmTuiController { 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 disabled = await 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); + await store.removeAlarm(alarm.id); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); @@ -208,14 +217,14 @@ export function useAlarmTui(params: Params): AlarmTuiController { setMessage(message); }, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]); - const saveDraft = useCallback(() => { + const saveDraft = useCallback(async () => { if (!draft || savingRef.current) return; const error = validateAlarmDraft(draft); if (error) { setValidationError(error); setMessage(error); return; } savingRef.current = true; setSaving(true); try { const input = alarmInputFromDraft(draft); - const alarm = draft.id ? store.updateAlarm(draft.id, input) : store.addAlarm(input); + const alarm = draft.id ? await store.updateAlarm(draft.id, input) : await store.addAlarm(input); setLibrary(store.snapshot()); setValidationError(null); go('alarms', {resetSelection: true, clearMessage: false}); @@ -229,9 +238,9 @@ export function useAlarmTui(params: Params): AlarmTuiController { }, [draft, go, setLibrary, setMessage, store, syncPersisted]); const editSelected = useCallback(() => { - const alarm = alarmStateRef.current[selected - 1]; if (!alarm) return; + const alarm = alarmStateRef.current[selectedRef.current - 1]; if (!alarm) return; setDraft(draftFromAlarm(alarm)); setEditingField(false); editorControlRef.current=null; setEditorControl(null); timeSegmentRef.current='hour'; setTimeSegment('hour'); weekdayIndexRef.current=0; setWeekdayIndex(0); setValidationError(null); setSelected(0); go('alarm-editor', {resetSelection: true}); - }, [go, library.alarms, selected, setSelected]); + }, [go, selectedRef, setSelected]); const runVerification=useCallback(()=>{ if(verificationRunningRef.current)return; @@ -243,6 +252,9 @@ export function useAlarmTui(params: Params): AlarmTuiController { },[library.settings,refreshRuntime,service,setMessage,store]); const handleInput = useCallback((input: string, key: InkKey): boolean => { + const screen = screenRef.current; + const selected = selectedRef.current; + const editorField = visibleFields[clamp(selected, visibleFields.length - 1)] ?? 'label'; if (!['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing'].includes(screen)) return false; if (key.scrollDelta) { if (screen === 'alarm-editor') { @@ -276,7 +288,7 @@ export function useAlarmTui(params: Params): AlarmTuiController { if (key.return || ['e',' ','x','t','g'].includes(input)) { setMessage(`Cleanup is still running for ${alarm.label}.`); return true; } } if ((key.return || input === 'e') && alarm) { editSelected(); return true; } - if (input === ' ' && alarm) { const updated = store.toggleAlarm(alarm.id); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); void syncPersisted(updated, `${updated.enabled ? 'Enabled' : 'Disabled'} ${updated.label}.`); return true; } + if (input === ' ' && alarm) { void (async () => { const updated = await store.toggleAlarm(alarm.id); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); await syncPersisted(updated, `${updated.enabled ? 'Enabled' : 'Disabled'} ${updated.label}.`); })().catch(error => setMessage(`Alarm save failed: ${messageOf(error)}`)); return true; } if (input === 'x' && alarm) { 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; } @@ -287,7 +299,7 @@ export function useAlarmTui(params: Params): AlarmTuiController { 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; } if (input === 'r') { const activeIds=new Set(activeAlarmsRef.current.map(session=>session.status.alarmId));const repairable = store.listAlarms().filter(item => !busyAlarmIdsRef.current.has(item.id)&&!activeIds.has(item.id)); void (async()=>{let terminalWarning:string|undefined;try{await service.prepareTerminalAccess?.();}catch(error){terminalWarning=messageOf(error);}try{const results=await service.syncAll(repairable);const failed=results.filter(item=>item.error);setMessage(failed.length?`${failed.length} alarms still degraded: ${failed[0]!.error}`:terminalWarning?`Native jobs repaired, but automatic terminal controls need attention: ${terminalWarning}`:activeIds.size?'Inactive alarms repaired; currently playing alarms were left untouched.':'All alarms repaired and synchronized.');void refreshRuntime();}catch(error){setMessage(`Alarm repair failed: ${messageOf(error)}`);}})(); return true; } - if (input === 'g' && alarm) { const updated = store.updateAlarm(alarm.id, {reliability: {...alarm.reliability, keepAwakeUntilAlarm: !alarm.reliability.keepAwakeUntilAlarm}}); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); void syncPersisted(updated, `Alarm Guard ${updated.reliability.keepAwakeUntilAlarm ? 'on' : 'off'} for ${alarm.label}.`); return true; } + if (input === 'g' && alarm) { void (async () => { const updated = await store.updateAlarm(alarm.id, {reliability: {...alarm.reliability, keepAwakeUntilAlarm: !alarm.reliability.keepAwakeUntilAlarm}}); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); await syncPersisted(updated, `Alarm Guard ${updated.reliability.keepAwakeUntilAlarm ? 'on' : 'off'} for ${alarm.label}.`); })().catch(error => setMessage(`Alarm save failed: ${messageOf(error)}`)); return true; } if (input === 'b' || key.escape) { go('home'); return true; } if (key.downArrow || input === 'j') { setSelected(value => clamp(value + 1, library.alarms.length + 1)); return true; } if (key.upArrow || input === 'k' || input === 'p') { setSelected(value => clamp(value - 1, library.alarms.length + 1)); return true; } @@ -389,13 +401,13 @@ export function useAlarmTui(params: Params): AlarmTuiController { return true; } return false; - }, [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]); + }, [activeAlarms, deleteAlarm, deletingId, draft, editSelected, editingField, editorControl, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screenRef, selectedRef, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]); const openActive = useCallback(() => { if (!activeAlarms.length) { setMessage('No alarm is currently playing.'); return; } if (screenRef.current !== 'alarm-ringing') returnScreenRef.current = screenRef.current; activeSelectedRef.current=0;activeSelectedKeyRef.current=activeAlarms[0]?.key;setActiveSelected(0); go('alarm-ringing', {resetSelection: true, clearMessage: false}); - }, [activeAlarms, go, setMessage]); + }, [activeAlarms, go, screenRef, setMessage]); return {draft, editorField, editingField, editorControl, timeSegment, weekdayIndex, validationError, saving, pickerChoices, pickerFallback, runtime, verification, activeAlarms, activeSelected, snoozeMinutes, deletingId, busyAlarmIds, itemCount(current) { if (current === 'alarms') return library.alarms.length + 2; if (current === 'alarm-editor') return visibleFields.length; if (current === 'alarm-picker') return pickerChoices.length; if (current === 'alarm-ringing') return activeAlarms.length; return undefined; }, handleInput, openForStation, openActive}; } diff --git a/src/ui/use-app-input.ts b/src/ui/use-app-input.ts index 7a06c70..de7f17e 100644 --- a/src/ui/use-app-input.ts +++ b/src/ui/use-app-input.ts @@ -1,5 +1,5 @@ import {useCallback, useEffect} from 'react'; -import {useInput, type Key} from 'ink'; +import {useInput, usePaste, 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,7 +19,7 @@ import { type MediaTransportAction } from './app-state.js'; import {parseTerminalMouseEvents, primaryMousePress, wheelScrollDelta} from './terminal-mouse.js'; -import {completeCommand} from './help-content.js'; +import {completeCommand, commandNamesForAirPlay} from './help-content.js'; import {commitImmediateSelection} from './selection-state.js'; import {sanitizeTerminalText} from '../safety.js'; @@ -36,6 +36,7 @@ type AppInputOptions = { adjustVolume: (delta: number) => void; airPlayCode: string; canEnterAirPlayCode: boolean; + airPlaySupported: boolean; cancelPendingAutoSkip: () => void; beginLearningTransportKey: (action: MediaTransportAction) => void; capturingTransportAction: MediaTransportAction | null; @@ -67,7 +68,7 @@ type AppInputOptions = { openAlarmForStation: (station?: Station | null) => void; openActiveAlarms: () => void; playAdjacent: (direction: 1 | -1) => void; - playStation: (station: Station) => Promise; + playStation: (station: Station) => Promise; player: PlayerController; recallSearchHistory: (direction: 'older' | 'newer') => void; playingStation: Station | null; @@ -81,7 +82,8 @@ type AppInputOptions = { runSearch: () => Promise; saveLearnedTransportKey: (action: MediaTransportAction, input: string) => void; screen: Screen; - settingsPage: SettingsPage; + screenRef: CurrentRef; + settingsPageRef: CurrentRef; searchQuery: string; selectedRef: CurrentRef; selectedStationForInput: () => Station | null; @@ -117,6 +119,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, + airPlaySupported, cancelPendingAutoSkip, beginLearningTransportKey, capturingTransportAction, @@ -162,7 +165,8 @@ export function useAppInput({ runSearch, saveLearnedTransportKey, screen, - settingsPage, + screenRef, + settingsPageRef, searchQuery, selectedRef, selectedStationForInput, @@ -197,15 +201,16 @@ export function useAppInput({ 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)); + commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screenRef.current)); }; const moveSelection = (delta: number): void => { - if (hasStationSelection(screen)) cancelPendingAutoSkip(); + if (hasStationSelection(screenRef.current)) cancelPendingAutoSkip(); commitSelection(selectedRef.current + delta); }; useEffect(() => { const handleData = (data: Buffer | string) => { + const screen = screenRef.current; const rawInput = String(data); if (capturingTransportAction) { if (rawInput === '\u001B') { @@ -282,6 +287,7 @@ export function useAppInput({ moveExploreCursorToCell, saveLearnedTransportKey, screen, + screenRef, selectedRef, setCapturingTransportAction, setMessage, @@ -292,6 +298,8 @@ export function useAppInput({ ]); const handleInput = async (input: string, key: Key): Promise => { + const screen = screenRef.current; + const settingsPage = settingsPageRef.current; if (key.ctrl && input === 'c') { confirmCtrlCExit(); return; @@ -305,7 +313,12 @@ export function useAppInput({ return; } - if (Date.now() - lastRawTransportAtRef.current < 50) { + // Suppress only the mouse/media event already handled by the raw listener. + // Other keys in the same chunk (or immediately afterward) must still work. + const editingPlainText = isPlainPrintableInput(input) && ( + commandMode || (screen === 'search' && editingSearch) || ((screen === 'countries' || screen === 'map') && editingCountryFilter) + ); + if (!editingPlainText && Date.now() - lastRawTransportAtRef.current < 50 && isRawInputEcho(input, key, settingsRef.current)) { return; } @@ -325,7 +338,7 @@ export function useAppInput({ if (key.tab) { // Complete the command name only while still typing it (no args yet). - setCommandText(value => (/\s/.test(value) ? value : completeCommand(value))); + setCommandText(value => (/\s/.test(value) ? value : completeCommand(value, commandNamesForAirPlay(airPlaySupported)))); return; } @@ -696,7 +709,7 @@ export function useAppInput({ } if (screen === 'settings') { - const item = settingsItemsForPage(settingsPage)[selectedRef.current]; + const item = settingsItemsForPage(settingsPage, airPlaySupported)[selectedRef.current]; if (settingsPage === 'root') { const targetPage = settingsPageForRootItem(item); if (targetPage) { @@ -790,8 +803,39 @@ export function useAppInput({ }; useInput((input, key) => { + // Ink preserves non-escape text chunks for paste, including repeated Tab + // bytes. Only a chunk consisting entirely of Tabs represents navigation. + if (/^\t{2,}$/.test(input)) { + for (let index = 0; index < input.length; index += 1) { + void handleInput('', {...key, tab: true}).catch(reportInputError); + } + return; + } void handleInput(input, key).catch(reportInputError); }); + // Keep bracketed paste on its own channel so pasted Tabs stay text. The + // existing text/command handling still receives each complete pasted string. + usePaste(input => { + void handleInput(input, pasteInputKey).catch(reportInputError); + }); +} + +const pasteInputKey: Key = { + upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, + pageDown: false, pageUp: false, home: false, end: false, + return: false, escape: false, ctrl: false, shift: false, tab: false, + backspace: false, delete: false, meta: false, + super: false, hyper: false, capsLock: false, numLock: false +}; + +function isRawInputEcho(input: string, key: Key, settings: AppSettings): boolean { + return Boolean( + mediaTransportActionForInput(input, settings.mediaKeys) || + mediaTransportActionForInput(`\u001B${input}`, settings.mediaKeys) || + ((key.shift || key.ctrl || key.meta) && (key.leftArrow || key.rightArrow)) || + /^\[<\d+;\d+;\d+[Mm]$/.test(input) || + input.startsWith('[M') + ); } function shouldScrollSelectionWithWheel(screen: Screen, commandMode: boolean, editingCountryFilter: boolean): boolean { diff --git a/src/ui/use-command-executor.ts b/src/ui/use-command-executor.ts index 682d19c..1da2dc9 100644 --- a/src/ui/use-command-executor.ts +++ b/src/ui/use-command-executor.ts @@ -2,7 +2,7 @@ import {useCallback, type Dispatch, type SetStateAction} from 'react'; import type {AppSettings, Country, LibraryState, Screen, Station} from '../types.js'; import type {ProviderManager} from '../providers/provider-manager.js'; import type {PlayerController} from '../player/player-controller.js'; -import type {JsonLibraryStore} from '../storage/store.js'; +import type {LibraryRepository} from '../storage/repository.js'; import type {MediaTransportAction, SearchFilters, NavigationOptions} from './app-state.js'; import {favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName} from './app-state.js'; import {importStreamUrl} from '../streams/import-stream.js'; @@ -11,42 +11,49 @@ type SettingsRef = { current: AppSettings; }; +export type SettingsUpdate = Partial | ((settings: Readonly) => Partial); + type UseCommandExecutorOptions = { beginLearningTransportKey: (action: MediaTransportAction) => void; + cancelPendingTune: () => void; countries: Country[]; go: (next: Screen, options?: NavigationOptions) => void; - loadCountry: (country: Country) => Promise; + loadCountry: (country: Country, navigate?: boolean) => Promise; openAirPlaySettings: () => void; + openAirPlayCode: () => void; openLibrary: () => void; - persistLibrary: (write: () => LibraryState) => LibraryState | undefined; + persistLibrary: (write: () => LibraryState | Promise) => Promise; player: PlayerController; playingStation: Station | null; providers: ProviderManager; resetLearnedTransportKeys: () => void; runSearch: (query?: string) => Promise; screen: Screen; + screenRef: {current: Screen}; selectedStation: Station | null; setCountries: Dispatch>; setFilters: Dispatch>; setLibrary: Dispatch>; setMessage: Dispatch>; setSearchQuery: Dispatch>; - setSleepUntil: Dispatch>; + setSleepUntil(until: number | null): void; setVolume: (volume: number) => void; settingsRef: SettingsRef; - store: JsonLibraryStore; + store: LibraryRepository; toggleFavorite: (station: Station | null) => void; toggleMute: () => void; updateCommand: () => Promise; - updateSettings: (settings: Partial) => LibraryState; + updateSettings: (settings: SettingsUpdate) => Promise; }; export function useCommandExecutor({ beginLearningTransportKey, + cancelPendingTune, countries, go, loadCountry, openAirPlaySettings, + openAirPlayCode, openLibrary, persistLibrary, player, @@ -55,6 +62,7 @@ export function useCommandExecutor({ resetLearnedTransportKeys, runSearch, screen, + screenRef, selectedStation, setCountries, setFilters, @@ -93,6 +101,7 @@ export function useCommandExecutor({ return; } + const origin = screenRef.current; const availableCountries = countries.length > 0 ? countries : await providers.countries(); if (countries.length === 0) { setCountries(availableCountries); @@ -102,11 +111,11 @@ export function useCommandExecutor({ `${country.code} ${country.name}`.toLowerCase().includes(value.toLowerCase()) ); if (!match) { - setMessage(`Country not found: ${value}`); + if (screenRef.current === origin) setMessage(`Country not found: ${value}`); return; } - await loadCountry(match); + await loadCountry(match, screenRef.current === origin); return; } @@ -147,7 +156,7 @@ export function useCommandExecutor({ if (name === 'location') { const enabled = value === 'on' || value === 'true' || value === '1'; - updateSettings({enableNearbyLocation: enabled}); + await updateSettings({enableNearbyLocation: enabled}); setMessage(`Nearby location lookup ${enabled ? 'enabled' : 'disabled'}.`); return; } @@ -155,14 +164,14 @@ export function useCommandExecutor({ if (name === 'timeout') { const seconds = Number(value); if (Number.isFinite(seconds)) { - updateSettings({tuneTimeoutSeconds: Math.min(45, Math.max(3, seconds))}); + await updateSettings({tuneTimeoutSeconds: Math.min(45, Math.max(3, seconds))}); } return; } if (name === 'skip') { const enabled = value !== 'off' && value !== 'false' && value !== '0'; - updateSettings({skipBrokenStreams: enabled}); + await updateSettings({skipBrokenStreams: enabled}); return; } @@ -190,11 +199,11 @@ export function useCommandExecutor({ if (name === 'airplay-code' || name === 'airplay-passcode') { if (!value.trim()) { - go('airplay-code'); + openAirPlayCode(); return; } - const result = player.submitAirPlayPasscode(value.trim()); + const result = await player.submitAirPlayPasscode(value.trim()); if (result.message) { setMessage(result.message); } @@ -239,7 +248,7 @@ export function useCommandExecutor({ if (name === 'export' || name === 'backup') { try { - const backupPath = store.exportBackup(value || undefined); + const backupPath = await store.exportBackup(value || undefined); setMessage(`Backup exported: ${backupPath}`); } catch (error) { setMessage(error instanceof Error ? error.message : 'Could not export the RadioCLI backup.'); @@ -257,7 +266,7 @@ export function useCommandExecutor({ const [url = '', ...nameParts] = rest; setMessage('Reading stream metadata...'); const result = await importStreamUrl(url, nameParts.join(' ') || undefined); - const nextLibrary = persistLibrary(() => store.addImported([result.station])); + const nextLibrary = await persistLibrary(() => store.addImported([result.station])); if (!nextLibrary) return; openLibrary(); setMessage(result.warning @@ -276,7 +285,7 @@ export function useCommandExecutor({ } try { - const result = store.importBackup(value); + const result = await store.importBackup(value); settingsRef.current = result.state.settings; setLibrary(result.state); setMessage( @@ -296,6 +305,7 @@ export function useCommandExecutor({ } if (name === 'stop') { + cancelPendingTune(); persistLibrary(() => store.finishActiveListeningSession()); await player.stop(); return; @@ -321,9 +331,10 @@ export function useCommandExecutor({ : name === 'motion' ? 'reduceMotion' : 'transparentBackground'; - const enabled = value === '' ? !settingsRef.current[key] : value === 'on' || value === 'true' || value === '1'; - updateSettings({[key]: enabled}); - setMessage(`${name} ${enabled ? 'on' : 'off'}.`); + const saved = await updateSettings(value === '' + ? current => ({[key]: !current[key]}) + : {[key]: value === 'on' || value === 'true' || value === '1'}); + setMessage(`${name} ${saved.settings[key] ? 'on' : 'off'}.`); return; } @@ -331,10 +342,12 @@ export function useCommandExecutor({ }, [ beginLearningTransportKey, + cancelPendingTune, countries, go, loadCountry, openAirPlaySettings, + openAirPlayCode, openLibrary, persistLibrary, player, @@ -343,6 +356,7 @@ export function useCommandExecutor({ resetLearnedTransportKeys, runSearch, screen, + screenRef, selectedStation, setCountries, setFilters, diff --git a/src/ui/visualizers/gallop.test.ts b/src/ui/visualizers/gallop.test.ts index 760514c..fe7bf39 100644 --- a/src/ui/visualizers/gallop.test.ts +++ b/src/ui/visualizers/gallop.test.ts @@ -21,7 +21,38 @@ const dotCount = (values: number[]) => values.reduce( 0 ); +const strideDotBounds = (width: number, height: number) => { + const bits = [[1, 8], [2, 16], [4, 32], [64, 128]]; + let left = Infinity; let right = -Infinity; let top = Infinity; let bottom = -Infinity; + for (let pulse = 0; pulse < horseStrideFrames.length; pulse++) { + buildGallopFine(pulse, width, height, 'violet').forEach((row, y) => { + [...row.text].forEach((glyph, x) => { + const mask = glyph === ' ' ? 0 : glyph.charCodeAt(0) - 0x2800; + bits.forEach((pair, yy) => pair.forEach((bit, xx) => { + if (!(mask & bit)) return; + left = Math.min(left, x * 2 + xx); right = Math.max(right, x * 2 + xx); + top = Math.min(top, y * 4 + yy); bottom = Math.max(bottom, y * 4 + yy); + })); + }); + }); + } + return {width: right - left + 1, height: bottom - top + 1}; +}; + describe('gallop fine', () => { + it('draws the complete stride about ten percent smaller in both fit directions', () => { + // Measured dot bounds before the size adjustment, over the complete stride. + // Allow two dots for rasterization and edge thinning at the smaller scale. + for (const [width, height, previousWidth, previousHeight] of [ + [40, 8, 36, 22], [80, 14, 71, 44], [160, 28, 154, 94], + [40, 28, 67, 42], [160, 8, 36, 22] + ] as const) { + const bounds = strideDotBounds(width, height); + expect(Math.abs(bounds.width - previousWidth * 0.9)).toBeLessThanOrEqual(2); + expect(Math.abs(bounds.height - previousHeight * 0.9)).toBeLessThanOrEqual(2); + } + }); + it('uses complete fixed-canvas poses and loops without accumulating translation', () => { for (const frame of horseStrideFrames) { expect(Buffer.from(frame, 'base64')).toHaveLength(horseStrideWidth * horseStrideHeight / 8); @@ -55,6 +86,19 @@ describe('gallop fine', () => { } }); + it('preserves each pose when resizing through tiny, narrow, wide and restored viewports', () => { + for (let pulse = 0; pulse < horseStrideFrames.length; pulse++) { + const original = buildGallopFine(pulse, 80, 14, 'violet'); + for (const [width, height] of [[1, 1], [8, 4], [9, 5], [32, 7], [40, 28], [160, 8], [160, 28]] as const) { + const rows = buildGallopFine(pulse, width, height, 'violet'); + expect(rows).toHaveLength(height); + expect(rows.every(row => row.text.length === width)).toBe(true); + expect(rows.every(row => /^[ \u2801-\u28ff]*$/.test(row.text))).toBe(true); + } + expect(buildGallopFine(pulse, 80, 14, 'violet')).toEqual(original); + } + }); + it('keeps themed transparent linework and modestly thins the base drawing', () => { for (let pulse = 0; pulse < horseStrideFrames.length; pulse++) { const original = masks(buildGallop(pulse, 80, 14, 'ruby')); diff --git a/src/ui/visualizers/gallop.ts b/src/ui/visualizers/gallop.ts index 78d3dc1..8db12f3 100644 --- a/src/ui/visualizers/gallop.ts +++ b/src/ui/visualizers/gallop.ts @@ -14,7 +14,7 @@ export function buildGallop(pulse: number, width: number, height: number, theme: const frame = frames[frameIndex]!; const usableW = w > 8 ? w - 4 : w; const usableH = h > 4 ? h - 2 : h; - const scale = Math.min(usableW * 2 / horseStrideWidth, usableH * 4 / horseStrideHeight); + const scale = Math.min(usableW * 2 / horseStrideWidth, usableH * 4 / horseStrideHeight) * 0.9; const dotsW = Math.max(1, Math.floor(horseStrideWidth * scale)); const dotsH = Math.max(1, Math.floor(horseStrideHeight * scale)); const left = Math.floor((w - Math.ceil(dotsW / 2)) / 2) * 2; diff --git a/src/ui/visualizers/receiver-style-registry.ts b/src/ui/visualizers/receiver-style-registry.ts index 7ee5bca..c85fec8 100644 --- a/src/ui/visualizers/receiver-style-registry.ts +++ b/src/ui/visualizers/receiver-style-registry.ts @@ -68,12 +68,13 @@ export const receiverStyleRegistry = [ export type ReceiverStyleId = (typeof receiverStyleRegistry)[number]['id']; export const receiverStyleNames = receiverStyleRegistry.map(style => style.id) as readonly ReceiverStyleId[]; -export const defaultReceiverStyle = 'gallop-fine' as const satisfies ReceiverStyleId; +export const defaultReceiverStyle = 'ultracode' as const satisfies ReceiverStyleId; export const receiverStyleMetadata = Object.fromEntries( receiverStyleRegistry.map(style => [style.id, style]) ) as Readonly>; const retiredReceiverStyleNames = [ + 'synthwave-sun', 'neon-alley', 'safari-dusk', 'hot-air-balloons', 'deep-sea-whale', 'meteor-shower', 'volcano', 'desert-highway', 'firefly-grove', 'cherry-blossom', 'event-horizon', 'lighthouse', 'duplex-wave', 'color-drift', 'undertow', 'cube-tide', 'parallax-slabs', 'voxel-swell', 'dot-current', 'flip-matrix', 'raster-harp', 'spin-lattice', 'packet-storm', 'spark-chamber', diff --git a/src/ui/visualizers/receiver-visualizers.test.ts b/src/ui/visualizers/receiver-visualizers.test.ts index 77f290b..adb77e8 100644 --- a/src/ui/visualizers/receiver-visualizers.test.ts +++ b/src/ui/visualizers/receiver-visualizers.test.ts @@ -77,6 +77,7 @@ const immersiveReceiverStyles = [ ] as const satisfies readonly ReceiverStyle[]; const retiredExternalStylePrefix = `${'term'}${'flix'}-`; const removedReceiverStyles = [ + 'synthwave-sun', 'neon-alley', 'safari-dusk', 'hot-air-balloons', 'deep-sea-whale', 'meteor-shower', 'volcano', 'desert-highway', 'firefly-grove', 'cherry-blossom', 'event-horizon', 'lighthouse', 'spectrum', 'oscilloscope', 'motion-bars', @@ -230,8 +231,8 @@ describe('receiver visualizers', () => { expect(receiverStyleNames).toEqual(expect.arrayContaining([...immersiveReceiverStyles])); }); - it('cycles receiver styles by visual family and defaults to gallop fine', () => { - expect(defaultReceiverStyle).toBe('gallop-fine'); + it('preserves the original receiver order and defaults to ultracode', () => { + expect(defaultReceiverStyle).toBe('ultracode'); expect(receiverStyleNames.slice(0, 8)).toEqual([ 'ultracode', 'pulse-grid', @@ -243,6 +244,9 @@ describe('receiver visualizers', () => { 'lava-lamp' ]); expect(receiverStyleNames[receiverStyleNames.indexOf('lava-lamp') + 1]).toBe('gallop-fine'); + expect(receiverStyleNames[receiverStyleNames.indexOf('gallop-fine') + 1]).toBe('motion-contour'); + expect(receiverStyleNames).toHaveLength(63); + expect(receiverStyleNames.slice(-2)).toEqual(['alpine', 'crossfade']); expect(receiverStyleNames.indexOf('cascade')).toBe(receiverStyleNames.indexOf('matrix') + 1); expect(receiverStyleNames.indexOf('equalizer')).toBe(receiverStyleNames.indexOf('hologram') + 1); expect(receiverStyleNames.indexOf('braille-wave')).toBe(receiverStyleNames.indexOf('ribbon') + 1); diff --git a/src/update-check.test.ts b/src/update-check.test.ts index bee193d..d60edc3 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -1,8 +1,12 @@ +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {delimiter, join} from 'node:path'; import {afterEach, describe, expect, it, vi} from 'vitest'; import { automaticUpdateChecksAllowed, checkForUpdate, compareSemver, + installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall, @@ -10,10 +14,22 @@ import { updateStatusText } from './update-check.js'; +const directories: string[] = []; + +function managerFixture(name: string, body: string): string { + const directory = mkdtempSync(join(tmpdir(), 'radiocli-update-')); + directories.push(directory); + const bin = join(directory, 'user bin 单播'); + mkdirSync(bin); + writeFileSync(join(bin, `${name}${process.platform === 'win32' ? '.cmd' : ''}`), body, {mode: 0o755}); + return bin; +} + describe('update checks', () => { afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers(); + for (const directory of directories.splice(0)) rmSync(directory, {recursive: true, force: true}); }); it('compares semantic versions', () => { @@ -166,11 +182,34 @@ describe('update checks', () => { }); expect(updateShellForPlatform('darwin', 'brew upgrade pkg')).toEqual({ command: 'sh', - args: ['-lc', 'brew upgrade pkg'] + args: ['-c', 'brew upgrade pkg'] }); expect(updateShellForPlatform('linux', 'npm install -g pkg')).toEqual({ command: 'sh', - args: ['-lc', 'npm install -g pkg'] + args: ['-c', 'npm install -g pkg'] + }); + }); + + it.each(['npm', 'pnpm', 'bun'])('updates with the launching PATH\'s %s instead of another installation', async manager => { + const script = (marker: string) => process.platform === 'win32' + ? `@echo off\r\necho ${marker}\r\n` + : `#!/bin/sh\nprintf '%s\\n' '${marker}'\n`; + const userBin = managerFixture(manager, script(`user-${manager}`)); + const systemBin = managerFixture(manager, script(`other-${manager}`)); + vi.stubEnv('PATH', [userBin, systemBin, process.env.PATH ?? ''].join(delimiter)); + + // --version is harmless even if a regression selects a real installed manager. + expect(await installUpdate(`${manager} --version`)).toEqual({ + ok: true, command: `${manager} --version`, output: `user-${manager}` }); }); + + it.skipIf(process.platform === 'win32').each([0, 7])('preserves ordered update chains and first-step failure (exit %i)', async code => { + const bin = managerFixture('radiocli-update-step', '#!/bin/sh\nprintf \'%s\\n\' "$1"\nexit "$RADIOCLI_UPDATE_PROBE_EXIT"\n'); + vi.stubEnv('PATH', [bin, process.env.PATH ?? ''].join(delimiter)); + vi.stubEnv('RADIOCLI_UPDATE_PROBE_EXIT', String(code)); + const command = 'radiocli-update-step first && radiocli-update-step second'; + + expect(await installUpdate(command)).toEqual({ok: code === 0, command, output: code === 0 ? 'first\nsecond' : 'first'}); + }); }); diff --git a/src/update-check.ts b/src/update-check.ts index 1bb9a24..e041da7 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -155,9 +155,10 @@ export function installUpdate(command = updateCommandForInstall().command): Prom } export function updateShellForPlatform(platform: NodeJS.Platform, command: string): UpdateShell { + // Login profiles can replace PATH and select a different package-manager installation. return platform === 'win32' ? {command: 'cmd.exe', args: ['/d', '/s', '/c', command]} - : {command: 'sh', args: ['-lc', command]}; + : {command: 'sh', args: ['-c', command]}; } export function compareSemver(left: string, right: string): number { diff --git a/vendor/node-airtunes2/.editorconfig b/vendor/node-airtunes2/.editorconfig new file mode 100644 index 0000000..e316dcc --- /dev/null +++ b/vendor/node-airtunes2/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.{js,json,yml}] +charset = utf-8 +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/vendor/node-airtunes2/.nvmrc b/vendor/node-airtunes2/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/vendor/node-airtunes2/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/vendor/node-airtunes2/LICENSE b/vendor/node-airtunes2/LICENSE new file mode 100644 index 0000000..074a8de --- /dev/null +++ b/vendor/node-airtunes2/LICENSE @@ -0,0 +1,208 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and giving a relevant date. +b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors of the material; or +e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/vendor/node-airtunes2/RADIOCLI_PATCH.md b/vendor/node-airtunes2/RADIOCLI_PATCH.md new file mode 100644 index 0000000..24f8006 --- /dev/null +++ b/vendor/node-airtunes2/RADIOCLI_PATCH.md @@ -0,0 +1,29 @@ +# RadioCLI AirPlay sender source + +Based on the published `node-airtunes2@2.5.0` archive: +https://registry.npmjs.org/node-airtunes2/-/node-airtunes2-2.5.0.tgz + +Archive SHA-256: `2cee1d86fcd2e5fb4f56f12e23fd1abbdbd0b71a9bc5e1ffe1dcbb885cec1d71`. +The original archive's npm integrity and hashes of unchanged published files are +recorded in `UPSTREAM.json`. Original attribution and AGPLv3 license are retained. + +RadioCLI modifications, 2026-10-16: + +- `lib/atvAuthenticator.js` uses `lib/ed25519.js` and Node's native Ed25519 + instead of elliptic. Its synchronous hex API, public key bytes, signature + casing, AES-GCM confirmation, and AES-CTR verification formats are preserved. +- `package.json` identifies this local revision as `2.5.0-radiocli.1`, records + the replacement, and lists only the sender's runtime imports. Unused packaging, + Cast, discovery, HTTP, and build-tool dependencies are removed. RadioCLI owns + the corresponding optional dependencies and the optional native build tool. +- Upstream host-specific `build/` output is omitted. All native C/C++ source + remains available. RadioCLI's optional postinstall builds the Linux codec when + node-gyp and build prerequisites are available, preserving the upstream JS + fallback if unavailable. macOS uses the upstream JS codec. Portable installs + skip the build and remain usable for normal local playback. + +The other upstream source files are unchanged. RadioCLI's existing bounded RTSP, +legacy Digest, volume and metadata compatibility patches still apply at runtime. +The modified source, native build source, license and this notice ship in the npm +package; no external unpublished fork is required. Regression fixtures include +RFC 8032 and deterministic outputs captured from the original published sender. diff --git a/vendor/node-airtunes2/README.md b/vendor/node-airtunes2/README.md new file mode 100644 index 0000000..b585171 --- /dev/null +++ b/vendor/node-airtunes2/README.md @@ -0,0 +1,225 @@ +node-airtunes2 - a node.js implementation of AirTunes v2 (AirPlay 1) / AirPlay 2 +======================================================= + +> This is a fork to handle last NodeJS versions, main work have been made in these repositories: +> - [ciderapp/node_airtunes2](https://github.com/ciderapp/node_airtunes2) +> - [xuan25/node_airtunes2](https://github.com/xuan25/node_airtunes2) +> - [mcfisto/node_airtunes2](https://github.com/mcfisto/node_airtunes2) +> - [microadam/node_airtunes](https://github.com/microadam/node_airtunes) +> - [openairplay/node_airtunes](https://github.com/openairplay/node_airtunes) +> +> Project needs code improvement, which will be done over time. + +**AirPlay Audio client for Windows, Mac and Linux** + +Port with modern devices pairing +Tested on Apple TV HD 5th gen / HomePod mini (with/without passcode, tvOS 15.4.1) , Shairport clones (without passcode) + +-------------- + +Installation + +
+  npm install node-airtunes2
+
+ +The example folder contains several test scripts: + +* `cat sample.pcm | node play_stdin.js --host yourhost` will stream PCM data passed by stdin. +* `play_ffmpeg.js` harnesses ffmpeg to stream from local audio files or remote URLs. +* `play_lame.js` use JavaScript lame decoder to converd mp3 stream to PCM +* `scan_airtunes.js` will list nearby AirTunes devices (OS X only). + +What is AirTunes ? +------------------ + +[AirTunes](http://en.wikipedia.org/wiki/AirTunes$) is a proprietary audio streaming protocol developed by Apple Inc. It allows wireless streaming of audio between devices. It is used today as the audio-streaming portion of AirPlay. + +AirTunes, AirPlay, RAOP ? +------------------------- + +When AirTunes was introduced in 2004, its underlying protocol was called [RAOP](http://en.wikipedia.org/wiki/RAOP). It was based on RTSP/RTP and used a TCP transport. It was reverse-engineered in 2004 by Jon Lech Johansen, which opened the way to projects like [Shairport](https://github.com/albertz/shairport). + +RAOP didn't support synchronisation between separate streams so AirTunes underwent a major revision in 2011 to include advanced timing features. Its name was also changed to AirPlay. AirTunes v2 still uses RTSP/RTP but now uses a UDP transport. + +Most of the available open projects implement AirTunes v1. This is a problem because newer devices tend to drop support for this protocol. + +OK, now what is node-airtunes ? +------------------------------- + +Node-airtunes is a node.js implementation of AirTunes v2. It supports synchronized audio output to any number of +AirTunes receivers (like Apple's AirPort Express or AirFoil Speakers). + +What about Core Audio ? +----------------------- + +Core Audio is the name Apple gives to iOS/OS X low-level sound API. node-airtunes has bindings to Core Audio to allow synchronized local and remote playback (local sync is not perfect yet). Obviously, this will only work on OS X. + +Credits +------- +- https://github.com/SteeBono/airplayreceiver/wiki/AirPlay2-Protocol +- https://emanuelecozzi.net/docs/airplay2 +- Evan Coleman for [node-appletv](https://github.com/evandcoleman/node-appletv) +- Pierre Ståhl for [pyatv](https://github.com/postlund/pyatv) +- [The Airtunes 2 Team](http://git.zx2c4.com/Airtunes2/about/) +- Clément Vasseur for [Air Speaker](https://github.com/nto/AirSpeaker) +- [Rogue Amoeba Software, LLC](http://www.rogueamoeba.com/) for AirFoil +- Julien Blache for this [blog post](http://blog.technologeek.org/airtunes-v2) +- Apple Inc and [Apple Lossless Audio Codec](http://alac.macosforge.org/) +- postlin + +Usage +----- + +### Build + +`yarn install` + +### Playback + +`airtunes` is a writable stream which accepts 16 bits, little-endian, stereo PCM data. + +```javascript +var airtunes = require('airtunes'); +myPCMStream.pipe(airtunes); +``` + +There is an internal circular buffer which allows to stream from a network source. The stream will ouput audio at the correct pace and will pause/resume accordingly. `airtunes` emits 'buffer' events to help you monitor the buffer: + +* `'buffering'`: Silence is being played while the buffer fills with data. +* `'playing'`: Real sound is being streamed to devices. +* `'end'`: The buffer was closed by the input stream. Attempting to write more data will raise an exception. + +After an `end` event, you should close all devices with `airtunes.stopAll()` 2s later (AirTunes devices usually have a 2s delay). If you want to pipe several successive streams to airtunes, just pass `{end: false}` to `stream.pipe`. + +### AirTunes Devices + +You can add devices at any time: sound will be synchronized between all devices. The second parameter is optional: + +```javascript +var device = airtunes.add(host, { + port: 5000, + volume: 100, + password: 'mypassword' +}); +``` + +* `host` and `port` are the location of the AirTunes device as reported by Zeroconf. The default port is 5000. +* `volume` is the initial volume, which must be between 0 and 100. The default volume is 50. +* AirTunes makes it possible to protect devices with a `password`, which is of course optional. Bonjour indicates if the device demands a password. + +AirTunes devices emit `'status'` events: + +* `'ready'`: The device connected and ready to stream. +* `'stopped'`: The device was stopped and has been removed from the pool. + +They also emit `'error'` events. After an error, a device will no longer emit any events. + +* `'timeout'`: The device did not reply within `'config.rtsp_timeout'`. +* `'connection_refused'`: The device refused the connection on the given port. +* `'busy'`: Another application is already streaming to this device. +* `'disconnected'`: The device closed the connection. This usually happens at the user's request. +* `'need_password'`: The device demands a password, but none was passed. +* `'bad_password'`: The device refused the given password. +* `'udp_ports'`: Could not bind UDP ports (these are required by AirPort v2). +* `'rtsp_socket'`: Another unhandled RTSP error. + +You can stop a device with: + +```javascript +device.stop(function() { + // device was stopped +}); +``` + +You can stop everything with: + +```javascript +airtunes.stopAll(function() { + // everything stopped +}); +``` + +### Volume + +Volume must be between 0 and 100. + +``` +device.setVolume(volume); +``` + +Support +------- + +Node-airtunes was tested on the following devices: + +* AirPort Express +* AirFoil Speakers +* Air Speaker +* Freebox Server +* Apple TV +* Zeppelin Air +* Raspberry PI +* Bose SoundTouch +* HomePod / HomePod mini +* Roku, Samsung, LG TVs + +Ping me to add more devices to this list. + +Known Issues +------------ + +While synchronization works nicely with AirTunes devices, there are sometimes problems with Core Audio. + +License +------- + +node-airtunes is available under the BSD license. + +How does it work ? +------------------ + +If you want detailed information about AirTunes v2, you should read the excellent documentation written by the [Airtunes 2 Team](http://git.zx2c4.com/Airtunes2/about/). I'm including theses short explanations for those not familiar with audio streaming in general. + +While being promoted as a proprietary protocol, AirTunes is really built on several existing protocols (RTSP, RTP and NTP) with several quirks. Not reinventing the wheel is a good thing. + +### RTSP Handshake + +AirTunes starts with an RTSP negociation. it is an HTTP-like protocol. The major difference being that it uses different verbs. Several successive requests are made to exchange parameters tneeded later. The `'status' = 'ready'` event is emitted when this handshake successfully completes. + +We follow this sequence: + +* `OPTIONS`: Apple added a proprietary `'Apple-Challenge'` header so that iTunes can check if the receiving device is legit. We do send the header, but we don't check the challenge response. +* `ANNOUNCE`: Among other things, we send an AES key and an IV (injection vector). The AES key is encrypted with a public RSA key shared by all AirTunes device. It is used to encrypt the audio packets. For simplicity's sake, we always use the same key/IV. +* `SETUP`: We send UDP ports for control and timing. These ports are chosen before the handshake starts. The device replies with ports of its own. +* `RECORD`: During record, we send the initial sequence and RTP time. These values allow devices to synchronize themselves. +* `SET_PARAMETER`: Used to change the volume. +* `TEARDOWN`: Used to terminate the connection. The stop callback is called when the device replies to this query. + +The RTSP socket stays open during the whole audio session. Since everything else is tranferred with UDP, closing this socket is the easiest way of letting the other peer know that the session is finished. + +### UDP Ports + +AirTunes v2 uses [RTP](http://en.wikipedia.org/wiki/Real-time_Transport_Protocol), which needs several UDP ports to transmit information. The ports are exchanged during the `SETUP` query. + +On the client (us): + +* Control: Used to send synchronization events and to receive information about resend packets. +* Timing: Devices send queries to this port to synchronize their clocks with the client. The format follows [NTP](http://en.wikipedia.org/wiki/Network_Time_Protocol). + +On the device: + +* Control: every second, the client sends a synchronization message to all devices. This message contains the current time and says: "you should be playing the packet with this timestamp right now". +* Timing: the port where we send timing replies. +* Audio: Where we send the audio stream. + +Each port has a preferred value (starting from UDP/6002). Since ports can be used by other applications, we test increasing port numbers until we can bind both the control and the timing sockets. The sockets are bound only when there are active AirTunes devices. + +### Audio Streaming + +The stream is first split in chunks by the circular buffer, each chunk containing exactly 352 frames. A PCM frame is just a single sample. We have 16 bits and 2 channels, so this translates into 4 bytes (chunks are 1408 bytes long). + +It is then compressed with [Apple Lossless](http://fr.wikipedia.org/wiki/Apple_Lossless), which was made public by Apple. The ALAC packet is then encrypted with AES. The key is chosen by the client and sent to devices during the `ANNOUNCE` query. We use native code to compress and encrypt packets. A gotcha: AES works by encrypting 16-byte chunks and the remaining bytes are not encrypted. + +Since we have no congestion control (UDP, remember), packets must be sent at the right time. There are 44,100 frames per second, so we need to send around 125 packets per second. Ideally, we should send a packet every 7-8 ms, but node.js's timing is not reliable at this precision. To make it work, we trigger a timeout every `config.stream_latency` ms. At each iteration, we compute the sequence number of the packet that we should be sending right now and we catch-up by sending in a burst all the packets that should have been sent since the last iteration. A higher latency reduces the CPU usage, but results in larger UDP bursts. + diff --git a/vendor/node-airtunes2/UPSTREAM.json b/vendor/node-airtunes2/UPSTREAM.json new file mode 100644 index 0000000..493d408 --- /dev/null +++ b/vendor/node-airtunes2/UPSTREAM.json @@ -0,0 +1,50 @@ +{ + "url": "https://registry.npmjs.org/node-airtunes2/-/node-airtunes2-2.5.0.tgz", + "integrity": "sha512-OMbxDCTwakRwTEZ/PKA6HJOOHD1FiqYnw98RtGx/X3lFsNXyKPUMLyR43t9lQArRCYGsGgLuPwDv9Ehkw4mhHQ==", + "sha256": "2cee1d86fcd2e5fb4f56f12e23fd1abbdbd0b71a9bc5e1ffe1dcbb885cec1d71", + "unmodifiedFiles": { + ".editorconfig": "4e40c0a34e2c5d50eeaeb682bb5fd3f72f79e5cbd744729d309bf3bc5f1a7742", + ".nvmrc": "f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56", + "LICENSE": "2c0434c24a2fd80ef3c0dcca3c12a7618f3b0194b6bf9dc545695be86dc6c8e0", + "README.md": "230de9b984355778dcd8524725abd408bd3981ff6d419372135e6eab9a765a00", + "binding.gyp": "53cb9eb3fb6ccf3d35f0db61446dbe549d38a592644ba671c25b247a047f2a38", + "eslint.config.mjs": "b737b0a39d662188d2183fd06588821a996ddd920bce57d7fed758d52a6dd2e2", + "example_execlient.js": "950d83e8069032fbec39ddc6cd5d1ac8a5241dd5742e44b797f46d40fe95d3d0", + "lib/ap2_test.js": "9d902c2ccf7ec6ba45800ddf2db46fe5e8c2ad959d60aed2feb151c6fe4f6804", + "lib/atv.js": "a46235ad8b4b02a4d3b8abca64eba6d36af00e1b7d66f3669f34c65efa2f5ddd", + "lib/audio_out.js": "0481f988ec3e76ce33a5fe771eeb20107183850e22310325a7cb4799b72f8cc6", + "lib/circular_buffer.js": "e5521fc371be58bf6aa61b0bbdb906a48da4fd90c012e6db45ce4979a85c7719", + "lib/config.js": "18a2e5bcc6c41804945c28af3c14ed3a306e19c48aad4f4624ff30a7c9e14e49", + "lib/device_airtunes.js": "4782a671f6fa0924e8a48119a69b6ff93764d2265aedb6e293f284c773261a9a", + "lib/devices.js": "90b40223384c81f5635bad7b27bbe0d2061a2653c70053aed53ba6ecb4be6852", + "lib/homekit/credentials.js": "aa74c977d9831f064164ef30a8a12fd2a9eb06ce4d787fc28d8a2c57bfc2c1a7", + "lib/homekit/encryption.js": "e1ce0ef009309797d91b70d5f27f8234ef9fda1bb325ecb78a6fd296d87e03db", + "lib/homekit/number.js": "fd34215e79e61f75fefab06188ff3041982b933d4c406c7638ccb90630f1c6cf", + "lib/homekit/tlv.js": "447d1544d6befbe2281d50027a419cdecf4c2c9ee9e226ba216161046545e678", + "lib/http.js": "c7c32939c30e214c6e64de5f63eb0f3764122e988a76d85e606b72d86492effa", + "lib/index.d.ts": "365cc6d2375896b8141c1f68b76d3e0940ede807f34b3dcbd428e4f45674d674", + "lib/index.js": "b1c112d4e7b8e378b9ec9fa21cf4b5147cd773f887c99062f54a6a2afb410e63", + "lib/ntp.js": "208491f53fb4dc9e872eec1953d13aebf7f3c6d042a32020faf402a92008a8df", + "lib/num_util.js": "6a9585f05cd0fdb709dfb7eb63d554de377831e8f37425cd01028f8fa1d85f13", + "lib/packet_pool.js": "aef0dc8c9fe4be88a212134b2703f3e6551454e991c8e04d869f3a2cddbb2960", + "lib/rtsp.js": "99f5fa32e4cb29817ee57d79dd103f8f3bee34217a8032e92df3b5c8b3daa3ca", + "lib/srp.js": "878ca193826b216edaff1f428cd7645d85bcc0d2a8c10de4e5145454a0f0d631", + "lib/udp_servers.js": "fcd41ba9ba2b25db8aa74cdb3606f8a8f0201c99cb6c26bbc30a344ade36380d", + "lib/util.js": "6aeffbe069d052157079bc4fd508ab616de80e08b219dff2a6f30ac4c8303c65", + "src/CADebugMacros.h": "bc139e5ceb89790191b71ce61038f532f755707608f123a8f4783eb6f98e6872", + "src/CAHostTimeBase.cpp": "e3db4379731e39ae6d5699aa9625dcf286cfc9ffacafa769c87b746d663186ce", + "src/CAHostTimeBase.h": "27d74cbf90e7b4e74f44b5dda11b18ebed962a77cc530c6a4eb4456cdad0fda2", + "src/aes_utils.c": "2d72155b2d9319abc1d4db695fdabfbbdbbaad157286df75a0500653352053ad", + "src/aes_utils.h": "500146ef43ea263263e15e39c91330deb979db13c85bb0c809f2c1b7243f08f0", + "src/base64.c": "79d33c06704f52ce09062deb4c9659a98f826db43625f5f1840a1d8250f16d9d", + "src/base64.h": "ed283d2c2b5e6593d683a97621c8a7994e590529ec6576811da39eaa9ee02146", + "src/bindings.cc": "38da78b95543422006a1d7c87118ee966cc970b2c353d24d751c43d0d44e0090", + "src/codec.cc": "ba2f0ab6071e09410daed8aa6ee4fcec20ed23c627b4b46cd7078ee70b74f109", + "src/coreaudio.cc": "487fcf7e7430be3faf30a9338080cc9147014c1c64c7e6debd3bede117e2a615" + }, + "excludedFiles": [ + "build/", + "SECURITY.md", + "exe_api.md" + ] +} diff --git a/vendor/node-airtunes2/binding.gyp b/vendor/node-airtunes2/binding.gyp new file mode 100644 index 0000000..3628c41 --- /dev/null +++ b/vendor/node-airtunes2/binding.gyp @@ -0,0 +1,41 @@ +{ + 'targets': [ + { + 'target_name': 'airtunes', + 'sources': [ + 'src/codec.cc', 'src/bindings.cc', + 'src/aes_utils.c', 'src/base64.c', + ], + 'conditions': [ + ['OS=="mac"', { + 'include_dirs': + ['/System/Library/Frameworks/Kernel.framework/Versions/A/Headers/sys' + ], + + 'sources': [ + ], + 'xcode_settings': { + 'CLANG_CXX_LANGUAGE_STANDARD': 'c++20' + } + }], + ['OS=="linux"', { + 'sources': [ + ], + 'cflags_cc': [ + "-std=c++20" + ] + }], + ['OS=="win"', { + 'include_dirs': ['C:\\Program Files\\OpenSSL-Win64\\include\\'], + "msbuild_settings": { + "ClCompile": { + "LanguageStandard": "stdcpp20" + } + }, + 'sources': [ + ], + }], + ] + } + ] +} diff --git a/vendor/node-airtunes2/eslint.config.mjs b/vendor/node-airtunes2/eslint.config.mjs new file mode 100644 index 0000000..0fcdbdc --- /dev/null +++ b/vendor/node-airtunes2/eslint.config.mjs @@ -0,0 +1,10 @@ +import js from '@eslint/js' +import globals from 'globals' +import { defineConfig } from 'eslint/config' +import stylistic from '@stylistic/eslint-plugin' + +export default defineConfig([ + { files: ['**/*.{js,mjs,cjs}'], plugins: { js }, extends: ['js/recommended'], languageOptions: { globals: globals.node } }, + { files: ['**/*.js'], languageOptions: { sourceType: 'commonjs' } }, + stylistic.configs.recommended, +]) diff --git a/vendor/node-airtunes2/example_execlient.js b/vendor/node-airtunes2/example_execlient.js new file mode 100644 index 0000000..ab84029 --- /dev/null +++ b/vendor/node-airtunes2/example_execlient.js @@ -0,0 +1,104 @@ +const spawn = require('child_process').spawn +const airtunes = spawn('airtunes2.exe') +const fetch = require('electron-fetch').default +const { WebSocket } = require('ws') +const ffmpeg = spawn('C:\\ffmpeg\\bin\\ffmpeg.exe', [ + '-i', 'http://radio.plaza.one/mp3_low', + '-acodec', 'pcm_s16le', + '-f', 's16le', // PCM 16bits, little-endian + '-ar', '44100', // Sampling rate + '-ac', 2, // Stereo + 'pipe:1', // Output on stdout +]) + +// pipe data to AirTunes +// ffmpeg.stdout.pipe(airtunes.stdin); + +// detect if ffmpeg was not spawned correctly +ffmpeg.stderr.setEncoding('utf8') +ffmpeg.stderr.on('data', function (data) { + if (/^execvp\(\)/.test(data)) { + console.log('failed to start ' + argv.ffmpeg) + process.exit(1) + } +}) +setTimeout(() => { + const ws = new WebSocket('ws://localhost:8980') + airtunes.stdout.pipe(process.stdout) + airtunes.stderr.pipe(process.stdout) + ws.on('error', console.error) + + // ws.on('open', function open() { + // ws.send(JSON.stringify({"type":"addDevices", + // "host":"192.168.100.5", + // "args":{"port":7000, + // "volume":20, "airplay2": true , + // // "password":"853228", + // //txt: ["cn=0,1","da=true","et=0,4","ft=0x445F8A00,0x1C340","fv=p20.72.2-40060","md=0,1,2","am=Bookshelf","sf=0x4","tp=UDP","vn=65537","vs=366.0","pk=lol"], + // //txt: ["acl=0","deviceid=","features=0x445F8A00,0x1C340","rsf=0x0","fv=p20.72.2-40060","flags=0x4","model=Bookshelf","manufacturer=Sonos","serialNumber=nol","protovers=1.1","srcvers=366.0","pi=nol","gid=nol","gcgl=0","pk=lolno"], + // "txt":["cn=0,1,2,3","da=true","et=0,3,5","ft=0x4A7FCA00,0xBC354BD0","sf=0xa0404","md=0,1,2","am=AudioAccessory5,1","pk=lolno","tp=UDP","vn=65537","vs=670.6.2","ov=16.2","vv=2"], + // //"txt":["cn=0,1,2,3","da=true","et=0,3,5","ft=0x4A7FCA00,0xBC354BD0","sf=0x80484","md=0,1,2","am=AudioAccessory5,1","pk=lol","tp=UDP","vn=65537","vs=670.6.2","ov=16.2","vv=2"], + // "debug":true, + // "forceAlac":false}})) + // }); + + ws.on('open', function open() { + ws.send(JSON.stringify({ type: 'addDevices', + // "devicetype": "googlecast", + host: '192.168.100.3', + args: { port: '7000', + volume: 20, airplay2: true, + // "password":"853228", + // txt: ["cn=0,1","da=true","et=0,4","ft=0x445F8A00,0x1C340","fv=p20.72.2-40060","md=0,1,2","am=Bookshelf","sf=0x4","tp=UDP","vn=65537","vs=366.0","pk=lol"], + // txt: ["acl=0","deviceid=","features=0x445F8A00,0x1C340","rsf=0x0","fv=p20.72.2-40060","flags=0x4","model=Bookshelf","manufacturer=Sonos","serialNumber=nol","protovers=1.1","srcvers=366.0","pi=nol","gid=nol","gcgl=0","pk=lolno"], + // "txt":["cn=0,1,2,3","da=true","et=0,3,5","ft=0x4A7FCA00,0xBC354BD0","sf=0xa0404","md=0,1,2","am=AudioAccessory5,1","pk=lolno","tp=UDP","vn=65537","vs=670.6.2","ov=16.2","vv=2"], + // "txt":["cn=0,1,2,3","da=true","et=0,3,5","ft=0x4A7FCA00,0xBC354BD0","sf=0x80484","md=0,1,2","am=AudioAccessory5,1","pk=lol","tp=UDP","vn=65537","vs=670.6.2","ov=16.2","vv=2"], + txt: ['features=0x527FFEF7,0x8', 'pw=0', 'flags=0x4', 'model=AppleTV3,1', 'rmodel=AirReceiver3,1', 'srcvers=211.3', 'pk=1ea28066c003a475811347ba91adb785d13a3f34beb0f1b16a5c16da703ee8e2', 'deviceid=64:D2:3C:CC:89:CF'], + debug: true, + forceAlac: true } })) + }) + + ffmpeg.stdout.on('data', function (data) { + try { + ws.send(JSON.stringify({ type: 'sendAudio', + data: data.toString('base64') })) + } + catch (err) {} + }) + + ws.on('message', function message(data) { + // console.log('received: %s', data); + data = JSON.parse(data) + if (data.status == 'ready') { + setInterval(() => { + fetch('https://api.plaza.one/status') + .then(res => res.json()).then((radiostatus) => { + ws.send(JSON.stringify( + { type: 'setTrackInfo', + devicekey: data.key, + artist: radiostatus.song.artist, + album: radiostatus.song.album, + name: radiostatus.song.title }, + )) + fetch(radiostatus.song.artwork_src) + .then(res => res.buffer()) + .then((buffer) => { + ws.send(JSON.stringify( + { type: 'setArtworkB64', + devicekey: data.key, + contentType: 'image/jpeg', + artworkURL: radiostatus.song.artwork_src, + artwork: buffer.toString('base64') }, + )) + }) + .catch((err) => { + console.log(err) + }) + }) + .catch((err) => { + console.log(err) + }) + }, 10000) + } + }) +}, 1000) diff --git a/vendor/node-airtunes2/lib/ap2_test.js b/vendor/node-airtunes2/lib/ap2_test.js new file mode 100644 index 0000000..c5613ac --- /dev/null +++ b/vendor/node-airtunes2/lib/ap2_test.js @@ -0,0 +1,4 @@ +const number = require('./homekit/number').default + +const u = number.UInt16toBufferBE(287) +console.log(Buffer.concat([u.slice(1), u.slice(0, 1)])) diff --git a/vendor/node-airtunes2/lib/atv.js b/vendor/node-airtunes2/lib/atv.js new file mode 100644 index 0000000..78d4688 --- /dev/null +++ b/vendor/node-airtunes2/lib/atv.js @@ -0,0 +1,307 @@ +const crypto = require('crypto') +const fs = require('fs') +const bplistCreator = require('bplist-creator') +const bplistParser = require('bplist-parser') +const SRP = require('./srp') +const ATVAuthenticator = require('./atvAuthenticator') +const httpClientFactory = require('./http') + +// ... +// Configuration. +const loadConfig = configFilePath => !fs.existsSync(configFilePath) ? null : JSON.parse(fs.readFileSync(configFilePath)) +const saveConfig = (configFilePath, config) => fs.writeFileSync(configFilePath, JSON.stringify(config, null, '\t')) + +// ... + +class ATV { + constructor(addr, port) { + this.addr = addr + this.port = port || 7000 + + this.httpClient = httpClientFactory() + this.auth_secret = null + } + + // ... + + auth(configFilePath, authenticator) { + async function auth(owner) { + await owner.httpClient.connect(owner.addr, owner.port) + + let conf = loadConfig(configFilePath) + if (!conf || !conf['auth_secret']) { + // a pairing does not exist and must be performed. + + // ... + // SRP parameters. + const srp = new SRP(2048) + + let I = '366B4165DD64AD3A' + let P + let s + let B + let a + let A + let M1 + + await owner.httpClient.request('POST', '/pair-pin-start') + .then(() => authenticator()) + .then((pin) => { + P = pin + + return owner.httpClient.request( + 'POST', + '/pair-setup-pin', + { + 'Content-Type': 'application/x-apple-binary-plist', + }, + bplistCreator({ + user: '366B4165DD64AD3A', + method: 'pin', + }), + ) + }) + .then((res) => { + const { pk, salt } = bplistParser.parseBuffer(res.body)[0] + + s = salt.toString('hex') + B = pk.toString('hex') + + // SRP: Generate random auth_secret, 'a'; if pairing is successful, it'll be utilized in + // subsequent session authentication(s). + a = crypto.randomBytes(32).toString('hex') + + // SRP: Compute A and M1. + A = srp.A(a) + M1 = srp.M1(I, P, s, a, B) + + return owner.httpClient.request( + 'POST', + '/pair-setup-pin', + { + 'Content-Type': 'application/x-apple-binary-plist', + }, + bplistCreator({ + pk: Buffer.from(A, 'hex'), + proof: Buffer.from(M1, 'hex'), + }), + ) + }) + .then(() => { + // confirm the auth secret (a). + const { epk, authTag } = ATVAuthenticator.confirm(a, srp.K(I, P, s, a, B)) + + // complete pair-setup-pin by registering the auth secret with the target device. + return owner.httpClient.request( + 'POST', + '/pair-setup-pin', + { + 'Content-Type': 'application/x-apple-binary-plist', + }, + bplistCreator({ + epk: Buffer.from(epk, 'hex'), + authTag: Buffer.from(authTag, 'hex'), + }), + ) + }) + .then(() => { + // save the auth secret for subsequent session authentication(s). + !conf && (conf = {}) + conf['auth_secret'] = a + saveConfig(configFilePath, conf) + }) + } + + // ... + // Authenticate session with the target device using existing pairing information. + const verifier = ATVAuthenticator.verifier(conf['auth_secret']) + + return owner.httpClient.request( + 'POST', + '/pair-verify', + { + 'Content-Type': 'application/octet-stream', + }, + verifier.verifierBody, + ) + .then((res) => { + const atv_pub = res.body.slice(0, 32).toString('hex') + const atv_data = res.body.slice(32).toString('hex') + + const shared = ATVAuthenticator.shared(verifier.v_pri, atv_pub) + const signed = ATVAuthenticator.signed(conf['auth_secret'], verifier.v_pub, atv_pub) + const signature = Buffer.from( + Buffer.from([0x00, 0x00, 0x00, 0x00]).toString('hex') + + ATVAuthenticator.signature(shared, atv_data, signed), + 'hex', + ) + + return owner.httpClient.request( + 'POST', + '/pair-verify', + { + 'Content-Type': 'application/octet-stream', + }, + signature, + ) + }) + }; + + return auth(this) + } + + authSecret() { + return this.auth_secret + } + + authSimple(authenticator) { + async function auth(owner) { + await owner.httpClient.connect(owner.addr, owner.port) + + let conf = null + if (!conf || !conf['auth_secret']) { + // a pairing does not exist and must be performed. + + // ... + // SRP parameters. + const srp = new SRP(2048) + + let I = '366B4165DD64AD3A' + let P + let s + let B + let a + let A + let M1 + + await owner.httpClient.request('POST', '/pair-pin-start') + .then(() => authenticator()) + .then((pin) => { + P = pin + + return owner.httpClient.request( + 'POST', + '/pair-setup-pin', + { + 'Content-Type': 'application/x-apple-binary-plist', + }, + bplistCreator({ + user: '366B4165DD64AD3A', + method: 'pin', + }), + ) + }) + .then((res) => { + const { pk, salt } = bplistParser.parseBuffer(res.body)[0] + + s = salt.toString('hex') + B = pk.toString('hex') + + // SRP: Generate random auth_secret, 'a'; if pairing is successful, it'll be utilized in + // subsequent session authentication(s). + a = crypto.randomBytes(32).toString('hex') + + // SRP: Compute A and M1. + A = srp.A(a) + M1 = srp.M1(I, P, s, a, B) + + return owner.httpClient.request( + 'POST', + '/pair-setup-pin', + { + 'Content-Type': 'application/x-apple-binary-plist', + }, + bplistCreator({ + pk: Buffer.from(A, 'hex'), + proof: Buffer.from(M1, 'hex'), + }), + ) + }).then(() => { + // confirm the auth secret (a). + const { epk, authTag } = ATVAuthenticator.confirm(a, srp.K(I, P, s, a, B)) + + // complete pair-setup-pin by registering the auth secret with the target device. + return owner.httpClient.request( + 'POST', + '/pair-setup-pin', + { + 'Content-Type': 'application/x-apple-binary-plist', + }, + bplistCreator({ + epk: Buffer.from(epk, 'hex'), + authTag: Buffer.from(authTag, 'hex'), + }), + ) + }) + .then(() => { + // save the auth secret for subsequent session authentication(s). + console.log(a) + owner.auth_secret = a + }) + } + } + return auth(this) + } + + verifySimple(secret) { + // ... + // Authenticate session with the target device using existing pairing information. + const verifier = ATVAuthenticator.verifier(secret) + + return owner.httpClient.request( + 'POST', + '/pair-verify', + { + 'Content-Type': 'application/octet-stream', + }, + verifier.verifierBody, + ) + .then((res) => { + const atv_pub = res.body.slice(0, 32).toString('hex') + const atv_data = res.body.slice(32).toString('hex') + + const shared = ATVAuthenticator.shared(verifier.v_pri, atv_pub) + const signed = ATVAuthenticator.signed(secret, verifier.v_pub, atv_pub) + const signature = Buffer.from( + Buffer.from([0x00, 0x00, 0x00, 0x00]).toString('hex') + + ATVAuthenticator.signature(shared, atv_data, signed), + 'hex', + ) + + return owner.httpClient.request( + 'POST', + '/pair-verify', + { + 'Content-Type': 'application/octet-stream', + }, + signature, + ) + }) + } + + play(videoUrl) { + return this.httpClient.request( + 'POST', + '/play', + { + 'Content-Type': 'application/x-apple-binary-plist', + }, + bplistCreator({ + 'Content-Location': videoUrl, + 'Start-Location': 0, + }), + ) + } + + stop() { + return this.httpClient.request('POST', '/stop') + } + + close() { + this.httpClient.close() + } +} + +// ... + +module.exports = ATV diff --git a/vendor/node-airtunes2/lib/atvAuthenticator.js b/vendor/node-airtunes2/lib/atvAuthenticator.js new file mode 100644 index 0000000..d030a70 --- /dev/null +++ b/vendor/node-airtunes2/lib/atvAuthenticator.js @@ -0,0 +1,136 @@ +// RadioCLI modification, 2026-10-16: replace elliptic with native Ed25519. +// Distributed under the accompanying AGPLv3; pairing formats remain unchanged. +const ed25519 = require('./ed25519') +const crypto = require('crypto') +const axlsign = require('axlsign') +const { hexString2ArrayBuffer, buf2hex } = require('./util') + +// ... +// Note: All functions expect parameters to be hex strings. + +function pair_setup_aes_key(K) { + return crypto.createHash('sha512') + .update('Pair-Setup-AES-Key') + .update(hexString2ArrayBuffer(K)) + .digest('hex') + .substring(0, 32) +} + +function pair_setup_aes_iv(K) { + let ab = crypto.createHash('sha512') + .update('Pair-Setup-AES-IV') + .update(hexString2ArrayBuffer(K)) + .digest() + + ab = ab.slice(0, 16) + ab[ab.length - 1] += 0x01 + + return buf2hex(ab) +} + +function pair_verify_aes_key(shared) { + return buf2hex( + crypto.createHash('sha512') + .update('Pair-Verify-AES-Key') + .update(hexString2ArrayBuffer(shared)) + .digest() + .slice(0, 16), + ) +} + +function pair_verify_aes_iv(shared) { + return buf2hex( + crypto.createHash('sha512') + .update('Pair-Verify-AES-IV') + .update(hexString2ArrayBuffer(shared)) + .digest() + .slice(0, 16), + ) +} + +// ... +// Public. + +function a_pub(a) { + return ed25519.publicKey(a) +} + +function confirm(a, K) { + const key = pair_setup_aes_key(K) + const iv = pair_setup_aes_iv(K) + + const cipher = crypto.createCipheriv( + 'aes-128-gcm', + hexString2ArrayBuffer(key), + hexString2ArrayBuffer(iv), + ) + + let encrypted = cipher.update(hexString2ArrayBuffer(a_pub(a)), null, 'hex') + encrypted += cipher.final('hex') + + return { + epk: encrypted, + authTag: buf2hex(cipher.getAuthTag()), + } +} + +function verifier(a) { + const keyPair = axlsign.generateKeyPair(crypto.randomBytes(32)) + const v_pri = buf2hex(keyPair.private) + const v_pub = buf2hex(keyPair.public) + + const header = Buffer.from([0x01, 0x00, 0x00, 0x00]) + const a_pub_buf = Buffer.from(a_pub(a), 'hex') + + return { + verifierBody: Buffer.concat( + [header, keyPair.public, a_pub_buf], + header.byteLength + keyPair.public.byteLength + a_pub_buf.byteLength, + ), + v_pri, + v_pub, + } +} + +function shared(v_pri, atv_pub) { + return buf2hex( + axlsign.sharedKey( + hexString2ArrayBuffer(v_pri), + hexString2ArrayBuffer(atv_pub), + ), + ) +} + +function signed(a, v_pub, atv_pub) { + return ed25519.sign(a, v_pub + atv_pub) +} + +function signature(shared, atv_data, signed) { + const cipher = crypto.createCipheriv( + 'aes-128-ctr', + hexString2ArrayBuffer(pair_verify_aes_key(shared)), + hexString2ArrayBuffer(pair_verify_aes_iv(shared)), + ) + + // discard the result of encrypting atv_data. + cipher.update(hexString2ArrayBuffer(atv_data)) + + let encrypted = cipher.update(Buffer.from(signed, 'hex'), null, 'hex') + encrypted += cipher.final('hex') + + return encrypted +} + +module.exports = { + pair_setup_aes_key, + pair_setup_aes_iv, + pair_verify_aes_key, + pair_verify_aes_iv, + + a_pub, + confirm, + verifier, + shared, + signed, + signature, +} diff --git a/vendor/node-airtunes2/lib/audio_out.js b/vendor/node-airtunes2/lib/audio_out.js new file mode 100644 index 0000000..20cd3fe --- /dev/null +++ b/vendor/node-airtunes2/lib/audio_out.js @@ -0,0 +1,69 @@ +const events = require('events') +const util = require('util') +const config = require('./config') +const nu = require('./num_util') + +const SEQ_NUM_WRAP = Math.pow(2, 16) + +function AudioOut() { + events.EventEmitter.call(this) + + this.lastSeq = -1 + this.hasAirTunes = false +} + +util.inherits(AudioOut, events.EventEmitter) + +AudioOut.prototype.init = function (devices, circularBuffer) { + var self = this + config.rtp_time_ref = new Date().getTime() + + devices.on('airtunes_devices', function (hasAirTunes) { + self.hasAirTunes = hasAirTunes + }) + + devices.on('need_sync', function () { + // a sync is forced when a new remote device is added + self.emit('need_sync', self.lastSeq) + }) + + function sendPacket(seq) { + var packet = circularBuffer.readPacket() + + packet.seq = seq % SEQ_NUM_WRAP + packet.timestamp = nu.low32(seq * config.frames_per_packet + 2 * config.sampling_rate) + + if (self.hasAirTunes && seq % config.sync_period == 0) + self.emit('need_sync', seq) + + self.emit('packet', packet) + packet.release() + } + + function syncAudio() { + /* + * Each time syncAudio() runs, a burst of packet is sent. + * Increasing config.stream_latency lowers CPU usage but increases the size of the burst. + * If the burst size exceeds the UDP windows size (which we do not know), packets are lost. + */ + var elapsed = new Date().getTime() - config.rtp_time_ref + + /* + * currentSeq is the # of the packet we should be sending now. We have some packets to catch-up + * since syncAudio is not always running. + */ + var currentSeq = Math.floor(elapsed * config.sampling_rate / (config.frames_per_packet * 1000)) + + for (let i = self.lastSeq + 1; i <= currentSeq; i++) + sendPacket(i) + + self.lastSeq = currentSeq + + // reschedule ourselves later + setTimeout(syncAudio, config.stream_latency) + } + + syncAudio() +} + +module.exports = AudioOut diff --git a/vendor/node-airtunes2/lib/circular_buffer.js b/vendor/node-airtunes2/lib/circular_buffer.js new file mode 100644 index 0000000..70a0345 --- /dev/null +++ b/vendor/node-airtunes2/lib/circular_buffer.js @@ -0,0 +1,133 @@ +const Stream = require('stream') +const util = require('util') +const PacketPool = require('./packet_pool') + +const WAITING = 0 +const FILLING = 1 +const NORMAL = 2 +const DRAINING = 3 +const ENDING = 4 +const ENDED = 5 + +function CircularBuffer(packetsInBuffer, packetSize) { + Stream.call(this) + + this.packetPool = new PacketPool() + this.maxSize = packetsInBuffer * packetSize + this.packetSize = packetSize + this.writable = true + this.muted = false + this.buffers = [] + this.currentSize = 0 + this.status = WAITING +} + +util.inherits(CircularBuffer, Stream) + +CircularBuffer.prototype.write = function (chunk) { + this.buffers.push(chunk) + this.currentSize += chunk.length + + if (this.status === ENDING || this.status === ENDED) + throw new Error('Cannot write in buffer after closing it') + + if (this.status === WAITING) { + // notify when we receive the first chunk + this.emit('status', 'buffering') + this.status = FILLING + } + + if (this.status === FILLING && this.currentSize > this.maxSize / 2) { + this.status = NORMAL + this.emit('status', 'playing') + } + + if (this.currentSize >= this.maxSize) { + this.status = DRAINING + return false + } + else { + return true + } +} + +CircularBuffer.prototype.readPacket = function () { + var packet = this.packetPool.getPacket() + + // play silence until buffer is filled enough + if (this.status !== ENDING && this.status !== ENDED + && (this.status === FILLING || this.currentSize < this.packetSize)) { + packet.pcm.fill(0) + + if (this.status !== FILLING && this.status !== WAITING) { + this.status = FILLING + this.emit('status', 'buffering') + } + } + else { + var offset = 0, remaining = this.packetSize + + // fill a whole packet with data + while (remaining > 0) { + // pad packet with silence if buffer is empty + if (this.buffers.length === 0) { + packet.pcm.fill(0, offset) + remaining = 0 + break + } + + var first = this.buffers[0] + + if (first.length <= remaining) { + // pop the whole buffer from the queue + first.copy(packet.pcm, offset) + offset += first.length + remaining -= first.length + this.buffers.shift() + } + else { + // first buffer contains enough data to fill a packet: slice it + first.copy(packet.pcm, offset, 0, remaining) + this.buffers[0] = first.slice(remaining) + offset += remaining + remaining = 0 + } + } + + this.currentSize -= this.packetSize + + // emit 'end' only once + if (this.status === ENDING && this.currentSize <= 0) { + this.status = ENDED + this.currentSize = 0 + this.emit('status', 'end') + } + + // notify that the buffer now has enough room if needed + if (this.status === DRAINING && this.currentSize < this.maxSize / 2) { + this.status = NORMAL + this.emit('drain') + } + } + + if (this.muted) + packet.pcm.fill(0) + + return packet +} + +CircularBuffer.prototype.end = function () { + // flush the buffer if it was filling + if (this.status === FILLING) + this.emit('status', 'playing') + + this.status = ENDING +} + +CircularBuffer.prototype.reset = function () { + this.buffers = [] + this.currentSize = 0 + this.status = WAITING +} + +module.exports = CircularBuffer diff --git a/vendor/node-airtunes2/lib/config.js b/vendor/node-airtunes2/lib/config.js new file mode 100644 index 0000000..a6564f6 --- /dev/null +++ b/vendor/node-airtunes2/lib/config.js @@ -0,0 +1,26 @@ +const nu = require('./num_util') + +const Config = { + user_agent: 'iTunes/11.3.1 (Windows; Microsoft Windows 10 x64 (Build 19044); x64) (dt:2)', + udp_default_port: 54621, // preferred starting port in AirTunes v2 + frames_per_packet: 352, // samples per frames in ALAC packets + channels_per_frame: 2, // always stereo in AirTunes v2 + bits_per_channel: 16, // -> 2 bytes per channel + packet_size: 352 * 2 * 2, // frames*channels*bytes + packets_in_buffer: 200, // increase this buffer protects against network issues + coreaudio_min_level: 5, // if CoreAudio's internal buffer drops too much, inject some silence to raise it + coreaudio_check_period: 2000, // CoreAudio buffer level check period + coreaudio_preload: 352 * 2 * 2 * 50, // ~0.5s of silence pushed to CoreAudio to avoid draining AudioQueue + sampling_rate: 44100, // fixed by AirTunes v2 + sync_period: 126, // UDP sync packets are sent to all AirTunes devices regularly + stream_latency: 100, // audio UDP packets are flushed in bursts periodically + rtsp_timeout: 2147483647, // RTSP servers are considered gone if no reply is received before the timeout + rtsp_heartbeat: 15000, // some RTSP (like HomePod) servers requires heartbeat. Recommanded value: 60000 (1 minute). Set to 0 if you want to disable it. + rtp_time_ref: 0, + device_magic: nu.randomInt(9), + ntp_epoch: 0x83aa7e80, + iv_base64: 'ePRBLI0XN5ArFaaz7ncNZw', + rsa_aeskey_base64: 'VjVbxWcmYgbBbhwBNlCh3K0CMNtWoB844BuiHGUJT51zQS7SDpMnlbBIobsKbfEJ3SCgWHRXjYWf7VQWRYtEcfx7ejA8xDIk5PSBYTvXP5dU2QoGrSBv0leDS6uxlEWuxBq3lIxCxpWO2YswHYKJBt06Uz9P2Fq2hDUwl3qOQ8oXb0OateTKtfXEwHJMprkhsJsGDrIc5W5NJFMAo6zCiM9bGSDeH2nvTlyW6bfI/Q0v0cDGUNeY3ut6fsoafRkfpCwYId+bg3diJh+uzw5htHDyZ2sN+BFYHzEfo8iv4KDxzeya9llqg6fRNQ8d5YjpvTnoeEQ9ye9ivjkBjcAfVw', +} + +module.exports = Config diff --git a/vendor/node-airtunes2/lib/device_airtunes.js b/vendor/node-airtunes2/lib/device_airtunes.js new file mode 100644 index 0000000..478eadb --- /dev/null +++ b/vendor/node-airtunes2/lib/device_airtunes.js @@ -0,0 +1,532 @@ +const dgram = require('dgram') +const events = require('events') +const util = require('util') +const crypto = require('crypto') +const process = require('process') +const config = require('./config') +const nu = require('./num_util') +const RTSP = require('./rtsp') +const UDPServers = require('./udp_servers') + +const RTP_HEADER_SIZE = 12 + +function BufferWithNames(size) { + // Very simple solution for this immediate need... + // I call it BufferWithNames because the names are not unique keys. + this.size = size + this.buffer = [] +} + +BufferWithNames.prototype.add = function (name, item) { + while (this.buffer.length > this.size) { + this.buffer.shift() + } + this.buffer.push([name, item]) +} + +BufferWithNames.prototype.getLatestNamed = function (name) { + var buf = this.buffer + for (let i = buf.length - 1; i >= 0; i--) { + if (buf[i][0] == name) { + return buf[i][1] + } + } +} + +function AirTunesDevice(host, audioOut, options, mode = 0, txt = '') { + events.EventEmitter.call(this) + + if (!host) + throw new Error('host is mandatory') + + this.bindings = null + this.bindingsok = false + this.audioPacketHistory = new BufferWithNames(100) + try { + if (!(process.platform == 'darwin')) { // MacOSX arm64 segfaults on airtunes.node + this.bindings = require('../build/Release/airtunes') + this.bindingsok = true + } + } + catch (e) {} + + this.udpServers = new UDPServers() + this.audioOut = audioOut + this.type = 'airtunes' + this.options = options + this.host = host + this.port = options?.port || 5000 + this.key = this.host + ':' + this.port + this.mode = mode // Homepods with or without passcode + // if(options.password != null && legacy == true){ + // this.mode = 1; // Airport / Shairport legacy passcode mode + // this.mode = 2 // MFi mode + // } + this.forceAlac = options.forceAlac ?? true + // this.skipAutoVolume = options.skipAutoVolume ?? false + this.statusflags = [] + this.alacEncoding = options?.alacEncoding ?? true + this.airplay2 = options?.airplay2 ?? false + this.txt = txt + this.borkedshp = false + console.log('yasc', this.txt) + console.log('port', this.port) + let a = this.txt.filter(u => String(u).startsWith('et=')) + if ((a[0] ?? '').includes('4')) { + this.mode = 2 + } + let b = this.txt.filter(u => String(u).startsWith('cn=')) + if (!this.forceAlac) { + if ((b[0] ?? '').includes('0')) { + this.alacEncoding = false + } + } + let c = this.txt.filter(u => String(u).startsWith('sf=')) + this.statusflags = c[0] ? parseInt(c[0].substring(3)).toString(2).split('') : [] + if (c.length == 0) { + c = this.txt.filter(u => String(u).startsWith('flags=')) + this.statusflags = c[0] ? parseInt(c[0].substring(6)).toString(2).split('') : [] + } + this.needPassword = false + this.needPin = false + this.transient = false + let d = '' + d = this.txt.filter(u => String(u).startsWith('features=')) + if (d.length == 0) d = this.txt.filter(u => String(u).startsWith('ft=')) + let features_set = d.length > 0 ? d[0].substring(d[0].indexOf('=') + 1).split(',') : [] + this.features = [...features_set.length > 0 ? parseInt(features_set[0]).toString(2).split('') : [], ...features_set.length > 1 ? parseInt(features_set[1]).toString(2).split('') : []] + if (this.features.length > 0) { + this.transient = (this.features[this.features.length - 1 - 48] == '1') + } + + if (this.statusflags != []) { + let PasswordRequired = (this.statusflags[this.statusflags.length - 1 - 7] == '1') + let PinRequired = (this.statusflags[this.statusflags.length - 1 - 3] == '1') + let OneTimePairingRequired = (this.statusflags[this.statusflags.length - 1 - 9] == '1') + console.log('needPss', PasswordRequired, PinRequired, OneTimePairingRequired) + this.needPassword = PasswordRequired + this.needPin = (PinRequired || OneTimePairingRequired) + this.transient = (!(PasswordRequired || PinRequired || OneTimePairingRequired)) ?? true + console.log('needPss', this.needPassword) + } + console.log('transient', this.transient) + // detect old shairports with broken text + let oldver1 = this.txt.filter(u => String(u).startsWith('sm=')) + let oldver2 = this.txt.filter(u => String(u).startsWith('sv=')) + if ((b[0] ?? '') == 'cn=0,1' && (a[0] ?? '') == 'et=0,1' && (oldver1[0] ?? '') == 'sm=false' && (oldver2[0] ?? '') == 'sv=false' && this.statusflags.length == 0) { + console.log('borked shairport found') + this.alacEncoding = true + this.borkedshp = true + } + let k = this.txt.filter(u => String(u).startsWith('am=')) + if ((k[0] ?? '').includes('AppleTV3,1') || (k[0] ?? '').includes('AirReceiver3,1') || (k[0] ?? '').includes('AirRecever3,1') || (k[0] ?? '').includes('Shairport')) { + this.alacEncoding = true + this.airplay2 = false + } + k = this.txt.filter(u => String(u).startsWith('rmodel=')) + if ((k[0] ?? '').includes('AppleTV3,1') || (k[0] ?? '').includes('AirReceiver3,1') || (k[0] ?? '').includes('AirRecever3,1') || (k[0] ?? '').includes('Shairport')) { + this.alacEncoding = true + this.airplay2 = false + } + let manufacturer = this.txt.filter(u => String(u).startsWith('manufacturer=')) + if ((manufacturer[0] ?? '').includes('Sonos')) { + this.mode = 2 + this.needPin = true + } + console.log('needPin', this.needPin) + console.log('mode-atv', this.mode) + console.log('alacEncoding', this.alacEncoding) + try { + this.rtsp = new RTSP.Client(options.volume || 50, options.password || null, audioOut, + { + mode: this.mode, + txt: this.txt, + alacEncoding: this.alacEncoding, + needPassword: this.needPassword, + airplay2: this.airplay2, + needPin: this.needPin, + debug: options.debug, + transient: this.transient, + borkedshp: this.borkedshp, + }) + } + catch (e) { + console.log('rtsp error', e) + } + this.audioCallback = null + this.encoder = [] + this.credentials = null + + // this.func = ` + // const {Worker, isMainThread, parentPort, workerData} = require('node:worker_threads'); + // var { WebSocketServer } = require('ws'); + // const wss = new WebSocketServer({ port: 8980 }); + // wss.on('connection', function connection(ws) { + // ws.on('message', function message(data) { + // parentPort.postMessage({message: data}); + // }); + // parentPort.on("message", data => { + // console.log("ass"); + // ws.send(data); + // }); + // });`; + // this.worker = new Worker(func, {eval: true}); +} + +util.inherits(AirTunesDevice, events.EventEmitter) + +AirTunesDevice.prototype.start = function () { + var self = this + this.audioSocket = dgram.createSocket('udp4') + + // Wait until timing and control ports are chosen. We need them in RTSP handshake. + this.udpServers.on('ports', function (err) { + if (err) { + console.log(err.code) + self.status = 'stopped' + self.emit('status', 'stopped') + console.log('port issues') + self.emit('error', 'udp_ports', err.code) + + return + } + self.doHandshake() + }) + + this.udpServers.bind(this.host) +} + +AirTunesDevice.prototype.doHandshake = function () { + var self = this + try { + if (this.rtsp == null) { + try { + this.rtsp = new RTSP.Client(this.options.volume || 30, this.options.password || null, this.audioOut, + { + mode: this.mode, + txt: this.txt, + alacEncoding: this.alacEncoding, + needPassword: this.needPassword, + airplay2: this.airplay2, + needPin: this.needPin, + debug: true, + transient: this.transient, + borkedshp: this.borkedshp, + }) + } + catch (e) { + console.log(e) + } + } + this.rtsp.on('config', function (setup) { + self.audioLatency = setup.audioLatency + self.requireEncryption = setup.requireEncryption + self.serverPort = setup.server_port + self.controlPort = setup.control_port + self.timingPort = setup.timing_port + self.credentials = setup.credentials + }) + + this.rtsp.on('ready', function () { + self.relayAudio() + }) + + this.rtsp.on('need_password', function () { + self.emit('status', 'need_password') + }) + + this.rtsp.on('pair_failed', function () { + self.emit('status', 'pair_failed') + }) + + this.rtsp.on('pair_success', function () { + self.emit('status', 'pair_success') + }) + + this.rtsp.on('end', function (err) { + console.log(err) + self.cleanup() + + if (err !== 'stopped') + self.emit(err) + }) + } + catch (e) { + console.log(e) + } + // console.log(this.udpServers, this.host,this.port) + this.rtsp.startHandshake(this.udpServers, this.host, this.port) +} + +AirTunesDevice.prototype.relayAudio = function () { + var self = this + this.status = 'ready' + this.emit('status', 'ready') + + this.audioCallback = function (packet) { + var airTunes = makeAirTunesPacket(packet, self.encoder, self.requireEncryption, self.alacEncoding, self.credentials, self.bindings, self.bindingsok) + // if (self.credentials) { + // airTunes = self.credentials.encrypt(airTunes) + // } + if (self.audioSocket == null) { + self.audioSocket = dgram.createSocket('udp4') + } + self.audioSocket.send( + airTunes, 0, airTunes.length, + self.serverPort, self.host, + ) + } + // this.sendAirTunesPacket = function(airTunes) { + // try{ + // if(self.audioSocket == null){ + // self.audioSocket = dgram.createSocket('udp4'); + // } + // self.audioSocket.send( + // airTunes, 0, airTunes.length, + // self.serverPort, self.host + // );} catch(e){ + + // console.log('send error',e) + // } + // }; + + // this.audioCallback = function(packet) { + // var airTunes = makeAirTunesPacket(packet, self.encoder, self.requireEncryption, self.alacEncoding, self.credentials, self.bindings, self.bindingsok); + // try{ + // self.sendAirTunesPacket(airTunes); + // self.audioPacketHistory.add(packet.seq, airTunes); // If we need to resend it + // } catch(e){} + // }; + + // this.udpServers.on('resendRequested', function (missedSeq, count) { + // try{ + // for (var i = 0; i < count; i++) { + // airTunes = self.audioPacketHistory.getLatestNamed(missedSeq + i); + // if (airTunes != null) + // self.sendAirTunesPacket(airTunes);}} + // catch (_){} + // }); + + this.audioOut.on('packet', this.audioCallback) +} + +AirTunesDevice.prototype.onSyncNeeded = function (seq) { + this.udpServers.sendControlSync(seq, this) + // if ( this.airplay2)this.rtsp.sendControlSync(seq, this, this.rtsp); +} + +AirTunesDevice.prototype.cleanup = function () { + this.audioSocket = null + this.audioPacketHistory = null + this.status = 'stopped' + this.emit('status', 'stopped') + console.log('stop') + if (this.audioCallback) { + this.audioOut.removeListener('packet', this.audioCallback) + this.audioCallback = null + } + + this.udpServers.close() + this.removeAllListeners() + this.rtsp = null +} + +AirTunesDevice.prototype.reportStatus = function () { + this.emit('status', this.status) +} + +AirTunesDevice.prototype.stop = function (cb) { + try { + this.rtsp.once('end', function () { + if (cb) + cb() + }) + console.log('ted') + this.rtsp.teardown() + } + catch (_) {} +} + +AirTunesDevice.prototype.setVolume = function (volume, callback) { + this.rtsp.setVolume(volume, callback) +} + +AirTunesDevice.prototype.setTrackInfo = function (name, artist, album, callback) { + this.rtsp.setTrackInfo(name, artist, album, callback) +} + +AirTunesDevice.prototype.setProgress = function (progress, duration, callback) { + this.rtsp.setProgress(progress, duration, callback) +} + +AirTunesDevice.prototype.setArtwork = function (art, contentType, callback) { + this.rtsp.setArtwork(art, contentType, callback) +} + +AirTunesDevice.prototype.setPasscode = function (password) { + this.rtsp.setPasscode(password) +} + +AirTunesDevice.prototype.requireEncryption = function () { + return this.requireEncryption +} + +module.exports = AirTunesDevice + +function makeAirTunesPacket(packet, encoder, requireEncryption, alacEncoding = true, credentials = null, bindings, bindingsok) { + // console.log("alacEncoding2",alacEncoding) + var alac = (alacEncoding || credentials) ? pcmToALAC(encoder, packet.pcm, bindings, bindingsok) : pcmParse(encoder, packet.pcm) + var airTunes = new Buffer(alac.length + RTP_HEADER_SIZE) + + const header = makeRTPHeader(packet) + if (requireEncryption) { + if (bindingsok) { + if (bindings == null) { + bindings = require('../build/Release/airtunes') + } + bindings.encryptAES(alac, alac.length) + } + else { + alac = encryptAES(alac, alac.length) + } + } + if (credentials) { + let pcm = credentials.encryptAudio(alac, header.slice(4, 12), packet.seq) + let airplay = new Buffer(RTP_HEADER_SIZE + pcm.length) + header.copy(airplay) + pcm.copy(airplay, RTP_HEADER_SIZE) + return airplay + // console.log(alac.length) + } + else { + header.copy(airTunes) + alac.copy(airTunes, RTP_HEADER_SIZE) + return airTunes + } +} + +function pcmToALAC(encoder, pcmData, bindings, bindingsok) { + var alacData = new Buffer(config.packet_size + 8) + if (bindingsok == true) { + var alacSize = bindings.encodeALAC(encoder, pcmData, alacData, pcmData.length) + return alacData.slice(0, alacSize) + } + else { + // I only did the actual computational part, the rest that I didn't do should be realitively simple to do. + let bsize = 352, frames = 352 // Set these to whatever they should be + const p = new Uint8Array((352 * 2 * 2) + 8) // p = *out; + const input = new Uint32Array(pcmData.length / 4) + let j = 0 + for (let i = 0; i < pcmData.length; i += 4) { + let res = pcmData[i] + res |= pcmData[i + 1] << 8 + res |= pcmData[i + 2] << 16 + res |= pcmData[i + 3] << 24 + input[j++] = res + } // uint32_t *in = (uint32_t*) sample; + + let pindex = 0, iindex = 0 + + p[pindex++] = 1 << 5 // 0b100000 + p[pindex++] = 0 + // 0b1001x, where x = Most Significant Bit of bsize, or basically just { set x if (bsize > 0x80000000) } + p[pindex++] = (1 << 4) | (1 << 1) | ((bsize & 0x80000000) >>> 31) + // bXX--bYY = bits XX to YY of bsize + // So we basically just splitting bsize into the individual byte values and storing them in p + // We've also shifted everything to the left by one (hence why we need the bit from bsize above) + p[pindex++] = ((bsize & 0x7f800000) << 1) >>> 24 // b30--b23 + p[pindex++] = ((bsize & 0x007f8000) << 1) >>> 16 // b22--b15 + p[pindex++] = ((bsize & 0x00007f80) << 1) >>> 8 // b14--b7 + p[pindex] = ((bsize & 0x0000007f) << 1) // b6--b0 + // And this is why we shifted the bits to the left. + p[pindex++] |= (input[iindex] & 0x00008000) >>> 15 // b7 from in[iindex] + + let count = frames - 1 + + while (count--) { + let i = input[iindex++] // Just to make it a bit easier to read + + // This is weird lmao. Everything that we're adding has been shifted left by one. + // And here, we're soring the lower 16 bits then the higher 16 bits. + p[pindex++] = ((i & 0x00007f80) >>> 7) // b14--b7 + p[pindex++] = ((i & 0x0000007f) << 1) | ((i & 0x80000000) >>> 31) // b6--b0, b31 + p[pindex++] = ((i & 0x7f800000) >>> 23) // b30--b23 + p[pindex++] = ((i & 0x007f0000) >>> 15) | ((input[iindex] & 0x00008000) >> 15)// b16--b15, b7 from in[pindex] + } + + // Last Sample + let i = input[iindex] + p[pindex++] = ((i & 0x00007f80) >>> 7) // b14--b7 + p[pindex++] = ((i & 0x0000007f) << 1) | ((i & 0x80000000) >>> 31) // b6--b0, b31 + p[pindex++] = ((i & 0x7f800000) >>> 23) // b30--b23 + p[pindex++] = ((i & 0x007f0000) >>> 15) // b16--b15, 0 as last bit because we have no more data after this + + // When we've read all we can from in, we need to fill the remaining space in p with 0's + count = (bsize - frames) * 4 + while (count--) p[pindex++] = 0 + + // Frame Footer ?? + p[pindex - 1] |= 1 + p[pindex++] = (7 >>> 1) << 6 + + // const size = pindex; + + const alacSize = pindex // Should be right + alacData = Buffer.from(p.buffer) + return alacData.slice(0, alacSize) + } + // alacData = new Buffer(p); + + // var alacSize = bindings.encodeALAC(encoder, pcmData, alacData, pcmData.length); + // console.log(alacData) +} + +function pcmParse(encoder, pcmData) { + let dst = new Uint8Array(352 * 4) + let src = pcmData + + let a = 0 + let b = 0 + for (let size = 0; size < 352; size++) { + dst[a++] = src[b + 1] + dst[a++] = src[b++] + b++ + + dst[a++] = src[b + 1] + dst[a++] = src[b++] + b++ + } + return Buffer.from(dst) +} + +function encryptAES(alacData, alacSize) { + let result = new Buffer.concat([]) + const isv = Buffer.from([0x78, 0xf4, 0x41, 0x2c, 0x8d, 0x17, 0x37, 0x90, 0x2b, 0x15, 0xa6, 0xb3, 0xee, 0x77, 0x0d, 0x67]) + const aes_key = Buffer.from([0x14, 0x49, 0x7d, 0xcc, 0x98, 0xe1, 0x37, 0xa8, 0x55, 0xc1, 0x45, 0x5a, 0x6b, 0xc0, 0xc9, 0x79]) + let remainder = alacData.length % 16 + let end_of_encoded_data = alacData.length - remainder + let cipher = crypto.createCipheriv('aes-128-cbc', aes_key, isv) + cipher.setAutoPadding(false) + + for (let i = 0, l = end_of_encoded_data - 16; i <= l; i += 16) { + let chunk = cipher.update(alacData.slice(i, i + 16)) + result = Buffer.concat([result, chunk]) + } + return Buffer.concat([result, alacData.slice(end_of_encoded_data)]) +} + +function makeRTPHeader(packet) { + var header = new Buffer(RTP_HEADER_SIZE) + + if (packet.seq === 0) + header.writeUInt16BE(0x80e0, 0) + else + header.writeUInt16BE(0x8060, 0) + + header.writeUInt16BE(nu.low16(packet.seq), 2) + + header.writeUInt32BE(packet.timestamp, 4) + header.writeUInt32BE(config.device_magic, 8) + + return header +} diff --git a/vendor/node-airtunes2/lib/devices.js b/vendor/node-airtunes2/lib/devices.js new file mode 100644 index 0000000..a11c98b --- /dev/null +++ b/vendor/node-airtunes2/lib/devices.js @@ -0,0 +1,266 @@ +const events = require('events') +const util = require('util') +const async = require('async') +const AirTunesDevice = require('./device_airtunes') + +function Devices(audioOut) { + events.EventEmitter.call(this) + + this.source = null + this.devices = {} + this.hasAirTunes = false + this.audioOut = audioOut +} + +util.inherits(Devices, events.EventEmitter) + +Devices.prototype.init = function () { + var self = this + self.audioOut.on('need_sync', function (seq) { + // relay to all devices + + self.forEach(function (dev) { + try { + if (dev.onSyncNeeded && dev.controlPort) + dev.onSyncNeeded(seq) + } + catch (e) {} + }) + }) +} + +Devices.prototype.forEach = function (it) { + for (const i in this.devices) { + if (!this.devices.hasOwnProperty(i)) + continue + + it(this.devices[i], i) + } +} + +Devices.prototype.add = function (type, host, options) { + var self = this + + this.status = 'connecting' + var dev + // type === 'coreaudio' ? + // new CoreAudioDevice(this.hasAirTunes, this.audioOut, options) : + = new AirTunesDevice(host, this.audioOut, options, options?.mode ?? 0, options?.txt ?? []) + + var previousDev = this.devices[dev.key] + + if (previousDev) { + // if device is already in the pool, just report its existing status. + previousDev.reportStatus() + + return previousDev + } + + this.devices[dev.key] = dev + + dev.on('status', function (status, arg) { + if (status === 'error' || status === 'stopped') { + delete self.devices[dev.key] + self.checkAirTunesDevices() + } + + if (this.hasAirTunes && status === 'playing') { + self.emit('need_sync') + } + + self.emit('status', dev.key, status, '') + }) + + dev.start() + self.checkAirTunesDevices() + + return dev +} + +Devices.prototype.setVolume = function (key, volume, callback) { + var dev = this.devices[key] + + if (!dev) { + this.emit('status', key, 'error', 'not_found') + + return + } + + dev.setVolume(volume, callback) +} + +Devices.prototype.setProgress = function (key, progress, duration, callback) { + try { + if (key === 'all') { + for (const device of Object.keys(this.devices)) { + try { + this.devices[device].setProgress(progress, duration, callback) + } + catch (e) { + try { + if (e.name === 'TypeError') + delete this.devices[device] + } + catch (e) {} + + console.log(e) + } + }; + } + else { + var dev = this.devices[key] + + if (!dev) { + this.emit('status', key, 'error', 'not_found') + + return + } + + dev.setProgress(progress, duration, callback) + } + } + catch (e) { console.log(e) } +} + +Devices.prototype.setTrackInfo = function (key, name, artist, album, callback) { + try { + if (key === 'all') { + for (const device of Object.keys(this.devices)) { + try { + this.devices[device].setTrackInfo(name, artist, album, callback) + } + catch (e) { + try { + if (e.name === 'TypeError') + delete this.devices[device] + } + catch (e) {} + console.log(e) + } + } + } + else { + var dev = this.devices[key] + + if (!dev) { + this.emit('status', key, 'error', 'not_found') + + return + } + else { + dev.setTrackInfo(name, artist, album, callback) + } + } + } + catch (e) { console.log(e) } +} + +Devices.prototype.setArtwork = function (key, art, contentType, callback) { + try { + if (key === 'all') { + for (const device of Object.keys(this.devices)) { + try { + this.devices[device].setArtwork(art, contentType, callback) + } + catch (e) { + try { + if (e.name === 'TypeError') + delete this.devices[device] + } + catch (e) {} + console.log(e) + } + } + } + else { + var dev = this.devices[key] + + if (!dev) { + this.emit('status', key, 'error', 'not_found') + + return + } + + dev.setArtwork(art, contentType, callback) + } + } + catch (e) { console.log(e) } +} + +Devices.prototype.setPasscode = function (key, passcode) { + var dev = this.devices[key] + + if (!dev) { + this.emit('status', key, 'error', 'not_found') + + return + } + + dev.setPasscode(passcode) +} + +Devices.prototype.stop = function (key) { + var dev = this.devices[key] + if (!dev) { + this.emit('status', key, 'error', 'not_found') + + return + } + dev.stop() + this.devices[key] = undefined +} + +Devices.prototype.stopAll = function (allCb) { + // conver to array to make async happy + var devices = [] + for (const i in this.devices) { + if (!this.devices.hasOwnProperty(i)) + continue + + devices.push(this.devices[i]) + } + if (allCb != null) { + async.forEach( + devices, + function (dev, cb) { + dev.stop(cb) + }, + function () { + this.devices = {} + allCb() + }, + ) + } +} + +Devices.prototype.checkAirTunesDevices = function () { + try { + var newHasAirTunes = false + + for (const key in this.devices) { + if (!this.devices.hasOwnProperty(key)) + continue + + var device = this.devices[key] + + // if(device.type === 'airtunes') { + newHasAirTunes = true + break + // } + } + + if (newHasAirTunes !== this.hasAirTunes) { + this.emit('airtunes_devices', newHasAirTunes) + + this.forEach(function (dev) { + if (dev.setHasAirTunes) + dev.setHasAirTunes(newHasAirTunes) + }) + } + + this.hasAirTunes = newHasAirTunes + } + catch (e) { console.log(e) } +} + +module.exports = Devices diff --git a/vendor/node-airtunes2/lib/ed25519.js b/vendor/node-airtunes2/lib/ed25519.js new file mode 100644 index 0000000..2a0150a --- /dev/null +++ b/vendor/node-airtunes2/lib/ed25519.js @@ -0,0 +1,26 @@ +// RadioCLI modification, 2026-10-16. Distributed under the accompanying AGPLv3. +// Preserve the sender's synchronous hex API using Node's Ed25519 implementation. +const crypto = require('node:crypto') +const pkcs8Prefix = Buffer.from('302e020100300506032b657004220420', 'hex') + +function bytes(hex, length) { + if (typeof hex !== 'string' || !/^(?:[a-f\d]{2})*$/i.test(hex) || (length !== undefined && hex.length !== length * 2)) { + throw new TypeError('Invalid Ed25519 hexadecimal input') + } + return Buffer.from(hex, 'hex') +} + +function privateKey(seed) { + return crypto.createPrivateKey({key: Buffer.concat([pkcs8Prefix, bytes(seed, 32)]), format: 'der', type: 'pkcs8'}) +} + +function publicKey(seed) { + const key = crypto.createPublicKey(privateKey(seed)).export({format: 'jwk'}) + return Buffer.from(key.x, 'base64url').toString('hex') +} + +function sign(seed, message) { + return crypto.sign(null, bytes(message), privateKey(seed)).toString('hex').toUpperCase() +} + +module.exports = {publicKey, sign} diff --git a/vendor/node-airtunes2/lib/homekit/credentials.js b/vendor/node-airtunes2/lib/homekit/credentials.js new file mode 100644 index 0000000..57c0c6c --- /dev/null +++ b/vendor/node-airtunes2/lib/homekit/credentials.js @@ -0,0 +1,86 @@ +'use strict' +Object.defineProperty(exports, '__esModule', { value: true }) +const struct = require('python-struct') +const encryption_1 = require('./encryption') + +class Credentials { + constructor(uniqueIdentifier, identifier, pairingId, publicKey, encryptionKey) { + this.uniqueIdentifier = uniqueIdentifier + this.identifier = identifier + this.pairingId = pairingId + this.publicKey = publicKey + this.encryptionKey = encryptionKey + this.encryptCount = 0 + this.decryptCount = 0 + } + + /** + * Parse a credentials string into a Credentials object. + * @param text The credentials string. + * @returns A credentials object. + */ + static parse(text) { + let parts = text.split(':') + return new Credentials(parts[0], Buffer.from(parts[1], 'hex'), Buffer.from(parts[2], 'hex').toString(), Buffer.from(parts[3], 'hex'), Buffer.from(parts[4], 'hex')) + } + + /** + * Returns a string representation of a Credentials object. + * @returns A string representation of a Credentials object. + */ + toString() { + return this.uniqueIdentifier + + ':' + + this.identifier.toString('hex') + + ':' + + Buffer.from(this.pairingId).toString('hex') + + ':' + + this.publicKey.toString('hex') + + ':' + + this.encryptionKey.toString('hex') + } + + encrypt(message) { + let offset = 0 + let total = message.byteLength + let result = Buffer.concat([]) + while (offset < total) { + let length = Math.min(total - offset, 1024) + let s1length_bytes = struct.pack('H', length) + // let cipher = crypto.createCipheriv('chacha20-poly1305', this.writeKey, Buffer.concat([Buffer.from([0x00,0x00,0x00,0x00]),struct.pack("Q", this.decryptCount)]), { authTagLength: 16 }); + // cipher.setAAD(s1length_bytes); + // let s1ct = cipher.update(message); + // cipher.final(); + // let s1tag = encryption_1.default.computePoly1305(s1ct,s1length_bytes,Buffer.concat([Buffer.from([0x00,0x00,0x00,0x00]),struct.pack("Q", this.decryptCount)]),this.writeKey) + let [s1ct, s1tag] = encryption_1.default.encryptAndSeal(message.slice(offset, offset + length), s1length_bytes, Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x00]), struct.pack('Q', this.encryptCount)]), this.writeKey) + + let ciphertext = Buffer.concat([s1length_bytes, s1ct, s1tag]) + offset += length + this.encryptCount += 1 + result = Buffer.concat([result, ciphertext]) + } + return result + } + + decrypt(message) { + let offset = 0 + let result = Buffer.concat([]) + while (offset < message.byteLength) { + let lengthbytes = message.slice(offset, offset + 2) + let length = struct.unpack('H', lengthbytes) + let messagea = message.slice(offset + 2, offset + 2 + length[0] + 16) + let cipherText = messagea.slice(0, -16) + let hmac = messagea.slice(-16) + let decrypted = encryption_1.default.verifyAndDecrypt(cipherText, hmac, lengthbytes, Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x00]), struct.pack('Q', this.decryptCount)]), this.readKey) + this.decryptCount += 1 + offset = offset + length[0] + 16 + 2 + result = Buffer.concat([result, decrypted]) + } + return result + } + + encryptAudio(message, aad, nonce) { + return Buffer.concat([Buffer.concat(encryption_1.default.encryptAndSeal(message, aad, struct.pack('Q', nonce), this.writeKey)), Buffer.from(struct.pack('Q', nonce))]) + } +} +exports.Credentials = Credentials diff --git a/vendor/node-airtunes2/lib/homekit/encryption.js b/vendor/node-airtunes2/lib/homekit/encryption.js new file mode 100644 index 0000000..14c38f1 --- /dev/null +++ b/vendor/node-airtunes2/lib/homekit/encryption.js @@ -0,0 +1,69 @@ +'use strict' +Object.defineProperty(exports, '__esModule', { value: true }) +const chacha = require('chacha-js') +const crypto = require('crypto') +// i'd really prefer for this to be a direct call to +// Sodium.crypto_aead_chacha20poly1305_decrypt() +// but unfortunately the way it constructs the message to +// calculate the HMAC is not compatible with homekit +// (long story short, it uses [ AAD, AAD.length, CipherText, CipherText.length ] +// whereas homekit expects [ AAD, CipherText, AAD.length, CipherText.length ] +function verifyAndDecrypt(cipherText, mac, AAD, nonce, key) { + try { + if (nonce.byteLength == 8) nonce = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x00]), nonce]) + const decipher = chacha.createDecipher(key, nonce) + if (AAD != null) decipher.setAAD(AAD)// must be called before data + decipher.setAuthTag(mac) + return decipher.update(cipherText) + } + catch (e) { + return null + } +} + +function encryptAndSeal(plainText, AAD, nonce, key) { + if (nonce.byteLength == 8) nonce = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x00]), nonce]) + var cipher = chacha.createCipher(key, nonce) + if (AAD != null) cipher.setAAD(AAD)// must be called before data + let cipherText = cipher.update(plainText) + cipher.final() + let hmac = cipher.getAuthTag() + return [cipherText, hmac] +} +// function getPadding(buffer, blockSize) { +// return buffer.length % blockSize === 0 +// ? Buffer.alloc(0) +// : Buffer.alloc(blockSize - (buffer.length % blockSize)); +// } +function HKDF(hashAlg, salt, ikm, info, size) { + // create the hash alg to see if it exists and get its length + var hash = crypto.createHash(hashAlg) + var hashLength = hash.digest().length + // now we compute the PRK + var hmac = crypto.createHmac(hashAlg, salt) + hmac.update(ikm) + var prk = hmac.digest() + var prev = Buffer.alloc(0) + var output + var buffers = [] + var num_blocks = Math.ceil(size / hashLength) + info = Buffer.from(info) + for (var i = 0; i < num_blocks; i++) { + var hmac = crypto.createHmac(hashAlg, prk) + var input = Buffer.concat([ + prev, + info, + Buffer.from(String.fromCharCode(i + 1)), + ]) + hmac.update(input) + prev = hmac.digest() + buffers.push(prev) + } + output = Buffer.concat(buffers, size) + return output.slice(0, size) +} +exports.default = { + encryptAndSeal, + verifyAndDecrypt, + HKDF, +} diff --git a/vendor/node-airtunes2/lib/homekit/number.js b/vendor/node-airtunes2/lib/homekit/number.js new file mode 100644 index 0000000..cdfd78d --- /dev/null +++ b/vendor/node-airtunes2/lib/homekit/number.js @@ -0,0 +1,44 @@ +'use strict' +Object.defineProperty(exports, '__esModule', { value: true }) +const assert = require('assert') +/* + * Originally based on code from github:KhaosT/HAP-NodeJS@0c8fd88 used + * used per the terms of the Apache Software License v2. + * + * Original code copyright Khaos Tian + * + * Modifications copyright Zach Bean + * * Reformatted for ES6-style module + * * renamed *UInt64* to *UInt53* to be more clear about range + * * renamed uintHighLow to be more clear about what it does + * * Refactored to return a buffer rather write into a passed-in buffer + */ +function splitUInt53(number) { + const MAX_UINT32 = 0x00000000FFFFFFFF + const MAX_INT53 = 0x001FFFFFFFFFFFFF + assert(number > -1 && number <= MAX_INT53, 'number out of range') + assert(Math.floor(number) === number, 'number must be an integer') + let high = 0 + const signbit = number & 0xFFFFFFFF + const low = signbit < 0 ? (number & 0x7FFFFFFF) + 0x80000000 : signbit + if (number > MAX_UINT32) { + high = (number - low) / (MAX_UINT32 + 1) + } + return [high, low] +} +function UInt53toBufferLE(number) { + const [high, low] = splitUInt53(number) + const buf = Buffer.alloc(8) + buf.writeUInt32LE(low, 0) + buf.writeUInt32LE(high, 4) + return buf +} +function UInt16toBufferBE(number) { + const buf = Buffer.alloc(2) + buf.writeUInt16BE(number, 0) + return buf +} +exports.default = { + UInt53toBufferLE, + UInt16toBufferBE, +} diff --git a/vendor/node-airtunes2/lib/homekit/tlv.js b/vendor/node-airtunes2/lib/homekit/tlv.js new file mode 100644 index 0000000..441983e --- /dev/null +++ b/vendor/node-airtunes2/lib/homekit/tlv.js @@ -0,0 +1,95 @@ +'use strict' +/** + * Type Length Value encoding/decoding, used by HAP as a wire format. + * https://en.wikipedia.org/wiki/Type-length-value + * + * Originally based on code from github:KhaosT/HAP-NodeJS@0c8fd88 used + * used per the terms of the Apache Software License v2. + * + * Original code copyright Khaos Tian + * + * Modifications copyright Zach Bean + * * Reformatted for ES6-style module + * * Rewrote encode() to be non-recursive; also simplified the logic + * * Rewrote decode() + */ +Object.defineProperty(exports, '__esModule', { value: true }) +const Tag = { + PairingMethod: 0x00, + Username: 0x01, + Salt: 0x02, + // could be either the SRP client public key (384 bytes) or the ED25519 public key (32 bytes), depending on context + PublicKey: 0x03, + Proof: 0x04, + EncryptedData: 0x05, + Sequence: 0x06, + ErrorCode: 0x07, + BackOff: 0x08, + Signature: 0x0A, + MFiCertificate: 0x09, + MFiSignature: 0x0A, + Flags: 0x13, +} +function encode(type, data) { + var encodedTLVBuffer = Buffer.alloc(0) + // coerce data to Buffer if needed + if (typeof data === 'number') + data = Buffer.from([data]) + else if (typeof data === 'string') + data = Buffer.from(data) + if (data.length <= 255) { + encodedTLVBuffer = Buffer.concat([Buffer.from([type, data.length]), data]) + } + else { + var leftLength = data.length + var tempBuffer = Buffer.alloc(0) + var currentStart = 0 + for (; leftLength > 0;) { + if (leftLength >= 255) { + tempBuffer = Buffer.concat([tempBuffer, Buffer.from([type, 0xFF]), data.slice(currentStart, currentStart + 255)]) + leftLength -= 255 + currentStart = currentStart + 255 + } + else { + tempBuffer = Buffer.concat([tempBuffer, Buffer.from([type, leftLength]), data.slice(currentStart, currentStart + leftLength)]) + leftLength -= leftLength + } + } + encodedTLVBuffer = tempBuffer + } + // do we have more to encode? + if (arguments.length > 2) { + // chop off the first two arguments which we already processed, and process the rest recursively + var remainingArguments = Array.prototype.slice.call(arguments, 2) + var remainingTLVBuffer = encode.apply(this, remainingArguments) + // append the remaining encoded arguments directly to the buffer + encodedTLVBuffer = Buffer.concat([encodedTLVBuffer, remainingTLVBuffer]) + } + return encodedTLVBuffer +} +function decode(data) { + const objects = {} + let leftLength = data.length + let currentIndex = 0 + for (; leftLength > 0;) { + const type = data[currentIndex] + const length = data[currentIndex + 1] + currentIndex += 2 + leftLength -= 2 + const newData = data.slice(currentIndex, currentIndex + length) + if (objects[type]) { + objects[type] = Buffer.concat([objects[type], newData]) + } + else { + objects[type] = newData + } + currentIndex += length + leftLength -= length + } + return objects +} +exports.default = { + Tag, + encode, + decode, +} diff --git a/vendor/node-airtunes2/lib/http.js b/vendor/node-airtunes2/lib/http.js new file mode 100644 index 0000000..761da04 --- /dev/null +++ b/vendor/node-airtunes2/lib/http.js @@ -0,0 +1,189 @@ +const net = require('net') + +// ... + +const HttpMessage = (parseStartLine, writeStartLine) => { + const instance = {} + + instance.parse = (buffer) => { + const messageObject = {} + + // ... + + let bodyIndex = buffer.indexOf('\r\n\r\n') + let headerString = buffer.slice(0, bodyIndex).toString() + let body = buffer.slice(bodyIndex + 4) + + headerString.replace(/\r\n/g, '\n') + + const lines = headerString.split('\n') + + bodyIndex += 2 + + // ... + + let line = lines.shift() + parseStartLine(line, messageObject) + + // ... + + messageObject.headers = {} + + line = lines.shift() + while (line) { + const headerName = line.substr(0, line.indexOf(':')) + const headerValue = line.substr(line.indexOf(':') + 1) + + messageObject.headers[headerName] = headerValue.trim() + + line = lines.shift() + } + + // ... + + if (messageObject.headers['Content-Length'] && messageObject.headers['Content-Length'] != 0) { + messageObject.body = body + } + + return messageObject + } + + instance.write = (messageObject) => { + let messageString = writeStartLine(messageObject) + messageString += '\r\n' + + if (messageObject.body) { + messageObject.headers['Content-Length'] = Buffer.byteLength(messageObject.body) + } + + for (const header in messageObject.headers) { + messageString += `${header}: ${messageObject.headers[header]}\r\n` + } + + messageString += '\r\n' + + const buffer = Buffer.from(messageString) + + if (!messageObject.body) { + return buffer + } + + return Buffer.concat([buffer, messageObject.body], buffer.length + messageObject.body.length) + } + + return instance +} + +const HttpRequest + = () => HttpMessage( + () => {}, // currently not parsing requests. + messageObject => `${messageObject.method} ${messageObject.path} HTTP/1.1`, + ) + +const HttpResponse + = () => HttpMessage( + (line, messageObject) => messageObject.statusCode = parseInt(line.split(' ')[1]), + () => {}, // currently not writing responses. + ) + +// ... + +class HttpClient { + constructor() { + this.resolveQueue = [] + this.pendingResponse = null + } + + // .... + + parseResponse(data) { + const res = HttpResponse().parse(data) + if (res.headers['Content-Length'] > 0) { + const remaining = res.headers['Content-Length'] - res.body.byteLength + if (remaining > 0) { + // not all data for this response's corresponding request was read. Create a pending response object + // to use for further reads. + this.pendingResponse = { + res, + remaining, + } + } + } + + if (!this.pendingResponse) { + const rr = this.resolveQueue.shift() + res.statusCode === 200 + ? rr.resolve(res) + : rr.resolve(null) + } + } + + // ... + + connect(host, port = 80) { + this.host = host + + return new Promise((resolve) => { + this.socket = net.connect( + { + host, + port, + }, + resolve, + ) + + this.socket.on('data', (data) => { + if (!this.pendingResponse) { + // there is no response pending, parse the data. + this.parseResponse(data) + } + else { + // incoming data for the pending response. + this.pendingResponse.res.body = Buffer.concat( + [this.pendingResponse.res.body, data], + data.byteLength + this.pendingResponse.res.body.byteLength, + ) + + this.pendingResponse.remaining -= data.byteLength + if (this.pendingResponse.remaining === 0) { + // all remaining data for the pending response has been read; resolve the promise for the + // corresponding request. + const rr = this.resolveQueue.shift() + this.pendingResponse.res.statusCode === 200 + ? rr.resolve(this.pendingResponse.res) + : rr.reject(new Error(`HTTP status: ${this.pendingResponse.res.statusCode}`)) + + this.pendingResponse = null + } + } + }) + }) + } + + request(method, path, headers, body) { + headers = headers || {} + // headers['Host'] = `${this.host}:${this.socket.remotePort}`; + + const data = HttpRequest().write({ + method, + path, + headers, + body, + }) + + // ... + + return new Promise((resolve, reject) => { + this.resolveQueue.push({ resolve, reject }) + this.socket.write(data) + }) + } + + close() { + this.socket.end() + } +} + +// ... + +module.exports = () => new HttpClient() diff --git a/vendor/node-airtunes2/lib/index.d.ts b/vendor/node-airtunes2/lib/index.d.ts new file mode 100644 index 0000000..201e709 --- /dev/null +++ b/vendor/node-airtunes2/lib/index.d.ts @@ -0,0 +1,141 @@ +// Path: node_modules/airtunes2/lib/index.d.ts +declare module 'airtunes2' { + export = AirTunes.Client; +} + +declare namespace AirTunes { + + /** + * AirTunes Casting Device + */ + class CastDevice { + name: string + host: string + port: number + addresses: string | string[] + txt: string[] + airplay2: boolean + } + + /** + * AirTunes Controller + */ + class Controller { + key: string + setPasscode(passcode: string): void + setVolume(volume: number, cb?: Function): void + stop(cb?: Function): void + on(event: 'status', cb: (status: string) => void): void + } + + /** + * AirTunes Device + * @param port The port of the device as reported by Zeroconf (default: 5000) + * @param volume The initial volume, which must be between 0 and 100. (default: 50) + * @param password The password for the device, if required. Bonjour indicates if the device demands a password. + */ + interface AirPlayDevice { + port: number + volume: number + password?: string + txt: string + airplay2: boolean + debug: boolean + forceAlac: boolean + } + + interface CircularBuffer { + write(data: Buffer): void + } + + /** + * AirTunes Server + */ + class Client { + constructor(); + + /** + * Add a airtunes device + * @param host The IP address of the device reported by Zeroconf (ipv4) + * @param options The options for the device (port, volume, password) + * @param mode The mode of the device (0 = local, 1 = remote, 2 = dual) + * @param txt The txt record of the device + */ + add(host: string, options: AirPlayDevice, mode?: number, txt?: string): Controller; + + /** + * Stop all devices + */ + stopAll(cb: Function): void + + /** + * Stop a device + */ + stop(deviceKey: string, cb?: Function): void + + /** + * End the circular buffer + */ + end(): void + + /** + * Set the volume of a device + * @param deviceKey The key of the device + * @param volume The volume to set (0-100) + * @param cb The callback function + */ + setVolume(deviceKey: string, volume: string, cb?: Function): void + + /** + * Set the current playback position of a device + * @param deviceKey The key of the device + * @param progress The progress to set (0-1) + * @param duration The duration of the track + * @param callback The callback function + */ + setProgress(deviceKey: string, progress: number, duration: number, callback?: Function): void + + /** + * Set the playback state of a device + * @param deviceKey The key of the device + * @param name The name of the track + * @param artist The artist of the track + * @param album The album of the track + * @param callback The callback function + */ + setTrackInfo(deviceKey: string, name: string, artist: string, album: string, callback?: Function): void + + /** + * Reset the AirTunes client + */ + reset(): void + + /** + * Set the artwork of a device + * @param deviceKey The key of the device + * @param art The artwork to set + * @param contentType The content type of the artwork + * @param callback The callback function + */ + setArtwork(deviceKey: string, art: string | Buffer, contentType: string, callback?: Function): void + + + /** + * Set the passcode of a device + * @param deviceKey The key of the device + * @param passcode The passcode to set + */ + setPasscode(deviceKey: string, passcode: string): void + + + // Untyped methods + addCoreAudio(options: Object): void + write(data: string): void + + // Untyped properties + circularBuffer: CircularBuffer + devices: unknown + writable: boolean + } + +} diff --git a/vendor/node-airtunes2/lib/index.js b/vendor/node-airtunes2/lib/index.js new file mode 100644 index 0000000..64d2840 --- /dev/null +++ b/vendor/node-airtunes2/lib/index.js @@ -0,0 +1,127 @@ +const Stream = require('stream') +const util = require('util') +const Devices = require('./devices') +const config = require('./config') +const CircularBuffer = require('./circular_buffer') +const AudioOut = require('./audio_out') +// const mdns = require("mdns-js"); +// var net = require('net'); +function AirTunes() { + var self = this + + Stream.call(this) + + var audioOut = new AudioOut() + this.devices = new Devices(audioOut) + + this.devices.init() + this.devices.on('status', function (key, status, desc) { + self.emit('device', key, status, desc) + }) + + this.circularBuffer = new CircularBuffer(config.packets_in_buffer, config.packet_size) + + this.circularBuffer.on('status', function (status) { + self.emit('buffer', status) + }) + + audioOut.init(this.devices, this.circularBuffer) + + this.circularBuffer.on('drain', function () { + self.emit('drain') + }) + + this.circularBuffer.on('error', function (err) { + self.emit('error', err) + }) + + this.writable = true + + // let x = mdns.tcp("dacp"); + // let txt_record = { + // Ver: "131075", + // DbId: "63B5E5C0C201542E", + // txtvers: "1", + // OSsi: "0x1F5" + // }; + + // this.remoteBroadcast= mdns.createAdvertisement(x, 5648, { + // name: "iTunes_Ctrl_04F8191D99BEC6E9", + // txt: txt_record, + // }); + // this.remoteBroadcast.start(); + + // this.remoteserver = net.createServer(function(sock) { + // // We have a connection - a socket object is assigned to the connection automatically + // // Add a 'data' event handler to this instance of socket + // sock.on('data', function(data) { + // console.log('DATA FROM' + sock.remoteAddress + ': ' + data); + // // Write the data back to the socket, the client will receive it as data from the server + // sock.write("HTTP/1.1 204 No Content" + "\r\n" + + // "DAAP-Server: iTunes/10.6 (Mac OS X)" + "\r\n" + + // "Content-Type: application/x-dmap-tagged" + "\r\n" + + // "Content-Length: 0\r\n\r\n" + // ); + // }); + + // // Add a 'close' event handler to this instance of socket + // sock.on('close', function(data) { + // console.log('CLOSED: ' + sock.remoteAddress +' '+ sock.remotePort); + // }); + + // }).listen(5648); +} + +util.inherits(AirTunes, Stream) + +AirTunes.prototype.add = function (host, options, mode = 0, txt = '') { + return this.devices.add('airtunes', host, options, mode, txt) +} + +AirTunes.prototype.addCoreAudio = function (options) { + return this.devices.add('coreaudio', null, options) +} + +AirTunes.prototype.stopAll = function (cb) { + this.devices.stopAll(cb) +} + +AirTunes.prototype.stop = function (deviceKey) { + this.devices.stop(deviceKey) +} + +AirTunes.prototype.setVolume = function (deviceKey, volume, callback) { + this.devices.setVolume(deviceKey, volume, callback) +} + +AirTunes.prototype.setProgress = function (deviceKey, progress, duration, callback) { + this.devices.setProgress(deviceKey, progress, duration, callback) +} + +AirTunes.prototype.setTrackInfo = function (deviceKey, name, artist, album, callback) { + this.devices.setTrackInfo(deviceKey, name, artist, album, callback) +} + +AirTunes.prototype.reset = function () { + this.circularBuffer.reset() +} + +AirTunes.prototype.setArtwork = function (deviceKey, art, contentType, callback) { + this.devices.setArtwork(deviceKey, art, contentType, callback) +} + +AirTunes.prototype.write = function (data) { + return this.circularBuffer.write(data) +} + +AirTunes.prototype.setPasscode = function (deviceKey, passcode) { + this.devices.setPasscode(deviceKey, passcode) +} + +AirTunes.prototype.end = function () { + this.circularBuffer.end() + // this.remoteBroadcast.stop(); + // this.remoteserver.close(); +} + +module.exports = AirTunes diff --git a/vendor/node-airtunes2/lib/ntp.js b/vendor/node-airtunes2/lib/ntp.js new file mode 100644 index 0000000..b6006a7 --- /dev/null +++ b/vendor/node-airtunes2/lib/ntp.js @@ -0,0 +1,37 @@ +const config = require('./config') + +function NTP() { + this.timeRef = new Date().getTime() - config.ntp_epoch * 1000 +} + +NTP.prototype.timestamp = function () { + const time = new Date().getTime() - this.timeRef + const sec = Math.floor(time / 1000) + + const msec = time - sec * 1000 + const ntp_msec = Math.floor(msec * 4294967.296) + + const ts = new Buffer(8) + + ts.writeUInt32BE(sec, 0) + ts.writeUInt32BE(ntp_msec, 4) + + return ts +} + +NTP.prototype.getTime = function (time) { + var time = new Date().getTime() - this.timeRef + var sec = Math.floor(time / 1000) + + var msec = time - sec * 1000 + var ntp_msec = Math.floor(msec * 4294967.296) + + var ts = new Buffer(8) + + ts.writeUInt32BE(sec, 0) + ts.writeUInt32BE(ntp_msec, 4) + console.log(time, sec) + return ntp_msec +} + +module.exports = new NTP() diff --git a/vendor/node-airtunes2/lib/num_util.js b/vendor/node-airtunes2/lib/num_util.js new file mode 100644 index 0000000..24e7e3f --- /dev/null +++ b/vendor/node-airtunes2/lib/num_util.js @@ -0,0 +1,21 @@ +const crypto = require('crypto') + +exports.randomHex = function (n) { + return crypto.randomBytes(n).toString('hex') +} + +exports.randomBase64 = function (n) { + return crypto.randomBytes(n).toString('base64').replace('=', '') +} + +exports.randomInt = function (n) { + return Math.floor(Math.random() * Math.pow(10, n)) +} + +exports.low16 = function (i) { + return Math.abs(i) % 65536 +} + +exports.low32 = function (i) { + return Math.abs(i) % 4294967296 +} diff --git a/vendor/node-airtunes2/lib/packet_pool.js b/vendor/node-airtunes2/lib/packet_pool.js new file mode 100644 index 0000000..d087368 --- /dev/null +++ b/vendor/node-airtunes2/lib/packet_pool.js @@ -0,0 +1,43 @@ +const config = require('./config') + +function PacketPool() { + this.pool = [] +} + +PacketPool.prototype.getPacket = function () { + let packet = this.pool.shift() + + if (!packet) { + packet = new Packet(this) + } + else + packet.retain() + + return packet +} + +PacketPool.prototype.release = function (packet) { + this.pool.push(packet) +} + +function Packet(pool) { + this.pool = pool + this.ref = 1 + this.seq = null + this.pcm = new Buffer(config.packet_size) +} + +Packet.prototype.retain = function () { + this.ref++ +} + +Packet.prototype.release = function () { + this.ref-- + + if (this.ref === 0) { + this.seq = null + this.pool.release(this) + } +} + +module.exports = PacketPool diff --git a/vendor/node-airtunes2/lib/rtsp.js b/vendor/node-airtunes2/lib/rtsp.js new file mode 100644 index 0000000..3b2a3d5 --- /dev/null +++ b/vendor/node-airtunes2/lib/rtsp.js @@ -0,0 +1,1807 @@ +const net = require('net') +const crypto = require('crypto') +const events = require('events') +const util = require('util') +const fs = require('fs') +const dgram = require('dgram') +const bplistCreator = require('bplist-creator') +const bplistParser = require('bplist-parser') +const { SRP, SrpClient } = require('fast-srp-hap') +const ed25519_js = require('@noble/ed25519') +const curve25519_js = require('curve25519-js') +const ntp = require('./ntp') +const config = require('./config') +const nu = require('./num_util') +const LegacySRP = require('./srp') +const ATVAuthenticator = require('./atvAuthenticator') +const tlv = require('./homekit/tlv').default +const enc = require('./homekit/encryption').default +const Credentials = require('./homekit/credentials').Credentials + +const INFO = -1 +const OPTIONS = 0 +const ANNOUNCE = 1 +const SETUP = 2 +const RECORD = 3 +const SETVOLUME = 4 +const PLAYING = 5 +const TEARDOWN = 6 +const CLOSED = 7 +const SETDAAP = 8 +const SETART = 9 +const PAIR_VERIFY_1 = 10 +const PAIR_VERIFY_2 = 11 +const OPTIONS2 = 12 +const AUTH_SETUP = 13 +const PAIR_PIN_START = 14 +const PAIR_PIN_SETUP_1 = 15 +const PAIR_PIN_SETUP_2 = 16 +const PAIR_PIN_SETUP_3 = 17 +const PAIR_SETUP_1 = 18 +const PAIR_SETUP_2 = 19 +const PAIR_SETUP_3 = 20 +const PAIR_VERIFY_HAP_1 = 21 +const PAIR_VERIFY_HAP_2 = 22 +const SETUP_AP2_1 = 23 +const SETUP_AP2_2 = 24 +const SETPEERS = 25 +const FLUSH = 26 +const GETVOLUME = 27 +const SETPROGRESS = 28 +const OPTIONS3 = 29 + +const rtsp_methods = [ + 'INFO', + 'OPTIONS', + 'ANNOUNCE', + 'SETUP', + 'RECORD', + 'SETVOLUME', + 'PLAYING', + 'TEARDOWN', + 'CLOSED', + 'SETDAAP', + 'SETART', + 'PAIR_VERIFY_1', + 'PAIR_VERIFY_2', + 'OPTIONS2', + 'AUTH_SETUP', + 'PAIR_PIN_START', + 'PAIR_PIN_SETUP_1', + 'PAIR_PIN_SETUP_2', + 'PAIR_PIN_SETUP_3', + 'PAIR_SETUP_1', + 'PAIR_SETUP_2', + 'PAIR_SETUP_3', + 'PAIR_VERIFY_HAP_1', + 'PAIR_VERIFY_HAP_2', + 'SETUP_AP2_1', + 'SETUP_AP2_2', + 'SETPEERS', + 'FLUSH', + 'GETVOLUME', + 'SETPROGRESS', + 'OPTIONS3', +] + +function Client(volume, password, audioOut, options) { + events.EventEmitter.call(this) + + this.audioOut = audioOut + this.status = PAIR_VERIFY_1 + this.socket = null + this.cseq = 0 + this.announceId = null + this.activeRemote = nu.randomInt(9).toString().toUpperCase() + this.dacpId = '04F8191D99BEC6E9' + this.session = null + this.timeout = null + this.volume = volume + this.progress = 0 + this.duration = 0 + this.starttime = 0 + this.password = password + this.passwordTried = false + this.requireEncryption = false + this.trackInfo = null + this.artwork = null + this.artworkContentType = null + this.callback = null + this.controlPort = null + this.timingPort = null + this.sentFakeProgess = false + this.timingDestPort = null + this.eventPort = null + this.heartBeat = null + this.pair_verify_1_verifier = null + this.pair_verify_1_signature = null + this.code_digest = null + this.authSecret = null + this.mode = options?.mode ?? 0 + this.dnstxt = options?.txt ?? [] + this.alacEncoding = options?.alacEncoding ?? true + this.needPassword = options?.needPassword ?? false + this.airplay2 = options?.airplay2 ?? false + this.needPin = options?.needPin ?? false + this.debug = options?.debug ?? false + this.transient = options?.transient ?? false + this.borkedshp = options?.borkedshp ?? false + this.privateKey = null + this.srp = new SRP(2048) + this.I = '366B4165DD64AD3A' + this.P = null + this.s = null + this.B = null + this.a = null + this.A = null + this.M1 = null + this.epk = null + this.authTag = null + this._atv_salt = null + this._atv_pub_key = null + this._hap_genkey = null + this._hap_encrypteddata = null + this.pairingId = null + this.seed = null + this.credentials = null + this.event_credentials = null + this.verifier_hap_1 = null + this.encryptionKey = null + this.encryptedChannel = false + this.hostip = null + this.homekitver = this.transient ? '4' : '3' + this.metadataReady = false +} + +util.inherits(Client, events.EventEmitter) + +exports.Client = Client + +Client.prototype.startHandshake = function (udpServers, host, port) { + var self = this + this.startTimeout() + this.hostip = host + this.controlPort = udpServers.control.port + this.timingPort = udpServers.timing.port + + this.socket = net.connect(port, host, async function () { + self.clearTimeout() + + if (self.needPassword || self.needPin) { + self.status = PAIR_PIN_START + self.sendNextRequest() + self.startHeartBeat() + } + else { + if (self.mode != 2) { + if (this.debug) console.log('AUTH_SETUP', 'nah') + self.status = OPTIONS + self.sendNextRequest() + self.startHeartBeat() + } + else { + self.status = AUTH_SETUP + if (this.debug) console.log('AUTH_SETUP', 'yah') + self.sendNextRequest() + self.startHeartBeat() + } + } + }) + + var blob = '' + this.socket.on('data', function (data) { + if (self.encryptedChannel && self.credentials) { + // if (self.debug != false) console.log("incoming", data) + data = self.credentials.decrypt(data) + } + self.clearTimeout() + + /* + * I wish I could use node's HTTP parser for this... + * I assume that all responses have empty bodies. + */ + var rawData = data + data = data.toString() + + blob += data + var endIndex = blob.indexOf('\r\n\r\n') + + if (endIndex < 0) { + return + } + + endIndex += 4 + + blob = blob.substring(0, endIndex) + self.processData(blob, rawData) + + blob = data.substring(endIndex) + }) + + this.socket.on('error', function (err) { + self.socket = null + if (this.debug) console.log(err.code) + if (err.code === 'ECONNREFUSED') { + if (this.debug) console.log('block') + self.cleanup('connection_refused') + } + else + self.cleanup('rtsp_socket', err.code) + }) + + this.socket.on('end', function () { + if (self.debug) console.log('block2') + self.cleanup('disconnected') + }) +} + +Client.prototype.startTimeout = function () { + var self = this + + this.timeout = setTimeout(function () { + if (self.debug) console.log('timeout') + self.cleanup('timeout') + }, config.rtsp_timeout) +} + +Client.prototype.clearTimeout = function () { + if (this.timeout !== null) { + clearTimeout(this.timeout) + this.timeout = null + } +} + +Client.prototype.teardown = function () { + if (this.status === CLOSED) { + this.emit('end', 'stopped') + return + } + + this.status = TEARDOWN + this.sendNextRequest() +} + +Client.prototype.setVolume = function (volume, callback) { + if (this.status !== PLAYING) + return + + this.volume = volume + this.callback = callback + this.status = SETVOLUME + this.sendNextRequest() +} + +Client.prototype.setProgress = function (progress, duration, callback) { + if (this.status !== PLAYING) + return + this.progress = progress + this.duration = duration + this.callback = callback + this.status = SETPROGRESS + this.sendNextRequest() +} + +Client.prototype.setPasscode = async function (passcode) { + this.password = passcode + this.status = this.airplay2 ? PAIR_SETUP_1 : PAIR_PIN_SETUP_1 + this.sendNextRequest() +} + +Client.prototype.startHeartBeat = function () { + var self = this + + if (config.rtsp_heartbeat > 0) { + this.heartBeat = setInterval(function () { + self.sendHeartBeat(function () { + // console.log('HeartBeat sent!'); + }) + }, config.rtsp_heartbeat) + } +} + +Client.prototype.sendHeartBeat = function (callback) { + if (this.status !== PLAYING) + return + + this.status = OPTIONS + this.callback = callback + this.sendNextRequest() +} + +Client.prototype.setTrackInfo = function (name, artist, album, callback) { + if (this.status !== PLAYING) + return + if (name != this.trackInfo?.name || artist != this.trackInfo?.artist || album != this.trackInfo?.album) { + this.starttime = this.audioOut.lastSeq * config.frames_per_packet + 2 * config.sampling_rate + } + this.trackInfo = { + name: name, + artist: artist, + album: album, + } + this.status = SETDAAP + this.callback = callback + this.sendNextRequest() +} + +Client.prototype.setArtwork = function (art, contentType, callback) { + if (this.status !== PLAYING) + return + + if (typeof contentType == 'function') { + callback = contentType + contentType = null + } + + if (typeof art == 'string') { + var self = this + if (contentType === null) { + var ext = art.slice(-4) + if (ext == '.jpg' || ext == 'jpeg') { + contentType = 'image/jpeg' + } + else if (ext == '.png') { + contentType = 'image/png' + } + else if (ext == '.gif') { + contentType = 'image/gif' + } + else { + return self.cleanup('unknown_art_file_ext') + } + } + return fs.readFile(art, function (err, data) { + if (err !== null) { + return self.cleanup('invalid_art_file') + } + self.setArtwork(data, contentType, callback) + }) + } + + if (contentType === null) + return this.cleanup('no_art_content_type') + + this.artworkContentType = contentType + this.artwork = art + this.status = SETART + this.callback = callback + this.sendNextRequest() +} + +Client.prototype.nextCSeq = function () { + this.cseq += 1 + + return this.cseq +} + +Client.prototype.cleanup = function (type, msg) { + this.emit('end', type, msg) + this.status = CLOSED + this.trackInfo = null + this.artwork = null + this.artworkContentType = null + this.callback = null + this.srp = null + this.P = null + this.s = null + this.B = null + this.a = null + this.A = null + this.M1 = null + this.epk = null + this.authTag = null + this._hap_genkey = null + this._hap_encrypteddata = null + this.seed = null + this.credentials = null + // this.password = null; + this.removeAllListeners() + + if (this.timeout) { + clearTimeout(this.timeout) + this.timeout = null + } + + if (this.heartBeat) { + clearInterval(this.heartBeat) + this.heartBeat = null + } + + if (this.socket) { + this.socket.destroy() + this.socket = null + } +} + +function parseResponse(blob) { + var response = {}, lines = blob.split('\r\n') + + if (lines[0].match(/^Audio-Latency/)) { + let tmp = lines[0] + lines[0] = lines[1] + lines[1] = tmp + } + + var codeRes = /(\w+)\/(\S+) (\d+) (.*)/.exec(lines[0]) + if (!codeRes) { + response.code = 599 + response.status = 'UNEXPECTED ' + lines[0] + + return response + } + + response.code = parseInt(codeRes[3], 10) + response.status = codeRes[4] + + var headers = {} + lines.slice(1).forEach(function (line) { + var res = /([^:]+):\s*(.*)/.exec(line) + + if (!res) + return + + headers[res[1]] = res[2] + }) + + response.headers = headers + // console.log(response); + return response +} + +function parseResponse2(blob, self) { + var response = {}, lines = blob.split('\r\n') + // if (self.debug) console.log(lines); + + if (lines[0].match(/^Audio-Latency/)) { + let tmp = lines[0] + lines[0] = lines[1] + lines[1] = tmp + } + + var codeRes = /(\w+)\/(\S+) (\d+) (.*)/.exec(lines[0]) + if (!codeRes) { + response.code = 599 + response.status = 'UNEXPECTED ' + lines[0] + + return response + } + + response.code = parseInt(codeRes[3], 10) + response.status = codeRes[4] + + var headers = {} + lines.slice(1).forEach(function (line) { + var res = /([^:]+):\s*(.*)/.exec(line) + + if (!res) + return + + headers[res[1]] = res[2] + }) + + response.headers = headers + + // if (this.debug) console.log('res: ', response); + return response +} + +function md5(str) { + var md5sum = crypto.createHash('md5') + md5sum.update(str) + + return md5sum.digest('hex').toUpperCase() +} + +function md5norm(str) { + var md5sum = crypto.createHash('md5') + md5sum.update(str) + + return md5sum.digest('hex') +} + +Client.prototype.makeHead = function (method, uri, di, clear = false, dimode = null) { + var head = method + ' ' + uri + ' RTSP/1.0' + '\r\n' + if (!clear) { + head += 'CSeq: ' + this.nextCSeq() + '\r\n' + + 'User-Agent: ' + (this.airplay2 ? 'AirPlay/409.16' : config.user_agent) + '\r\n' + + 'DACP-ID: ' + this.dacpId + '\r\n' + + (this.session ? 'Session: ' + this.session + '\r\n' : '') + + 'Active-Remote: ' + this.activeRemote + '\r\n' + head += 'Client-Instance: ' + this.dacpId + '\r\n' + }; + + if (di) { + let diResponse + if (dimode == 'airplay2') { + const ha1 = md5norm(di.username + ':' + di.realm + ':' + di.password) + const ha2 = md5norm(method + ':' + uri) + diResponse = md5(ha1 + ':' + di.nonce + ':' + ha2) + } + else { + const ha1 = md5(di.username + ':' + di.realm + ':' + di.password) + const ha2 = md5(method + ':' + uri) + diResponse = md5(ha1 + ':' + di.nonce + ':' + ha2) + } + + head += 'Authorization: Digest ' + + 'username="' + di.username + '", ' + + 'realm="' + di.realm + '", ' + + 'nonce="' + di.nonce + '", ' + + 'uri="' + uri + '", ' + + 'response="' + diResponse + '"\r\n' + } + + return head +} + +Client.prototype.makeHeadWithURL = function (method, digestInfo, dimode) { + return this.makeHead(method, 'rtsp://' + this.socket.address().address + '/' + this.announceId, digestInfo, false, dimode) +} + +Client.prototype.makeRtpInfo = function () { + var nextSeq = this.audioOut.lastSeq + 1 + var rtpSyncTime = nextSeq * config.frames_per_packet + 2 * config.sampling_rate + return 'RTP-Info: seq=' + nextSeq + ';rtptime=' + rtpSyncTime + '\r\n' +} + +Client.prototype.sendNextRequest = async function (di) { + var request = '', body = '' + if (this.debug) console.log('Sending request:', rtsp_methods[this.status + 1]) + switch (this.status) { + case PAIR_PIN_START: + this.I = '366B4165DD64AD3A' + this.P = null + this.s = null + this.B = null + this.a = null + this.A = null + this.M1 = null + this.epk = null + this.authTag = null + this._atv_salt = null + this._atv_pub_key = null + this._hap_encrypteddata = null + this.seed = null + this.pairingId = crypto.randomUUID() + this.credentials = null + this.verifier_hap_1 = null + this.encryptionKey = null + + request = '' + if (this.transient && (this.needPin != true) && (this.needPassword != true)) { + (this.status = PAIR_SETUP_1) + this.sendNextRequest() + } + else + if (this.needPin) { + request += this.makeHead('POST', '/pair-pin-start', '', true) + if (this.airplay2) { + request += 'User-Agent: AirPlay/409.16\r\n' + request += 'Connection: keep-alive\r\n' + request += 'CSeq: ' + 0 + '\r\n' + } + + request += 'Content-Length:' + 0 + '\r\n\r\n' + this.socket.write(Buffer.from(request, 'utf-8')) + } + else { + console.log('pass', this.password) + if (this.password) { + this.status = this.airplay2 ? PAIR_SETUP_1 : PAIR_PIN_SETUP_1 + console.log('pass2', this.password) + this.sendNextRequest() + } + else { + if (!this.needPassword) { + this.status = this.airplay2 ? INFO : PAIR_PIN_SETUP_1 + this.sendNextRequest() + } + else { + this.emit('need_password') + } + } + } + request = '' + // } + break + case PAIR_PIN_SETUP_1: + request = '' + request += this.makeHead('POST', '/pair-setup-pin', '', true) + request += 'Content-Type: application/x-apple-binary-plist\r\n' + let u = bplistCreator({ + user: '366B4165DD64AD3A', + method: 'pin', + }) + request += 'Content-Length:' + Buffer.byteLength(u) + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), u])) + request = '' + break + case PAIR_PIN_SETUP_2: + request = '' + request += this.makeHead('POST', '/pair-setup-pin', '', true) + request += 'Content-Type: application/x-apple-binary-plist\r\n' + let u1 = bplistCreator({ + pk: Buffer.from(this.A, 'hex'), + proof: Buffer.from(this.M1, 'hex'), + }) + request += 'Content-Length:' + Buffer.byteLength(u1) + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), u1])) + request = '' + break + case PAIR_PIN_SETUP_3: + request = '' + request += this.makeHead('POST', '/pair-setup-pin', '', true) + request += 'Content-Type: application/x-apple-binary-plist\r\n' + let u2 = bplistCreator({ + epk: Buffer.from(this.epk, 'hex'), + authTag: Buffer.from(this.authTag, 'hex'), + }) + request += 'Content-Length:' + Buffer.byteLength(u2) + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), u2])) + request = '' + break + case PAIR_VERIFY_1: + request = '' + request += this.makeHead('POST', '/pair-verify', '', true) + request += 'Content-Type: application/octet-stream\r\n' + this.pair_verify_1_verifier = ATVAuthenticator.verifier(this.authSecret) + if (this.debug) console.log(this.authSecret) + request += 'Content-Length:' + Buffer.byteLength(this.pair_verify_1_verifier.verifierBody) + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), this.pair_verify_1_verifier.verifierBody])) + request = '' + break + case PAIR_VERIFY_2: + request = '' + request += this.makeHead('POST', '/pair-verify', '', true) + request += 'Content-Type: application/octet-stream\r\n' + request += 'Content-Length:' + Buffer.byteLength(this.pair_verify_1_signature) + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), this.pair_verify_1_signature])) + request = '' + // const verifier = ATVAuthenticator.verifier('3c0591f41d1236c9ce5078sscd6fcd42f71f374b8b6dff33fea825366f1c34f828'); + // request += 'Content-Length:' + Buffer.byteLength(verifier.verifierBody) + '\r\n\r\n'; + // this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'),verifier.verifierBody])) + // request = '' + break + case PAIR_SETUP_1: + if (this.debug) console.log('loh') + request = '' + request += this.makeHead('POST', '/pair-setup', '', true) + request += 'User-Agent: AirPlay/409.16\r\n' + request += 'CSeq: ' + this.nextCSeq() + '\r\n' + request += 'Connection: keep-alive\r\n' + request += 'X-Apple-HKP: ' + this.homekitver + '\r\n' + + console.log('rtsp.transient', this.transient) + if (this.transient == true) { + console.log('rtsp.transient', 'uas') + let ps1 = tlv.encode( + tlv.Tag.Sequence, 0x01, + tlv.Tag.PairingMethod, 0x00, + tlv.Tag.Flags, 0x00000010, + ) + + request += 'Content-Length: ' + Buffer.byteLength(ps1) + '\r\n' + request += 'Content-Type: application/octet-stream' + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), ps1])) + } + else { + let ps1 = tlv.encode( + tlv.Tag.PairingMethod, 0x00, + tlv.Tag.Sequence, 0x01, + ) + request += 'Content-Length: ' + 6 + '\r\n' + request += 'Content-Type: application/octet-stream' + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), ps1])) + } + + request = '' + break + case PAIR_SETUP_2: + if (this.debug) console.log('loh2') + request = '' + request += this.makeHead('POST', '/pair-setup', '', true) + request += 'User-Agent: AirPlay/409.16\r\n' + request += 'CSeq: ' + this.nextCSeq() + '\r\n' + request += 'Connection: keep-alive\r\n' + request += 'X-Apple-HKP: ' + this.homekitver + '\r\n' + request += 'Content-Type: application/octet-stream\r\n' + let ps2 = tlv.encode( + tlv.Tag.Sequence, 0x03, + tlv.Tag.PublicKey, this.A, + tlv.Tag.Proof, this.M1, + ) + request += 'Content-Length: ' + Buffer.byteLength(ps2) + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), ps2])) + request = '' + break + case PAIR_SETUP_3: + if (this.debug) console.log('loh3') + request = '' + request += this.makeHead('POST', '/pair-setup', '', true) + request += 'User-Agent: AirPlay/409.16\r\n' + request += 'CSeq: ' + this.nextCSeq() + '\r\n' + request += 'Connection: keep-alive\r\n' + request += 'X-Apple-HKP: ' + this.homekitver + '\r\n' + request += 'Content-Type: application/octet-stream\r\n' + this.K = this.srp.computeK() + this.seed = crypto.randomBytes(32) + // let keyPair = ed25519.MakeKeypair(this.seed); + this.privateKey = ed25519_js.utils.randomPrivateKey() + let publicKey = await ed25519_js.getPublicKey(this.privateKey) + // let keyPair = nacl.sign.keyPair.fromSeed(this.seed) + // let privateKey = keyPair.secretKey; + // let publicKey = keyPair.publicKey; + let deviceHash = enc.HKDF( + 'sha512', + Buffer.from('Pair-Setup-Controller-Sign-Salt'), + this.K, + Buffer.from('Pair-Setup-Controller-Sign-Info'), + 32, + ) + let deviceInfo = Buffer.concat([deviceHash, Buffer.from(this.pairingId), publicKey]) + let deviceSignature = await ed25519_js.sign(deviceInfo, this.privateKey) + // let deviceSignature = nacl.sign(deviceInfo, privateKey) + this.encryptionKey = enc.HKDF( + 'sha512', + Buffer.from('Pair-Setup-Encrypt-Salt'), + this.K, + Buffer.from('Pair-Setup-Encrypt-Info'), + 32, + ) + let tlvData = tlv.encode( + tlv.Tag.Username, Buffer.from(this.pairingId), + tlv.Tag.PublicKey, publicKey, + tlv.Tag.Signature, deviceSignature, + ) + let encryptedTLV = Buffer.concat(enc.encryptAndSeal(tlvData, null, Buffer.from('PS-Msg05'), this.encryptionKey)) + // console.log("DEBUG: Encrypted Data=" + encryptedTLV.toString('hex')); + let outerTLV = tlv.encode( + tlv.Tag.Sequence, 0x05, + tlv.Tag.EncryptedData, encryptedTLV, + ) + request += 'Content-Length: ' + Buffer.byteLength(outerTLV) + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), outerTLV])) + request = '' + break + case PAIR_VERIFY_HAP_1: + request = '' + request += this.makeHead('POST', '/pair-verify', '', true) + request += 'User-Agent: AirPlay/409.16\r\n' + request += 'CSeq: ' + this.nextCSeq() + '\r\n' + request += 'Connection: keep-alive\r\n' + request += 'X-Apple-HKP: ' + this.homekitver + '\r\n' + request += 'Content-Type: application/octet-stream\r\n' + + let hap1kp = curve25519_js.generateKeyPair(Buffer.alloc(32)) + this.verifyPrivate = Buffer.from(hap1kp.private) + this.verifyPublic = Buffer.from(hap1kp.public) + // this.verifyPrivate = Buffer.alloc(32); + // curve25519.makeSecretKey(this.verifyPrivate); + // this.verifyPublic = curve25519.derivePublicKey(this.verifyPrivate); + let encodedData = tlv.encode( + tlv.Tag.Sequence, 0x01, + tlv.Tag.PublicKey, this.verifyPublic, + ) + request += 'Content-Length: ' + Buffer.byteLength(encodedData) + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), encodedData])) + request = '' + break + case PAIR_VERIFY_HAP_2: + request = '' + request += this.makeHead('POST', '/pair-verify', '', true) + request += 'User-Agent: AirPlay/409.16\r\n' + request += 'CSeq: ' + this.nextCSeq() + '\r\n' + request += 'Connection: keep-alive\r\n' + request += 'X-Apple-HKP: ' + this.homekitver + '\r\n' + request += 'Content-Type: application/octet-stream\r\n' + let identifier = tlv.decode(this.verifier_hap_1.pairingData)[tlv.Tag.Username] + let signature = tlv.decode(this.verifier_hap_1.pairingData)[tlv.Tag.Signature] + let material = Buffer.concat([this.verifyPublic, Buffer.from(this.credentials.pairingId), this.verifier_hap_1.sessionPublicKey]) + // let keyPair1 = ed25519.MakeKeypair(this.credentials.encryptionKey); + // let signed = ed25519.Sign(material, keyPair1); + // let keyPair1 = ed25519.MakeKeypair(this.credentials.encryptionKey); + let signed = await ed25519_js.sign(material, this.privateKey) + console.log('lengths', this.credentials.encryptionKey.length) + // let keyPair1 = nacl.sign.keyPair.fromSeed(this.credentials.encryptionKey) + // let signed = nacl.sign(material, keyPair1.secretKey); + let plainTLV = tlv.encode( + tlv.Tag.Username, Buffer.from(this.credentials.pairingId), + tlv.Tag.Signature, signed, + ) + let encryptedTLV1 = Buffer.concat(enc.encryptAndSeal(plainTLV, null, Buffer.from('PV-Msg03'), this.verifier_hap_1.encryptionKey)) + let pv2 = tlv.encode( + tlv.Tag.Sequence, 0x03, + tlv.Tag.EncryptedData, encryptedTLV1, + ) + request += 'Content-Length: ' + Buffer.byteLength(pv2) + '\r\n\r\n' + this.socket.write(Buffer.concat([Buffer.from(request, 'utf-8'), pv2])) + request = '' + break + case AUTH_SETUP: + request = '' + request += this.makeHead('POST', '/auth-setup', di) + request += 'Content-Length: ' + 33 + '\r\n\r\n' + let finalbuffer = Buffer.concat([Buffer.from(request, 'utf-8'), + Buffer.from([0x01, // unencrypted + 0x4e, 0xea, 0xd0, 0x4e, 0xa9, 0x2e, 0x47, 0x69, + 0xd2, 0xe1, 0xfb, 0xd0, 0x96, 0x81, 0xd5, 0x94, + 0xa8, 0xef, 0x18, 0x45, 0x4a, 0x24, 0xae, 0xaf, + 0xb3, 0x14, 0x97, 0x0d, 0xa0, 0xb5, 0xa3, 0x49]), + ]) + if (this.airplay2 != true && this.credentials != null) { + try { + this.socket.write(this.credentials.encrypt(finalbuffer)) + } + catch (e) { + + } + } + else { + this.socket.write(finalbuffer) + } + + request = '' + // this.status = OPTIONS; + // this.sendNextRequest() + break + case OPTIONS: + request += this.makeHead('OPTIONS', '*', di) + if (this.airplay2) { + request += 'User-Agent: AirPlay/409.16\r\n' + request += 'Connection: keep-alive\r\n' + } + request += 'Apple-Challenge: SdX9kFJVxgKVMFof/Znj4Q\r\n\r\n' + break + case OPTIONS2: + + request = '' + request += this.makeHead('OPTIONS', '*') + request += this.code_digest + console.log(request) + this.socket.write(Buffer.from(request, 'utf-8')) + request = '' + break + case OPTIONS3: + request = '' + request += this.makeHead('OPTIONS', '*', di) + console.log(request) + this.socket.write(Buffer.from(request, 'utf-8')) + request = '' + break + case ANNOUNCE: + if (this.announceId == null) { + this.announceId = nu.randomInt(10) + } + + body + = 'v=0\r\n' + + 'o=iTunes ' + this.announceId + ' 0 IN IP4 ' + this.socket.address().address + '\r\n' + + 's=iTunes\r\n' + + 'c=IN IP4 ' + this.socket.address().address + '\r\n' + + 't=0 0\r\n' + + 'm=audio 0 RTP/AVP 96\r\n' + if (!this.alacEncoding) { + body = body + 'a=rtpmap:96 L16/44100/2\r\n' + + 'a=fmtp:96 352 0 16 40 10 14 2 255 0 0 44100\r\n' + } + else { + body = body + 'a=rtpmap:96 AppleLossless\r\n' + + 'a=fmtp:96 352 0 16 40 10 14 2 255 0 0 44100\r\n' + } + ; + if (this.requireEncryption) { + body + += 'a=rsaaeskey:' + config.rsa_aeskey_base64 + '\r\n' + + 'a=aesiv:' + config.iv_base64 + '\r\n' + } + + request += this.makeHeadWithURL('ANNOUNCE', di) + request + += 'Content-Type: application/sdp\r\n' + + 'Content-Length: ' + body.length + '\r\n\r\n' + + request += body + // console.log(request); + break + case SETUP: + request += this.makeHeadWithURL('SETUP', di) + request + += 'Transport: RTP/AVP/UDP;unicast;interleaved=0-1;mode=record;' + + 'control_port=' + this.controlPort + ';' + + 'timing_port=' + this.timingPort + '\r\n\r\n' + // console.log(request); + break + case INFO: + request += this.makeHead('GET', '/info', di, true) + request += 'User-Agent: AirPlay/409.16\r\n' + request += 'Connection: keep-alive\r\n' + request += 'CSeq: ' + this.nextCSeq() + '\r\n\r\n' + if (this.credentials) { + let enct1x = this.credentials.encrypt(Buffer.concat([Buffer.from(request, 'utf-8')])) + this.socket.write(enct1x) + request = '' + } + // console.log(request); + break + case SETUP_AP2_1: + if (this.announceId == null) { + this.announceId = nu.randomInt(10) + } + request += this.makeHeadWithURL('SETUP', di, 'airplay2') + request += 'Content-Type: application/x-apple-binary-plist\r\n' + // request += 'CSeq: ' + this.nextCSeq() + '\r\n' ; + // this.timingPort = 32325; + console.log('starting ports', this.timingPort, this.controlPort) + let setap1 = bplistCreator( + { deviceID: '2C:61:F3:B6:64:C1', + sessionUUID: '8EB266BA-B741-40C5-8213-4B7A38DF8773', + timingPort: this.timingPort, + timingProtocol: 'NTP', + // ekey: config.rsa_aeskey_base64, + // eiv: config.iv_base64 + }, + ) + + try { + this.timingsocket.close() + } + catch (e) {} + + try { + this.timingsocket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + var self = this + this.timingsocket.on('message', function (msg, rinfo) { + // only listen and respond on own hosts + // if (this.hosts.indexOf(rinfo.address) < 0) return; + + var ts1 = msg.readUInt32BE(24) + var ts2 = msg.readUInt32BE(28) + + var reply = new Buffer(32) + reply.writeUInt16BE(0x80d3, 0) + reply.writeUInt16BE(0x0007, 2) + reply.writeUInt32BE(0x00000000, 4) + + reply.writeUInt32BE(ts1, 8) + reply.writeUInt32BE(ts2, 12) + + var ntpTime = ntp.timestamp() + + ntpTime.copy(reply, 16) + ntpTime.copy(reply, 24) + + self.timingsocket.send( + reply, 0, reply.length, + rinfo.port, rinfo.address, + ) + console.log('timing socket pinged', rinfo.port, rinfo.address) + }) + this.timingsocket.bind(this.timingPort, this.socket.address().address) + } + catch (e) {} + request += 'Content-Length: ' + Buffer.byteLength(setap1) + '\r\n\r\n' + console.log(request) + let s1ct = this.credentials.encrypt(Buffer.concat([Buffer.from(request, 'utf-8'), setap1])) + this.socket.write(s1ct) + request = '' + break + case SETPEERS: + request += this.makeHeadWithURL('SETPEERS', di) + request += 'Content-Type: /peer-list-changed\r\n' + let speers = bplistCreator([ + this.hostip, this.socket.address().address, + ]) + console.log([ + this.hostip, this.socket.address().address, + ]) + request += 'Content-Length: ' + Buffer.byteLength(speers) + '\r\n\r\n' + let spct = this.credentials.encrypt(Buffer.concat([Buffer.from(request, 'utf-8'), speers])) + this.socket.write(spct) + request = '' + break + case FLUSH: + request += this.makeHeadWithURL('FLUSH', di) + request += this.makeRtpInfo() + '\r\n' + let fct = this.credentials.encrypt(Buffer.concat([Buffer.from(request, 'utf-8')])) + this.socket.write(fct) + request = '' + break + case SETUP_AP2_2: + if (this.announceId == null) { + this.announceId = nu.randomInt(10) + } + request += this.makeHeadWithURL('SETUP', di) + request += 'Content-Type: application/x-apple-binary-plist\r\n' + let setap2 = bplistCreator( + { streams: [{ audioFormat: 262144, // PCM/44100/16/2 + audioMode: 'default', + controlPort: this.controlPort, + ct: 2, + isMedia: true, + latencyMax: 88200, + latencyMin: 11025, + shk: Buffer.from(this.credentials.writeKey), + spf: 352, + sr: 44100, + type: 0x60, + supportsDynamicStreamID: false, + streamConnectionID: this.announceId, + }] }) + request += 'Content-Length: ' + Buffer.byteLength(setap2) + '\r\n\r\n' + this.controlsocket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + var self = this + this.controlsocket.on('message', function (msg, rinfo) { + console.log('controlsocket.data', msg) + }) + this.controlsocket.bind(this.controlPort, this.socket.address().address) + let s2ct = this.credentials.encrypt(Buffer.concat([Buffer.from(request, 'utf-8'), setap2])) + this.socket.write(s2ct) + request = '' + break + case RECORD: + // console.log(request); + if (this.airplay2) { + this.eventsocket = net.connect(this.eventPort, this.hostip, async function () { + + }) + this.eventsocket.on('data', function (data) { + console.log('eventsocket.data', data) + console.log('eventsocket.data2', self.event_credentials.decrypt(data).toString()) + }) + this.eventsocket.on('error', function (err) { + console.log('eventsocket.error', err) + }) + this.event_credentials = new Credentials( + 'sdsds', + '', + '', + '', + this.seed, + ) + this.event_credentials.writeKey = enc.HKDF( + 'sha512', + Buffer.from('Events-Salt'), + this.srp.computeK(), + Buffer.from('Events-Read-Encryption-Key'), + 32, + ) + this.event_credentials.readKey = enc.HKDF( + 'sha512', + Buffer.from('Events-Salt'), + this.srp.computeK(), + Buffer.from('Events-Write-Encryption-Key'), + 32, + ) + } + if (this.airplay2 != null && this.credentials != null) { + // this.controlsocket.close(); + var nextSeq = this.audioOut.lastSeq + 10 + var rtpSyncTime = nextSeq * config.frames_per_packet + 2 * config.sampling_rate + request += this.makeHead('RECORD', 'rtsp://' + this.socket.address().address + '/' + this.announceId, di, true) + request += 'CSeq: ' + ++this.cseq + '\r\n' + request += 'User-Agent: AirPlay/409.16' + '\r\n' + request += 'Client-Instance: ' + this.dacpId + '\r\n' + request += 'DACP-ID: ' + this.dacpId + '\r\n' + request += 'Active-Remote: ' + this.activeRemote + '\r\n' + request += 'X-Apple-ProtocolVersion: 1\r\n' + request += 'Range: npt=0-\r\n' + request += this.makeRtpInfo() + '\r\n' + // request += '\r\n'; + console.log('ssdas3', request) + let rct = this.credentials.encrypt(Buffer.from(request, 'utf-8')) + this.socket.write(rct) + request = '' + } + else { + request += this.makeHeadWithURL('RECORD', di) + request += 'Range: npt=0-\r\n' + request += this.makeRtpInfo() + '\r\n' + } + break + case GETVOLUME: + body = 'volume\r\n' + request += this.makeHeadWithURL('GET_PARAMETER', di) + request + += 'Content-Type: text/parameters\r\n' + + 'Content-Length: ' + body.length + '\r\n\r\n' + if (this.airplay2) { + let rct2 = this.credentials.encrypt(Buffer.concat([Buffer.from(request + body, 'utf-8')])) + this.socket.write(rct2) + request = '' + } + break + case SETVOLUME: + var attenuation + = this.volume === 0.0 + ? -144.0 + : (-30.0) * (100 - this.volume) / 100.0 + + body = 'volume: ' + attenuation + '\r\n' + + request += this.makeHeadWithURL('SET_PARAMETER', di) + request + += 'Content-Type: text/parameters\r\n' + + 'Content-Length: ' + body.length + '\r\n\r\n' + + request += body + // console.log(request); + break + case SETPROGRESS: + function hms(seconds) { + return [3600, 60] + .reduceRight( + (p, b) => r => [Math.floor(r / b)].concat(p(r % b)), + r => [r], + )(seconds) + .map(a => a.toString().padStart(2, '0')) + .join(':') + } + let position = this.starttime + (this.progress) * Math.floor((2 * config.sampling_rate) / (config.frames_per_packet / 125) / 0.71) + let duration = this.starttime + (this.duration) * Math.floor((2 * config.sampling_rate) / (config.frames_per_packet / 125) / 0.71) + + if (this.debug) { + console.log('start', this.starttime, 'position', position, 'duration', duration, 'position1', hms(this.progress), 'duration1', hms(this.duration)) + } + + body = 'progress: ' + this.starttime + '/' + position + '/' + duration + '\r\n' + request += this.makeHeadWithURL('SET_PARAMETER', di) + request + += 'Content-Type: text/parameters\r\n' + + 'Content-Length: ' + body.length + '\r\n\r\n' + + request += body + // console.log(request); + break + case SETDAAP: + let daapenc = true + // daapenc = true + var name = this.daapEncode('minm', this.trackInfo.name, daapenc) + var artist = this.daapEncode('asar', this.trackInfo.artist, daapenc) + var album = this.daapEncode('asal', this.trackInfo.album, daapenc) + var daapInfo = this.daapEncodeList('mlit', daapenc, name, artist, album) + + var head = this.makeHeadWithURL('SET_PARAMETER', di) + head += this.makeRtpInfo() + head + += 'Content-Type: application/x-dmap-tagged\r\n' + + 'Content-Length: ' + daapInfo.length + '\r\n\r\n' + + var buf = new Buffer(head.length) + buf.write(head, 0, head.length, 'utf-8') + request = Buffer.concat([buf, daapInfo]) + // console.log(request); + break + + case SETART: + var head = this.makeHeadWithURL('SET_PARAMETER', di) + head += this.makeRtpInfo() + head + += 'Content-Type: ' + this.artworkContentType + '\r\n' + + 'Content-Length: ' + this.artwork.length + '\r\n\r\n' + + var buf = new Buffer(head.length) + buf.write(head, 0, head.length, 'utf-8') + request = Buffer.concat([buf, this.artwork]) + // console.log(request); + if (this.encryptedChannel && this.credentials) { + this.socket.write(this.credentials.encrypt(Buffer.concat([request]))) + } + else { this.socket.write(request) } + request = '' + break + + case TEARDOWN: + try { + this.socket.end(this.makeHead('TEARDOWN', '', di) + '\r\n') + } + catch (_) {} + if (this.debug) console.log('teardown') + this.cleanup('stopped') + + // return here since the socket is closed + return + + default: + return + } + + this.startTimeout() + if (this.encryptedChannel && this.credentials) { + this.socket.write(this.credentials.encrypt(Buffer.concat([Buffer.from(request, 'utf-8')]))) + } + else { this.socket.write(request) } +} + +Client.prototype.daapEncodeList = function (field, enc, ...args) { + var values = Array.prototype.slice.call(args) + var value = Buffer.concat(values) + var buf = new Buffer(field.length + 4) + buf.write(field, 0, field.length, enc ? 'utf-8' : 'ascii') + buf.writeUInt32BE(value.byteLength, field.length) + return Buffer.concat([buf, value]) +} + +Client.prototype.daapEncode = function (field, value, enc) { + var valuebuf = new Buffer.from(value, 'utf-8') + var buf = new Buffer(field.length + valuebuf.byteLength + 4) + + buf.write(field, 0, field.length, enc ? 'utf-8' : 'ascii') + buf.writeUInt32BE(valuebuf.byteLength, field.length) + buf.write(value, field.length + 4, valuebuf.byteLength, enc ? 'utf-8' : 'ascii') + return buf +} + +Client.prototype.parsePorts = function (headers) { + function parsePort(name, transport) { + var re = new RegExp(name + '=(\\d+)') + var res = re.exec(transport) + + return res ? parseInt(res[1]) : null + } + + var transport = headers['Transport'], + rtspConfig = { + audioLatency: parseInt(headers['Audio-Latency']), + requireEncryption: this.requireEncryption, + }, + names = ['server_port', 'control_port', 'timing_port'] + + for (var i = 0; i < names.length; i++) { + var name = names[i] + var port = parsePort(name, transport) + + if (port === null) { + if (this.debug) console.log('parseport') + // this.cleanup('parse_ports', transport); + // return false; + rtspConfig[name] = 4533 + } + else + rtspConfig[name] = port + } + + this.emit('config', rtspConfig) + + return true +} + +function parseAuthenticate(auth, field) { + var re = new RegExp(field + '="([^"]+)"'), + res = re.exec(auth) + + return res ? res[1] : null +} + +Client.prototype.processData = function (blob, rawData) { + console.log('Receiving request:', this.hostip, rtsp_methods[this.status + 1]) + var response = parseResponse2(blob, this), + headers = response.headers + if (this.debug != false) { + if ((rawData.toString()).includes('bplist00')) { + try { + let buf = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + let bplist = bplistParser.parseBuffer(buf) + console.log('incoming-res: \r\n', JSON.stringify(bplist)) + } + catch (_) { + console.log('incoming-res: \r\n', rawData.toString()) + } + } + else { + console.log('incoming-res: \r\n', rawData.toString()) + } + } + if (this.status != OPTIONS && this.status != OPTIONS2 && this.mode == 0) { + if (response.code === 401) { + if (!this.password) { + if (this.debug) console.log('nopass') + if (this.status == OPTIONS3) { + this.emit('pair_failed') + this.cleanup('no_password') + } + return + } + + if (response.code === 455) { + return + } + + if (this.passwordTried) { + if (this.debug) console.log('badpass') + this.emit('pair_failed') + this.cleanup('bad_password') + + return + } + else + this.passwordTried = true + + const auth = headers['WWW-Authenticate'] + + const di = { + realm: parseAuthenticate(auth, 'realm'), + nonce: parseAuthenticate(auth, 'nonce'), + username: 'iTunes', + password: this.password, + } + if (this.debug) console.log() + this.sendNextRequest(di) + return + } + + if (response.code === 453) { + if (this.debug) console.log('busy') + this.cleanup('busy') + return + } + + if (response.code === 403 && this.status == ANNOUNCE && this.mode == 2) { + this.status = AUTH_SETUP + this.sendNextRequest() + return + } + + if (response.code !== 200) { + if (this.status != SETVOLUME && (this.status != ANNOUNCE && this.mode == 2) && this.status != SETPEERS && this.status != FLUSH && this.status != RECORD && this.status != GETVOLUME && this.status != SETPROGRESS && this.status != SETDAAP && this.status != SETART) { + if ([PAIR_VERIFY_1, + PAIR_VERIFY_2, + AUTH_SETUP, + PAIR_PIN_START, + PAIR_PIN_SETUP_1, + PAIR_PIN_SETUP_2, + PAIR_PIN_SETUP_3].includes(this.status)) { + this.emit('pair_failed') + } + this.cleanup(response.status) + return + } + } + } + else if (this.mode == 1) { + if (response.code === 401) { + if (!this.password) { + if (this.debug) console.log('nopass') + this.emit('pair_failed') + this.cleanup('no_password') + return + } + + if (this.passwordTried) { + if (this.debug) console.log('badpass') + this.emit('pair_failed') + this.cleanup('bad_password') + + return + } + else + this.passwordTried = true + + const auth = headers['WWW-Authenticate'] + + const di = { + realm: parseAuthenticate(auth, 'realm'), + nonce: parseAuthenticate(auth, 'nonce'), + username: 'iTunes', + password: this.password, + } + if (this.debug) console.log(di) + this.sendNextRequest(di) + return + } + + if (response.code === 453) { + if (this.debug) console.log('busy') + this.cleanup('busy') + return + } + + if (response.code === 403 && this.status == ANNOUNCE && this.mode == 2) { + this.status = AUTH_SETUP + this.sendNextRequest() + return + } + + if (response.code !== 200) { + if (this.debug) console.log(response.status) + if (this.status != SETVOLUME && (this.status != ANNOUNCE && this.mode == 2) && this.status != SETPEERS && this.status != FLUSH && this.status != RECORD && this.status != GETVOLUME && this.status != SETPROGRESS && this.status != SETDAAP && this.status != SETART) { + if ([PAIR_VERIFY_1, + PAIR_VERIFY_2, + AUTH_SETUP, + PAIR_PIN_START, + PAIR_PIN_SETUP_1, + PAIR_PIN_SETUP_2, + PAIR_PIN_SETUP_3].includes(this.status)) { + this.emit('pair_failed') + } + this.cleanup(response.status) + return + } + } + } + + // password was accepted (or not needed) + this.passwordTried = false + + switch (this.status) { + case PAIR_PIN_START: + if (!this.transient) { + this.emit('need_password') + } + this.status = this.airplay2 ? PAIR_SETUP_1 : PAIR_PIN_SETUP_1 + if (!this.transient) return + break + case PAIR_PIN_SETUP_1: + this.srp = new LegacySRP(2048) + this.P = this.password + let bufa = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + const { pk, salt } = bplistParser.parseBuffer(bufa)[0] + + this.s = salt.toString('hex') + this.B = pk.toString('hex') + + // SRP: Generate random auth_secret, 'a'; if pairing is successful, it'll be utilized in + // subsequent session authentication(s). + this.a = crypto.randomBytes(32).toString('hex') + + // SRP: Compute A and M1. + this.A = this.srp.A(this.a) + this.M1 = this.srp.M1(this.I, this.P, this.s, this.a, this.B) + this.status = PAIR_PIN_SETUP_2 + break + case PAIR_PIN_SETUP_2: + const { epk, authTag } = ATVAuthenticator.confirm(this.a, this.srp.K(this.I, this.P, this.s, this.a, this.B)) + + this.epk = epk + this.authTag = authTag + this.status = PAIR_PIN_SETUP_3 + + break + case PAIR_PIN_SETUP_3: + this.status = PAIR_VERIFY_1 + this.authSecret = this.a + break + case PAIR_VERIFY_1: + let buf1 = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + console.log('verify2', Buffer.byteLength(buf1)) + if (Buffer.byteLength(buf1) != 0) { + const atv_pub = buf1.slice(0, 32).toString('hex') + const atv_data = buf1.slice(32).toString('hex') + + const shared = ATVAuthenticator.shared(this.pair_verify_1_verifier.v_pri, atv_pub) + const signed = ATVAuthenticator.signed(this.authSecret, this.pair_verify_1_verifier.v_pub, atv_pub) + this.pair_verify_1_signature = Buffer.from( + Buffer.from([0x00, 0x00, 0x00, 0x00]).toString('hex') + + ATVAuthenticator.signature(shared, atv_data, signed), + 'hex', + ) + if (this.debug) console.log('verify2', Buffer.byteLength(this.pair_verify_1_signature)) + this.status = PAIR_VERIFY_2 + } + else { + this.emit('pair_failed') + this.cleanup('pair_failed') + return + } + break + case PAIR_VERIFY_2: + + this.status = OPTIONS + break + case PAIR_SETUP_1: + let buf2 = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + let databuf1 = tlv.decode(buf2) + if (this.debug) console.log(databuf1) + if (databuf1[tlv.Tag.BackOff]) { + let backOff = databuf1[tlv.Tag.BackOff] + console.log(backOff) + let seconds = Buffer.from(backOff).readInt16LE(0, backOff.byteLength) + + console.log('You\'ve attempt to pair too recently. Try again in ' + (seconds) + ' seconds.') + } + if (databuf1[tlv.Tag.ErrorCode]) { + let buffer = databuf1[tlv.Tag.ErrorCode] + console.log('Device responded with error code ' + Buffer.from(buffer).readIntLE(0, buffer.byteLength) + '. Try rebooting your Apple TV.') + } + if (databuf1[tlv.Tag.PublicKey]) { + this._atv_pub_key = databuf1[tlv.Tag.PublicKey] + this._atv_salt = databuf1[tlv.Tag.Salt] + this._hap_genkey = crypto.randomBytes(32) + if (this.password == null) { + this.password = 3939 // transient + } + this.srp = new SrpClient(SRP.params.hap, Buffer.from(this._atv_salt), Buffer.from('Pair-Setup'), Buffer.from(this.password.toString()), Buffer.from(this._hap_genkey), true) + this.srp.setB(this._atv_pub_key) + this.A = this.srp.computeA() + this.M1 = this.srp.computeM1() + this.status = PAIR_SETUP_2 + } + else { + this.emit('pair_failed') + this.cleanup('pair_failed') + return + } + break + case PAIR_SETUP_2: + let buf3 = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + let databuf2 = tlv.decode(buf3) + this.deviceProof = databuf2[tlv.Tag.Proof] + // console.log("DEBUG: Device Proof=" + this.deviceProof.toString('hex')); + this.srp.checkM2(this.deviceProof) + if (this.transient == true) { + this.credentials = new Credentials( + 'sdsds', + '', + '', + '', + this.seed, + ) + this.credentials.writeKey = enc.HKDF( + 'sha512', + Buffer.from('Control-Salt'), + this.srp.computeK(), + Buffer.from('Control-Write-Encryption-Key'), + 32, + ) + this.credentials.readKey = enc.HKDF( + 'sha512', + Buffer.from('Control-Salt'), + this.srp.computeK(), + Buffer.from('Control-Read-Encryption-Key'), + 32, + ) + this.encryptedChannel = true + console.log(this.srp.computeK()) + this.status = SETUP_AP2_1 + } + else { + this.status = PAIR_SETUP_3 + } + break + case PAIR_SETUP_3: + let buf4 = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + let encryptedData = tlv.decode(buf4)[tlv.Tag.EncryptedData] + let cipherText = encryptedData.slice(0, -16) + let hmac = encryptedData.slice(-16) + let decrpytedData = enc.verifyAndDecrypt(cipherText, hmac, null, Buffer.from('PS-Msg06'), this.encryptionKey) + let tlvData = tlv.decode(decrpytedData) + this.credentials = new Credentials( + 'sdsds', + tlvData[tlv.Tag.Username], + this.pairingId, + tlvData[tlv.Tag.PublicKey], + this.seed, + ) + this.status = PAIR_VERIFY_HAP_1 + break + case PAIR_VERIFY_HAP_1: + let buf5 = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + let decodedData = tlv.decode(buf5) + let sessionPublicKey = decodedData[tlv.Tag.PublicKey] + let encryptedData1 = decodedData[tlv.Tag.EncryptedData] + + if (sessionPublicKey.length != 32) { + throw new Error(`sessionPublicKey must be 32 bytes (but was ${sessionPublicKey.length})`) + } + + let cipherText1 = encryptedData1.slice(0, -16) + let hmac1 = encryptedData1.slice(-16) + // let sharedSecret = curve25519.deriveSharedSecret(this.verifyPrivate, sessionPublicKey); + let sharedSecret = curve25519_js.sharedKey(this.verifyPrivate, sessionPublicKey) + let encryptionKey = enc.HKDF( + 'sha512', + Buffer.from('Pair-Verify-Encrypt-Salt'), + sharedSecret, + Buffer.from('Pair-Verify-Encrypt-Info'), + 32, + ) + let decryptedData = enc.verifyAndDecrypt(cipherText1, hmac1, null, Buffer.from('PV-Msg02'), encryptionKey) + this.verifier_hap_1 = { + sessionPublicKey: sessionPublicKey, + sharedSecret: sharedSecret, + encryptionKey: encryptionKey, + pairingData: decryptedData, + } + this.status = PAIR_VERIFY_HAP_2 + this.sharedSecret = sharedSecret + break + case PAIR_VERIFY_HAP_2: + let buf6 = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + this.credentials.readKey = enc.HKDF( + 'sha512', + Buffer.from('Control-Salt'), + this.verifier_hap_1.sharedSecret, + Buffer.from('Control-Read-Encryption-Key'), + 32, + ) + this.credentials.writeKey = enc.HKDF( + 'sha512', + Buffer.from('Control-Salt'), + this.verifier_hap_1.sharedSecret, + Buffer.from('Control-Write-Encryption-Key'), + 32, + ) + if (this.debug) { + console.log('write', this.credentials.writeKey) + console.log('buf6', buf6) + } + this.encryptedChannel = true + this.status = SETUP_AP2_1 + break + case SETUP_AP2_1: + console.log('timing port parsing') + let buf7 = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + let sa1_bplist = bplistParser.parseBuffer(buf7) + this.eventPort = sa1_bplist[0]['eventPort'] + if (sa1_bplist[0]['timingPort']) + this.timingDestPort = sa1_bplist[0]['timingPort'] + console.log('timing port ok', sa1_bplist[0]['timingPort']) + // let rtspConfig1 = { + // audioLatency: 50, + // requireEncryption: false, + // server_port : 22223, + // control_port : this.controlPort, + // timing_port : this.timingPort, + // event_port: this.eventPort, + // credentials : this.credentials + // } + // this.emit('config', rtspConfig1); + + // this.eventsocket.bind(3003, this.socket.address().address); + this.status = SETPEERS + break + case SETUP_AP2_2: + let buf8 = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + let sa2_bplist = bplistParser.parseBuffer(buf8) + let rtspConfig = { + audioLatency: 50, + requireEncryption: false, + server_port: sa2_bplist[0]['streams'][0]['dataPort'], + control_port: sa2_bplist[0]['streams'][0]['controlPort'], + timing_port: this.timingDestPort ? this.timingDestPort : this.timingPort, + credentials: this.credentials, + } + this.timingsocket.close() + this.controlsocket.close() + this.emit('config', rtspConfig) + console.log('goto info') + // this.session = 1; + this.status = RECORD + // this.emit('ready'); + break + case SETPEERS: + this.status = SETUP_AP2_2 + break + case FLUSH: + this.status = PLAYING + this.metadataReady = true + this.emit('pair_success') + this.session = '1' + console.log('flush') + this.emit('ready') + // console.log(sa2_bplist[0]["streams"][0]["controlPort"], sa2_bplist[0]["streams"][0]["dataPort"] ) + + break + case INFO: + let buf9 = Buffer.from(rawData).slice(rawData.length - parseInt(headers['Content-Length']), rawData.length) + this.status = (this.credentials) ? RECORD : PAIR_SETUP_1 + break + case GETVOLUME: + this.status = RECORD + break + case AUTH_SETUP: + this.status = OPTIONS + break + case OPTIONS: + /* + * Devices like Apple TV and Zeppelin Air do not support encryption. + * Only way of checking that: they do not reply to Apple-Challenge + */ + if (headers['Apple-Response']) + this.requireEncryption = true + // console.log("yeah22332",headers['WWW-Authenticate'],response.code) + if (headers['WWW-Authenticate'] != null & response.code === 401) { + let auth = headers['WWW-Authenticate'] + let realm = parseAuthenticate(auth, 'realm') + let nonce = parseAuthenticate(auth, 'nonce') + let uri = '*' + let user = 'iTunes' + let methodx = 'OPTIONS' + let pwd = this.password + const ha1 = md5norm(`${user}:${realm}:${pwd}`) + const ha2 = md5norm(`${methodx}:${uri}`) + const di_response = md5(`${ha1}:${nonce}:${ha2}`) + this.code_digest = `Authorization: Digest username="${user}", realm="${realm}", nonce="${nonce}", uri="${uri}", response="${di_response}" \r\n\r\n` + this.status = OPTIONS2 + } + else { + this.status = this.session ? PLAYING : (this.airplay2 ? PAIR_PIN_START : ANNOUNCE) + // if (this.status == ANNOUNCE && response.code === 200){this.emit('pair_success')}; + } + + break + case OPTIONS2: + /* + * Devices like Apple TV and Zeppelin Air do not support encryption. + * Only way of checking that: they do not reply to Apple-Challenge + */ + // if(headers['Apple-Response']) + // this.requireEncryption = true; + if (headers['WWW-Authenticate'] != null & response.code === 401) { + let auth = headers['WWW-Authenticate'] + let realm = parseAuthenticate(auth, 'realm') + let nonce = parseAuthenticate(auth, 'nonce') + let uri = '*' + let user = 'iTunes' + let methodx = 'OPTIONS' + let pwd = this.password + const ha1 = md5(`${user}:${realm}:${pwd}`) + const ha2 = md5(`${methodx}:${uri}`) + const di_response = md5(`${ha1}:${nonce}:${ha2}`) + this.code_digest = `Authorization: Digest username="${user}", realm="${realm}", nonce="${nonce}", uri="${uri}", response="${di_response}" \r\n\r\n` + this.status = OPTIONS3 + } + else { + this.status = this.session ? PLAYING : (this.airplay2 ? SETUP_AP2_1 : ANNOUNCE) + // if (this.status == ANNOUNCE && response.code === 200){this.emit('pair_success')} + } ; + + break + case OPTIONS3: + this.status = this.session ? PLAYING : (this.airplay2 ? SETUP_AP2_1 : ANNOUNCE) + // if (this.status == ANNOUNCE && response.code === 200){this.emit('pair_success')} + break + case ANNOUNCE: + this.status = (this.airplay2 == true && this.mode == 2) ? PAIR_PIN_START : SETUP + break + + case SETUP: + this.status = RECORD + this.session = headers['Session'] + this.parsePorts(headers) + break + + case RECORD: + this.metadataReady = true + this.emit('pair_success') + if (!this.airplay2) { + this.session = this.session ?? '1' + this.emit('ready') + }; + this.status = SETVOLUME + break + + case SETVOLUME: + if (!this.sentFakeProgess) { + this.progress = 10 + this.duration = 2000000 + this.sentFakeProgess = true + this.status = SETPROGRESS + } + else { + this.status = PLAYING + }; + break + case SETPROGRESS: + this.status = this.airplay2 ? FLUSH : PLAYING + break + case SETDAAP: + this.status = PLAYING + break + + case SETART: + this.status = PLAYING + break + } + try { + if (this.callback != null) { + this.callback() + } + } + catch (e) {} + + this.sendNextRequest() +} + +Client.prototype.parseObject = function (plist) { + if (this.debug) console.log('plist', plist) +} diff --git a/vendor/node-airtunes2/lib/srp.js b/vendor/node-airtunes2/lib/srp.js new file mode 100644 index 0000000..0c1cf36 --- /dev/null +++ b/vendor/node-airtunes2/lib/srp.js @@ -0,0 +1,158 @@ +const bigInt = require('big-integer') +const sha1 = require('js-sha1') +const memoize = require('lodash/memoize') +const { hexString2ArrayBuffer } = require('./util') + +// ... + +const groups = { + 1024: { + N: new bigInt( + 'EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C' + + '9C256576D674DF7496EA81D3383B4813D692C6E0E0D5D8E250B98BE4' + + '8E495C1D6089DAD15DC7D7B46154D6B6CE8EF4AD69B15D4982559B29' + + '7BCF1885C529F566660E57EC68EDBC3C05726CC02FD4CBF4976EAA9A' + + 'FD5138FE8376435B9FC61D2FC0EB06E3', 16), + + g: new bigInt(2), + }, + + 2048: { + N: new bigInt( + 'AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC319294' + + '3DB56050A37329CBB4A099ED8193E0757767A13DD52312AB4B03310D' + + 'CD7F48A9DA04FD50E8083969EDB767B0CF6095179A163AB3661A05FB' + + 'D5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF74' + + '7359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A' + + '436C6481F1D2B9078717461A5B9D32E688F87748544523B524B0D57D' + + '5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6AF874E73' + + '03CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6' + + '94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F' + + '9E4AFF73', 16), + + g: new bigInt(2), + }, +} + +// ... + +class SRP { + constructor(group) { + this.group = group + + this.N = groups[group].N + this.g = groups[group].g + + this.A = memoize(this.A) + this.K = memoize(this.K) + } + + // ... + // Private. + + u(A, B) { + const A_buf = hexString2ArrayBuffer(A) + const B_buf = hexString2ArrayBuffer(B) + + const result = new Uint8Array(A_buf.byteLength + B_buf.byteLength) + result.set(A_buf) + result.set(B_buf, A_buf.byteLength) + + return sha1(result) + } + + k() { + const padded_g = '0'.repeat((this.group / 4) - 1) + this.g.toString(16) + + const N_buf = hexString2ArrayBuffer(this.N.toString(16)) + const g_buf = hexString2ArrayBuffer(padded_g) + + const result = new Uint8Array(N_buf.byteLength + g_buf.byteLength) + result.set(N_buf) + result.set(g_buf, N_buf.byteLength) + + return sha1(result) + } + + x(I, P, s) { + const s_buf = hexString2ArrayBuffer(s.toLowerCase()) + const I_P_buf = hexString2ArrayBuffer(sha1(I + ':' + P)) + + const result = new Uint8Array(s_buf.byteLength + I_P_buf.byteLength) + result.set(s_buf) + result.set(I_P_buf, s_buf.byteLength) + + return sha1(result) + }; + + S(B, k, x, a, u) { + B = new bigInt(B, 16) + k = new bigInt(k, 16) + x = new bigInt(x, 16) + a = new bigInt(a, 16) + u = new bigInt(u, 16) + + return B.add(this.N.multiply(k)).subtract(this.g.modPow(x, this.N).multiply(k)).mod(this.N) + .modPow(a.add(u.multiply(x)), this.N) + .toString(16) + } + + // ... + // Public. + + A(a) { + return this.g.modPow(new bigInt(a, 16), this.N).toString(16) + } + + K(I, P, s, a, B) { + const k = this.k() + const x = this.x(I, P, s) + const u = this.u(this.A(a), B) + const S = this.S(B, k, x, a, u) + + // ... + + const S_buf = hexString2ArrayBuffer(S) + + let hash1 = new Uint8Array(S_buf.byteLength + 4) + hash1.set(S_buf) + hash1.set([0x00, 0x00, 0x00, 0x00], S_buf.byteLength) + + let hash2 = new Uint8Array(S_buf.byteLength + 4) + hash2.set(S_buf) + hash2.set([0x00, 0x00, 0x00, 0x01], S_buf.byteLength) + + return sha1(hash1) + sha1(hash2) + } + + M1(I, P, s, a, B) { + // M1 = H( H(N) ^ H(g) | H(I) | s | PAD(A) | PAD(B) | K ) + const A = this.A(a) + const K = this.K(I, P, s, a, B) + + const hN = new Uint8Array(sha1.arrayBuffer(hexString2ArrayBuffer(this.N.toString(16)))) + const hg = new Uint8Array(sha1.arrayBuffer([this.g.toString(16)])) + + const hN_hg = new Uint8Array(20) + for (let i = 0; i < 20; i++) { + hN_hg[i] = hN[i] ^ hg[i] + } + + const hI = sha1.arrayBuffer(I) + + // ... + + return sha1.create() + .update(hN_hg) + .update(hI) + .update(hexString2ArrayBuffer(s)) + .update(hexString2ArrayBuffer(A)) + .update(hexString2ArrayBuffer(B)) + .update(hexString2ArrayBuffer(K)) + .hex() + } +} + +// ... + +module.exports = SRP diff --git a/vendor/node-airtunes2/lib/udp_servers.js b/vendor/node-airtunes2/lib/udp_servers.js new file mode 100644 index 0000000..f3c8d66 --- /dev/null +++ b/vendor/node-airtunes2/lib/udp_servers.js @@ -0,0 +1,214 @@ +const dgram = require('dgram') +const events = require('events') +const util = require('util') +const async = require('async') +const config = require('./config') +const nu = require('./num_util') +const ntp = require('./ntp') + +const UNBOUND = 0 +const BINDING = 1 +const BOUND = 2 + +function UDPServers() { + events.EventEmitter.call(this) + + this.status = UNBOUND + + this.control = { + socket: null, + port: null, + name: 'control', + } + + this.timing = { + socket: null, + port: null, + name: 'timing', + } + + this.hosts = [] +} + +util.inherits(UDPServers, events.EventEmitter) + +UDPServers.prototype.bind = function (host) { + let self = this + + this.hosts.push(host) + + switch (this.status) { + case BOUND: + process.nextTick(function () { + self.emit('ports', null, this.control, this.timing) + }) + return + + case BINDING: + return + } + + self = this + this.status = BINDING + + // Timing socket + this.timing.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + + this.timing.socket.on('message', function (msg, rinfo) { + // only listen and respond on own hosts + if (self.hosts.indexOf(rinfo.address) < 0) return + + var ts1 = msg.readUInt32BE(24) + var ts2 = msg.readUInt32BE(28) + + var reply = new Buffer(32) + reply.writeUInt16BE(0x80d3, 0) + reply.writeUInt16BE(0x0007, 2) + reply.writeUInt32BE(0x00000000, 4) + + reply.writeUInt32BE(ts1, 8) + reply.writeUInt32BE(ts2, 12) + + var ntpTime = ntp.timestamp() + + ntpTime.copy(reply, 16) + ntpTime.copy(reply, 24) + + self.timing.socket.send( + reply, 0, reply.length, + rinfo.port, rinfo.address, + ) + }) + + // Control socket + this.control.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + + this.control.socket.on('message', function (msg, rinfo) { + // only listen for own hosts + if (self.hosts.indexOf(rinfo.address) < 0) return + + var resendRequested = msg.readUInt8(1) == (0x80 | 0x55) + // var serverSeq = msg.readUInt16BE(2); + if (resendRequested) { + const missedSeq = msg.readUInt16BE(4) + const count = msg.readUInt16BE(6) + self.emit('resendRequested', missedSeq, count) + } + }) + + // // Event socket + // this.event.socket = dgram.createSocket('udp4'); + + // this.event.socket.on('message', function(msg, rinfo) { + + // // only listen and respond on own hosts + // if (self.hosts.indexOf(rinfo.address) < 0) return; + // }); + + // Find open ports + if (!(process.platform == 'darwin')) { + var current_port = config.udp_default_port + + self.control.socket.bind(function (err) { + if (err) { + return cb(err) + } + + self.control.port = self.control.socket.address().port + }) + self.timing.socket.bind(function (err) { + if (err) { + return cb(err) + } + + self.timing.port = self.timing.socket.address().port + }) + let myInterval = setInterval(small, 100) + function small() { + if (self.timing.port != null && self.control.port != null) { + clearInterval(myInterval) + self.status = BOUND + self.emit('ports', null, self.control, self.timing) + } + } + } + else { + var to_bind = [this.control, this.timing] + var current_port = config.udp_default_port + async.whilst( + function (cb) { cb(null, to_bind.length > 0) }, + function (cb) { + var nextPort = to_bind[0] + nextPort.socket.once('error', function (e) { + if (e.code === 'EADDRINUSE') { + // try next port + current_port++ + cb() + } + else + // otherwise, report the error and cancel everything + cb(e) + }) + + nextPort.socket.once('listening', function () { + // socket successfully bound + to_bind.shift() + nextPort.port = current_port + current_port++ + cb() + }) + + nextPort.socket.bind(current_port) + }, + function (err) { + if (err) { + self.close() + self.emit('ports', err) + } + else { + self.status = BOUND + self.emit('ports', null, self.control, self.timing) + } + }, + ) + } +} + +UDPServers.prototype.close = function () { + // + try { + this.status = UNBOUND + + this.timing.socket.close() + this.timing.socket = null + + this.control.socket.close() + this.control.socket = null + } + catch (e) { } + + // this.event.socket.close(); + // this.event.socket = null; +} + +UDPServers.prototype.sendControlSync = function (seq, dev) { + if (this.status !== BOUND) + return + + var packet = new Buffer(20) + + packet.writeUInt16BE(0x80d4, 0) + packet.writeUInt16BE(0x0007, 2) + packet.writeUInt32BE(nu.low32(seq * config.frames_per_packet), 4) + + var ntpTime = ntp.timestamp() + ntpTime.copy(packet, 8) + + packet.writeUInt32BE( + nu.low32(seq * config.frames_per_packet + + config.sampling_rate * 2), 16, + ) + this.control.socket.send(packet, 0, packet.length, dev.controlPort, dev.host) +} + +module.exports = UDPServers diff --git a/vendor/node-airtunes2/lib/util.js b/vendor/node-airtunes2/lib/util.js new file mode 100644 index 0000000..dc19579 --- /dev/null +++ b/vendor/node-airtunes2/lib/util.js @@ -0,0 +1,12 @@ +const hexString2ArrayBuffer + = hexString => new Uint8Array(hexString.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))) + +const buf2hex + = buffer => Array.prototype.map.call(new Uint8Array(buffer), x => ('00' + x.toString(16)).slice(-2)).join('') + +// ... + +module.exports = { + hexString2ArrayBuffer, + buf2hex, +} diff --git a/vendor/node-airtunes2/package.json b/vendor/node-airtunes2/package.json new file mode 100644 index 0000000..301691e --- /dev/null +++ b/vendor/node-airtunes2/package.json @@ -0,0 +1,31 @@ +{ + "name": "node-airtunes2", + "version": "2.5.0-radiocli.1", + "private": true, + "description": "an AirTunes v2 implementation: stream wirelessly to audio devices.", + "main": "./lib/index.js", + "licenses": [ + { + "type": "AGPLv3" + } + ], + "repository": { + "type": "git", + "url": "git+https://github.com/bertrandda/node-airtunes2.git" + }, + "radiocliLegacyPairing": "node:crypto", + "dependencies": { + "@noble/ed25519": "1.7.5", + "async": "3.2.6", + "axlsign": "git+https://github.com/ciderapp/curve25519-js.git#cae68782564b83ffa1c7c757e40a3944cb140290", + "big-integer": "1.6.52", + "bplist-creator": "0.1.1", + "bplist-parser": "0.3.2", + "chacha-js": "git+https://github.com/ciderapp/chacha20poly1305.git#f85941b8f5c62843a1459758cc3dbabad9f849cb", + "curve25519-js": "0.0.4", + "fast-srp-hap": "2.0.4", + "js-sha1": "0.7.0", + "lodash": "4.18.1", + "python-struct": "1.1.3" + } +} diff --git a/vendor/node-airtunes2/src/CADebugMacros.h b/vendor/node-airtunes2/src/CADebugMacros.h new file mode 100644 index 0000000..0158c98 --- /dev/null +++ b/vendor/node-airtunes2/src/CADebugMacros.h @@ -0,0 +1,257 @@ +/* Copyright: © Copyright 2004 Apple Computer, Inc. All rights reserved. + + Disclaimer: IMPORTANT: This Apple software is supplied to you by Apple Computer, Inc. + ("Apple") in consideration of your agreement to the following terms, and your + use, installation, modification or redistribution of this Apple software + constitutes acceptance of these terms. If you do not agree with these terms, + please do not use, install, modify or redistribute this Apple software. + + In consideration of your agreement to abide by the following terms, and subject + to these terms, Apple grants you a personal, non-exclusive license, under AppleÕs + copyrights in this original Apple software (the "Apple Software"), to use, + reproduce, modify and redistribute the Apple Software, with or without + modifications, in source and/or binary forms; provided that if you redistribute + the Apple Software in its entirety and without modifications, you must retain + this notice and the following text and disclaimers in all such redistributions of + the Apple Software. Neither the name, trademarks, service marks or logos of + Apple Computer, Inc. may be used to endorse or promote products derived from the + Apple Software without specific prior written permission from Apple. Except as + expressly stated in this notice, no other rights or licenses, express or implied, + are granted by Apple herein, including but not limited to any patent rights that + may be infringed by your derivative works or by other works in which the Apple + Software may be incorporated. + + The Apple Software is provided by Apple on an "AS IS" basis. APPLE MAKES NO + WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION THE IMPLIED + WARRANTIES OF NON-INFRINGEMENT, MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE, REGARDING THE APPLE SOFTWARE OR ITS USE AND OPERATION ALONE OR IN + COMBINATION WITH YOUR PRODUCTS. + + IN NO EVENT SHALL APPLE BE LIABLE FOR ANY SPECIAL, INDIRECT, INCIDENTAL OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE + GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + ARISING IN ANY WAY OUT OF THE USE, REPRODUCTION, MODIFICATION AND/OR DISTRIBUTION + OF THE APPLE SOFTWARE, HOWEVER CAUSED AND WHETHER UNDER THEORY OF CONTRACT, TORT + (INCLUDING NEGLIGENCE), STRICT LIABILITY OR OTHERWISE, EVEN IF APPLE HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +/*============================================================================= + CADebugMacros.h + +=============================================================================*/ +#if !defined(__CADebugMacros_h__) +#define __CADebugMacros_h__ + +//============================================================================= +// CADebugMacros +//============================================================================= + +//#define CoreAudio_StopOnFailure 1 +//#define CoreAudio_TimeStampMessages 1 +//#define CoreAudio_ThreadStampMessages 1 +//#define CoreAudio_FlushDebugMessages 1 + +#define CA4CCToCString(the4CC) { ((char*)&the4CC)[0], ((char*)&the4CC)[1], ((char*)&the4CC)[2], ((char*)&the4CC)[3], 0 } + +#if DEBUG || CoreAudio_Debug + + // can be used to break into debugger immediately, also see CADebugger + #define BusError() (*(long *)0 = 0) + + // basic debugging print routines + #if TARGET_OS_MAC && !TARGET_API_MAC_CARBON + extern pascal void DebugStr(const unsigned char* debuggerMsg); + #define DebugMessage(msg) DebugStr("\p"msg) + #define DebugMessageN1(msg, N1) + #define DebugMessageN2(msg, N1, N2) + #define DebugMessageN3(msg, N1, N2, N3) + #else + #include "CADebugPrintf.h" + + #if CoreAudio_FlushDebugMessages && !CoreAudio_UseSysLog + #define FlushRtn ;fflush(DebugPrintfFile) + #else + #define FlushRtn + #endif + + #if CoreAudio_ThreadStampMessages + #include + #include "CAHostTimeBase.h" + #define DebugMessage(msg) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: %s\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), msg) FlushRtn + #define DebugMessageN1(msg, N1) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: "msg"\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1) FlushRtn + #define DebugMessageN2(msg, N1, N2) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: "msg"\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2) FlushRtn + #define DebugMessageN3(msg, N1, N2, N3) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: "msg"\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3) FlushRtn + #define DebugMessageN4(msg, N1, N2, N3, N4) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: "msg"\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4) FlushRtn + #define DebugMessageN5(msg, N1, N2, N3, N4, N5) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: "msg"\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5) FlushRtn + #define DebugMessageN6(msg, N1, N2, N3, N4, N5, N6) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: "msg"\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5, N6) FlushRtn + #define DebugMessageN7(msg, N1, N2, N3, N4, N5, N6, N7) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: "msg"\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5, N6, N7) FlushRtn + #define DebugMessageN8(msg, N1, N2, N3, N4, N5, N6, N7, N8) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: "msg"\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5, N6, N7, N8) FlushRtn + #define DebugMessageN9(msg, N1, N2, N3, N4, N5, N6, N7, N8, N9) DebugPrintfRtn(DebugPrintfFile, "%p %.4f: "msg"\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5, N6, N7, N8, N9) FlushRtn + #elif CoreAudio_TimeStampMessages + #include "CAHostTimeBase.h" + #define DebugMessage(msg) DebugPrintfRtn(DebugPrintfFile, "%.4f: %s\n", pthread_self(), ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), msg) FlushRtn + #define DebugMessageN1(msg, N1) DebugPrintfRtn(DebugPrintfFile, "%.4f: "msg"\n", ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1) FlushRtn + #define DebugMessageN2(msg, N1, N2) DebugPrintfRtn(DebugPrintfFile, "%.4f: "msg"\n", ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2) FlushRtn + #define DebugMessageN3(msg, N1, N2, N3) DebugPrintfRtn(DebugPrintfFile, "%.4f: "msg"\n", ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3) FlushRtn + #define DebugMessageN4(msg, N1, N2, N3, N4) DebugPrintfRtn(DebugPrintfFile, "%.4f: "msg"\n", ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4) FlushRtn + #define DebugMessageN5(msg, N1, N2, N3, N4, N5) DebugPrintfRtn(DebugPrintfFile, "%.4f: "msg"\n", ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5) FlushRtn + #define DebugMessageN6(msg, N1, N2, N3, N4, N5, N6) DebugPrintfRtn(DebugPrintfFile, "%.4f: "msg"\n", ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5, N6) FlushRtn + #define DebugMessageN7(msg, N1, N2, N3, N4, N5, N6, N7) DebugPrintfRtn(DebugPrintfFile, "%.4f: "msg"\n", ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5, N6, N7) FlushRtn + #define DebugMessageN8(msg, N1, N2, N3, N4, N5, N6, N7, N8) DebugPrintfRtn(DebugPrintfFile, "%.4f: "msg"\n", ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5, N6, N7, N8) FlushRtn + #define DebugMessageN9(msg, N1, N2, N3, N4, N5, N6, N7, N8, N9) DebugPrintfRtn(DebugPrintfFile, "%.4f: "msg"\n", ((Float64)(CAHostTimeBase::GetCurrentTimeInNanos()) / 1000000.0), N1, N2, N3, N4, N5, N6, N7, N8, N9) FlushRtn + #else + #define DebugMessage(msg) DebugPrintfRtn(DebugPrintfFile, "%s\n", msg) FlushRtn + #define DebugMessageN1(msg, N1) DebugPrintfRtn(DebugPrintfFile, msg"\n", N1) FlushRtn + #define DebugMessageN2(msg, N1, N2) DebugPrintfRtn(DebugPrintfFile, msg"\n", N1, N2) FlushRtn + #define DebugMessageN3(msg, N1, N2, N3) DebugPrintfRtn(DebugPrintfFile, msg"\n", N1, N2, N3) FlushRtn + #define DebugMessageN4(msg, N1, N2, N3, N4) DebugPrintfRtn(DebugPrintfFile, msg"\n", N1, N2, N3, N4) FlushRtn + #define DebugMessageN5(msg, N1, N2, N3, N4, N5) DebugPrintfRtn(DebugPrintfFile, msg"\n", N1, N2, N3, N4, N5) FlushRtn + #define DebugMessageN6(msg, N1, N2, N3, N4, N5, N6) DebugPrintfRtn(DebugPrintfFile, msg"\n", N1, N2, N3, N4, N5, N6) FlushRtn + #define DebugMessageN7(msg, N1, N2, N3, N4, N5, N6, N7) DebugPrintfRtn(DebugPrintfFile, msg"\n", N1, N2, N3, N4, N5, N6, N7) FlushRtn + #define DebugMessageN8(msg, N1, N2, N3, N4, N5, N6, N7, N8) DebugPrintfRtn(DebugPrintfFile, msg"\n", N1, N2, N3, N4, N5, N6, N7, N8) FlushRtn + #define DebugMessageN9(msg, N1, N2, N3, N4, N5, N6, N7, N8, N9) DebugPrintfRtn(DebugPrintfFile, msg"\n", N1, N2, N3, N4, N5, N6, N7, N8, N9) FlushRtn + #endif + #endif + void DebugPrint(const char *fmt, ...); // can be used like printf + #define DEBUGPRINT(msg) DebugPrint msg // have to double-parenthesize arglist (see Debugging.h) + #if VERBOSE + #define vprint(msg) DEBUGPRINT(msg) + #else + #define vprint(msg) + #endif + + #if CoreAudio_StopOnFailure + #include "CADebugger.h" + #define STOP CADebuggerStop() + #else + #define STOP + #endif + +#else + #define DebugMessage(msg) + #define DebugMessageN1(msg, N1) + #define DebugMessageN2(msg, N1, N2) + #define DebugMessageN3(msg, N1, N2, N3) + #define DebugMessageN4(msg, N1, N2, N3, N4) + #define DebugMessageN5(msg, N1, N2, N3, N4, N5) + #define DebugMessageN6(msg, N1, N2, N3, N4, N5, N6) + #define DebugMessageN7(msg, N1, N2, N3, N4, N5, N6, N7) + #define DebugMessageN8(msg, N1, N2, N3, N4, N5, N6, N7, N8) + #define DebugMessageN9(msg, N1, N2, N3, N4, N5, N6, N7, N8, N9) + #define DEBUGPRINT(msg) + #define vprint(msg) + #define STOP +#endif + +void LogError(const char *fmt, ...); // writes to syslog (and stderr if debugging) + +#define Assert(inCondition, inMessage) \ + if(!(inCondition)) \ + { \ + DebugMessage(inMessage); \ + STOP; \ + } + +#define AssertNoError(inError, inMessage) \ + { \ + SInt32 __E__err = (inError); \ + if(__E__err != 0) \ + { \ + char __4CC_string[5]; \ + *((SInt32*)__4CC_string) = __E__err; \ + __4CC_string[4] = 0; \ + DebugMessageN2(inMessage ", Error: %ld (%s)", __E__err, __4CC_string); \ + STOP; \ + } \ + } + +#define AssertNoKernelError(inError, inMessage) \ + { \ + unsigned int __E__err = (unsigned int)(inError); \ + if(__E__err != 0) \ + { \ + DebugMessageN1(inMessage ", Error: 0x%X", __E__err); \ + STOP; \ + } \ + } + +#define FailIf(inCondition, inHandler, inMessage) \ + if(inCondition) \ + { \ + DebugMessage(inMessage); \ + STOP; \ + goto inHandler; \ + } + +#define FailWithAction(inCondition, inAction, inHandler, inMessage) \ + if(inCondition) \ + { \ + DebugMessage(inMessage); \ + STOP; \ + { inAction; } \ + goto inHandler; \ + } + +#if defined(__cplusplus) + +#define ThrowIf(inCondition, inException, inMessage) \ + if(inCondition) \ + { \ + DebugMessage(inMessage); \ + STOP; \ + throw (inException); \ + } + +#define ThrowIfNULL(inPointer, inException, inMessage) \ + if((inPointer) == NULL) \ + { \ + DebugMessage(inMessage); \ + STOP; \ + throw (inException); \ + } + +#define ThrowIfKernelError(inKernelError, inException, inMessage) \ + { \ + kern_return_t __E__err = (inKernelError); \ + if(__E__err != 0) \ + { \ + DebugMessageN1(inMessage ", Error: 0x%X", __E__err); \ + STOP; \ + throw (inException); \ + } \ + } + +#define ThrowIfError(inError, inException, inMessage) \ + { \ + SInt32 __E__err = (inError); \ + if(__E__err != 0) \ + { \ + char __4CC_string[5]; \ + *((SInt32*)__4CC_string) = __E__err; \ + __4CC_string[4] = 0; \ + DebugMessageN2(inMessage ", Error: %ld (%s)", __E__err, __4CC_string); \ + STOP; \ + throw (inException); \ + } \ + } + +#define ThrowError(inError, inException, inMessage) \ + { \ + SInt32 __E__err = (inError); \ + char __4CC_string[5]; \ + *((SInt32*)__4CC_string) = __E__err; \ + __4CC_string[4] = 0; \ + DebugMessageN2(inMessage ", Error: %ld (%s)", __E__err, __4CC_string); \ + STOP; \ + throw (inException); \ + } + +#define SubclassResponsibility(inMethodName, inException) \ + { \ + DebugMessage(inMethodName": Subclasses must implement this method"); \ + throw (inException); \ + } + +#endif // defined(__cplusplus) + +#endif diff --git a/vendor/node-airtunes2/src/CAHostTimeBase.cpp b/vendor/node-airtunes2/src/CAHostTimeBase.cpp new file mode 100644 index 0000000..ce0c3c2 --- /dev/null +++ b/vendor/node-airtunes2/src/CAHostTimeBase.cpp @@ -0,0 +1,105 @@ +/* Copyright: © Copyright 2005 Apple Computer, Inc. All rights reserved. + + Disclaimer: IMPORTANT: This Apple software is supplied to you by Apple Computer, Inc. + ("Apple") in consideration of your agreement to the following terms, and your + use, installation, modification or redistribution of this Apple software + constitutes acceptance of these terms. If you do not agree with these terms, + please do not use, install, modify or redistribute this Apple software. + + In consideration of your agreement to abide by the following terms, and subject + to these terms, Apple grants you a personal, non-exclusive license, under AppleÕs + copyrights in this original Apple software (the "Apple Software"), to use, + reproduce, modify and redistribute the Apple Software, with or without + modifications, in source and/or binary forms; provided that if you redistribute + the Apple Software in its entirety and without modifications, you must retain + this notice and the following text and disclaimers in all such redistributions of + the Apple Software. Neither the name, trademarks, service marks or logos of + Apple Computer, Inc. may be used to endorse or promote products derived from the + Apple Software without specific prior written permission from Apple. Except as + expressly stated in this notice, no other rights or licenses, express or implied, + are granted by Apple herein, including but not limited to any patent rights that + may be infringed by your derivative works or by other works in which the Apple + Software may be incorporated. + + The Apple Software is provided by Apple on an "AS IS" basis. APPLE MAKES NO + WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION THE IMPLIED + WARRANTIES OF NON-INFRINGEMENT, MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE, REGARDING THE APPLE SOFTWARE OR ITS USE AND OPERATION ALONE OR IN + COMBINATION WITH YOUR PRODUCTS. + + IN NO EVENT SHALL APPLE BE LIABLE FOR ANY SPECIAL, INDIRECT, INCIDENTAL OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE + GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + ARISING IN ANY WAY OUT OF THE USE, REPRODUCTION, MODIFICATION AND/OR DISTRIBUTION + OF THE APPLE SOFTWARE, HOWEVER CAUSED AND WHETHER UNDER THEORY OF CONTRACT, TORT + (INCLUDING NEGLIGENCE), STRICT LIABILITY OR OTHERWISE, EVEN IF APPLE HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +/*============================================================================= + CAHostTimeBase.cp + +=============================================================================*/ + +//============================================================================= +// Includes +//============================================================================= + +#include "CAHostTimeBase.h" + +Float64 CAHostTimeBase::sFrequency = 0; +UInt32 CAHostTimeBase::sMinDelta = 0; +UInt32 CAHostTimeBase::sToNanosNumerator = 0; +UInt32 CAHostTimeBase::sToNanosDenominator = 0; +UInt32 CAHostTimeBase::sFromNanosNumerator = 0; +UInt32 CAHostTimeBase::sFromNanosDenominator = 0; +bool CAHostTimeBase::sUseMicroseconds = false; +bool CAHostTimeBase::sIsInited = false; +#if Track_Host_TimeBase +UInt64 CAHostTimeBase::sLastTime = 0; +#endif + +//============================================================================= +// CAHostTimeBase +// +// This class provides platform independent access to the host's time base. +//============================================================================= + +void CAHostTimeBase::Initialize() +{ + // get the info about Absolute time + #if TARGET_OS_MAC + struct mach_timebase_info theTimeBaseInfo; + mach_timebase_info(&theTimeBaseInfo); + sMinDelta = 1; + sToNanosNumerator = theTimeBaseInfo.numer; + sToNanosDenominator = theTimeBaseInfo.denom; + sFromNanosNumerator = sToNanosDenominator; + sFromNanosDenominator = sToNanosNumerator; + + // the frequency of that clock is: (sToNanosDenominator / sToNanosNumerator) * 10^9 + sFrequency = static_cast(sToNanosDenominator) / static_cast(sToNanosNumerator); + sFrequency *= 1000000000.0; + #elif TARGET_OS_WIN32 + LARGE_INTEGER theFrequency; + QueryPerformanceFrequency(&theFrequency); + sMinDelta = 1; + sToNanosNumerator = 1000000000ULL; + sToNanosDenominator = *((UInt64*)&theFrequency); + sFromNanosNumerator = sToNanosDenominator; + sFromNanosDenominator = sToNanosNumerator; + sFrequency = static_cast(*((UInt64*)&theFrequency)); + #endif + + + #if Log_Host_Time_Base_Parameters + DebugMessage( "Host Time Base Parameters"); + DebugMessageN1(" Minimum Delta: %lu", sMinDelta); + DebugMessageN1(" Frequency: %f", sFrequency); + DebugMessageN1(" To Nanos Numerator: %lu", sToNanosNumerator); + DebugMessageN1(" To Nanos Denominator: %lu", sToNanosDenominator); + DebugMessageN1(" From Nanos Numerator: %lu", sFromNanosNumerator); + DebugMessageN1(" From Nanos Denominator: %lu", sFromNanosDenominator); + #endif + + sIsInited = true; +} diff --git a/vendor/node-airtunes2/src/CAHostTimeBase.h b/vendor/node-airtunes2/src/CAHostTimeBase.h new file mode 100644 index 0000000..0f2c222 --- /dev/null +++ b/vendor/node-airtunes2/src/CAHostTimeBase.h @@ -0,0 +1,224 @@ +/* Copyright: © Copyright 2005 Apple Computer, Inc. All rights reserved. + + Disclaimer: IMPORTANT: This Apple software is supplied to you by Apple Computer, Inc. + ("Apple") in consideration of your agreement to the following terms, and your + use, installation, modification or redistribution of this Apple software + constitutes acceptance of these terms. If you do not agree with these terms, + please do not use, install, modify or redistribute this Apple software. + + In consideration of your agreement to abide by the following terms, and subject + to these terms, Apple grants you a personal, non-exclusive license, under AppleÕs + copyrights in this original Apple software (the "Apple Software"), to use, + reproduce, modify and redistribute the Apple Software, with or without + modifications, in source and/or binary forms; provided that if you redistribute + the Apple Software in its entirety and without modifications, you must retain + this notice and the following text and disclaimers in all such redistributions of + the Apple Software. Neither the name, trademarks, service marks or logos of + Apple Computer, Inc. may be used to endorse or promote products derived from the + Apple Software without specific prior written permission from Apple. Except as + expressly stated in this notice, no other rights or licenses, express or implied, + are granted by Apple herein, including but not limited to any patent rights that + may be infringed by your derivative works or by other works in which the Apple + Software may be incorporated. + + The Apple Software is provided by Apple on an "AS IS" basis. APPLE MAKES NO + WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION THE IMPLIED + WARRANTIES OF NON-INFRINGEMENT, MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE, REGARDING THE APPLE SOFTWARE OR ITS USE AND OPERATION ALONE OR IN + COMBINATION WITH YOUR PRODUCTS. + + IN NO EVENT SHALL APPLE BE LIABLE FOR ANY SPECIAL, INDIRECT, INCIDENTAL OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE + GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + ARISING IN ANY WAY OUT OF THE USE, REPRODUCTION, MODIFICATION AND/OR DISTRIBUTION + OF THE APPLE SOFTWARE, HOWEVER CAUSED AND WHETHER UNDER THEORY OF CONTRACT, TORT + (INCLUDING NEGLIGENCE), STRICT LIABILITY OR OTHERWISE, EVEN IF APPLE HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +/*============================================================================= + CAHostTimeBase.h + +=============================================================================*/ +#if !defined(__CAHostTimeBase_h__) +#define __CAHostTimeBase_h__ + +//============================================================================= +// Includes +//============================================================================= + +#if !defined(__COREAUDIO_USE_FLAT_INCLUDES__) + #include +#else + #include +#endif + +#if TARGET_OS_MAC + #include +#elif TARGET_OS_WIN32 + #include +#else + #error Unsupported operating system +#endif + +#include "CADebugMacros.h" + +//============================================================================= +// CAHostTimeBase +// +// This class provides platform independent access to the host's time base. +//============================================================================= + +#if CoreAudio_Debug +// #define Log_Host_Time_Base_Parameters 1 +// #define Track_Host_TimeBase 1 +#endif + +class CAHostTimeBase +{ + +public: + static UInt64 ConvertToNanos(UInt64 inHostTime); + static UInt64 ConvertFromNanos(UInt64 inNanos); + + static UInt64 GetTheCurrentTime(); +#if TARGET_OS_MAC + static UInt64 GetCurrentTime() { return GetTheCurrentTime(); } +#endif + static UInt64 GetCurrentTimeInNanos(); + + static Float64 GetFrequency() { if(!sIsInited) { Initialize(); } return sFrequency; } + static UInt32 GetMinimumDelta() { if(!sIsInited) { Initialize(); } return sMinDelta; } + + static UInt64 AbsoluteHostDeltaToNanos(UInt64 inStartTime, UInt64 inEndTime); + static SInt64 HostDeltaToNanos(UInt64 inStartTime, UInt64 inEndTime); + +private: + static void Initialize(); + + static bool sIsInited; + + static Float64 sFrequency; + static UInt32 sMinDelta; + static UInt32 sToNanosNumerator; + static UInt32 sToNanosDenominator; + static UInt32 sFromNanosNumerator; + static UInt32 sFromNanosDenominator; + static bool sUseMicroseconds; +#if Track_Host_TimeBase + static UInt64 sLastTime; +#endif +}; + +inline UInt64 CAHostTimeBase::GetTheCurrentTime() +{ + UInt64 theTime = 0; + + #if TARGET_OS_MAC + theTime = mach_absolute_time(); + #elif TARGET_OS_WIN32 + LARGE_INTEGER theValue; + QueryPerformanceCounter(&theValue); + theTime = *((UInt64*)&theValue); + #endif + + #if Track_Host_TimeBase + if(sLastTime != 0) + { + if(theTime <= sLastTime) + { + DebugMessageN2("CAHostTimeBase::GetTheCurrentTime: the current time is earlier than the last time, now: %qd, then: %qd", theTime, sLastTime); + } + sLastTime = theTime; + } + else + { + sLastTime = theTime; + } + #endif + + return theTime; +} + +inline UInt64 CAHostTimeBase::ConvertToNanos(UInt64 inHostTime) +{ + if(!sIsInited) + { + Initialize(); + } + + Float64 theNumerator = static_cast(sToNanosNumerator); + Float64 theDenominator = static_cast(sToNanosDenominator); + Float64 theHostTime = static_cast(inHostTime); + + Float64 thePartialAnswer = theHostTime / theDenominator; + Float64 theFloatAnswer = thePartialAnswer * theNumerator; + UInt64 theAnswer = static_cast(theFloatAnswer); + + //Assert(!((theNumerator > theDenominator) && (theAnswer < inHostTime)), "CAHostTimeBase::ConvertToNanos: The conversion wrapped"); + //Assert(!((theDenominator > theNumerator) && (theAnswer > inHostTime)), "CAHostTimeBase::ConvertToNanos: The conversion wrapped"); + + return theAnswer; +} + +inline UInt64 CAHostTimeBase::ConvertFromNanos(UInt64 inNanos) +{ + if(!sIsInited) + { + Initialize(); + } + + Float64 theNumerator = static_cast(sToNanosNumerator); + Float64 theDenominator = static_cast(sToNanosDenominator); + Float64 theNanos = static_cast(inNanos); + + Float64 thePartialAnswer = theNanos / theNumerator; + Float64 theFloatAnswer = thePartialAnswer * theDenominator; + UInt64 theAnswer = static_cast(theFloatAnswer); + + //Assert(!((theDenominator > theNumerator) && (theAnswer < inNanos)), "CAHostTimeBase::ConvertToNanos: The conversion wrapped"); + //Assert(!((theNumerator > theDenominator) && (theAnswer > inNanos)), "CAHostTimeBase::ConvertToNanos: The conversion wrapped"); + + return theAnswer; +} + + +inline UInt64 CAHostTimeBase::GetCurrentTimeInNanos() +{ + return ConvertToNanos(GetTheCurrentTime()); +} + +inline UInt64 CAHostTimeBase::AbsoluteHostDeltaToNanos(UInt64 inStartTime, UInt64 inEndTime) +{ + UInt64 theAnswer; + + if(inStartTime <= inEndTime) + { + theAnswer = inEndTime - inStartTime; + } + else + { + theAnswer = inStartTime - inEndTime; + } + + return ConvertToNanos(theAnswer); +} + +inline SInt64 CAHostTimeBase::HostDeltaToNanos(UInt64 inStartTime, UInt64 inEndTime) +{ + SInt64 theAnswer; + SInt64 theSign = 1; + + if(inStartTime <= inEndTime) + { + theAnswer = inEndTime - inStartTime; + } + else + { + theAnswer = inStartTime - inEndTime; + theSign = -1; + } + + return theSign * ConvertToNanos(theAnswer); +} + +#endif diff --git a/vendor/node-airtunes2/src/aes_utils.c b/vendor/node-airtunes2/src/aes_utils.c new file mode 100644 index 0000000..17cd32a --- /dev/null +++ b/vendor/node-airtunes2/src/aes_utils.c @@ -0,0 +1,864 @@ +/* + * FIPS-197 compliant AES implementation + * + * Copyright (C) 2001-2004 Christophe Devine + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ + +#include "aes_utils.h" + +/* uncomment the following line to run the test suite */ + +/* #define TEST */ + +/* uncomment the following line to use pre-computed tables */ +/* otherwise the tables will be generated at the first run */ + +/* #define FIXED_TABLES */ + +#ifndef FIXED_TABLES + +/* forward S-box & tables */ + +uint32 FSb[256]; +uint32 FT0[256]; +uint32 FT1[256]; +uint32 FT2[256]; +uint32 FT3[256]; + +/* reverse S-box & tables */ + +uint32 RSb[256]; +uint32 RT0[256]; +uint32 RT1[256]; +uint32 RT2[256]; +uint32 RT3[256]; + +/* round constants */ + +uint32 RCON[10]; + +/* tables generation flag */ + +int do_init = 1; + +/* tables generation routine */ + +#define ROTR8(x) ( ( ( x << 24 ) & 0xFFFFFFFF ) | \ + ( ( x & 0xFFFFFFFF ) >> 8 ) ) + +#define XTIME(x) ( ( x << 1 ) ^ ( ( x & 0x80 ) ? 0x1B : 0x00 ) ) +#define MUL(x,y) ( ( x && y ) ? aes_gen_tables_pow[(aes_gen_tables_log[x] + aes_gen_tables_log[y]) % 255] : 0 ) + +void aes_gen_tables( void ) +{ + int i; + uint8 x, y; + uint8 aes_gen_tables_pow[256]; + uint8 aes_gen_tables_log[256]; + + /* compute pow and log tables over GF(2^8) */ + + for( i = 0, x = 1; i < 256; i++, x ^= XTIME( x ) ) + { + aes_gen_tables_pow[i] = x; + aes_gen_tables_log[x] = i; + } + + /* calculate the round constants */ + + for( i = 0, x = 1; i < 10; i++, x = XTIME( x ) ) + { + RCON[i] = (uint32) x << 24; + } + + /* generate the forward and reverse S-boxes */ + + FSb[0x00] = 0x63; + RSb[0x63] = 0x00; + + for( i = 1; i < 256; i++ ) + { + x = aes_gen_tables_pow[255 - aes_gen_tables_log[i]]; + + y = x; y = ( y << 1 ) | ( y >> 7 ); + x ^= y; y = ( y << 1 ) | ( y >> 7 ); + x ^= y; y = ( y << 1 ) | ( y >> 7 ); + x ^= y; y = ( y << 1 ) | ( y >> 7 ); + x ^= y ^ 0x63; + + FSb[i] = x; + RSb[x] = i; + } + + /* generate the forward and reverse tables */ + + for( i = 0; i < 256; i++ ) + { + x = FSb[i]; y = XTIME( x ); + + FT0[i] = (uint32) ( x ^ y ) ^ + ( (uint32) x << 8 ) ^ + ( (uint32) x << 16 ) ^ + ( (uint32) y << 24 ); + + FT0[i] &= 0xFFFFFFFF; + + FT1[i] = ROTR8( FT0[i] ); + FT2[i] = ROTR8( FT1[i] ); + FT3[i] = ROTR8( FT2[i] ); + + y = RSb[i]; + + RT0[i] = ( (uint32) MUL( 0x0B, y ) ) ^ + ( (uint32) MUL( 0x0D, y ) << 8 ) ^ + ( (uint32) MUL( 0x09, y ) << 16 ) ^ + ( (uint32) MUL( 0x0E, y ) << 24 ); + + RT0[i] &= 0xFFFFFFFF; + + RT1[i] = ROTR8( RT0[i] ); + RT2[i] = ROTR8( RT1[i] ); + RT3[i] = ROTR8( RT2[i] ); + } +} + +#else + +/* forward S-box */ + +static const uint32 FSb[256] = +{ + 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, + 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76, + 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, + 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0, + 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, + 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, + 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, + 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75, + 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, + 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84, + 0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, + 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF, + 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, + 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8, + 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, + 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, + 0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, + 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73, + 0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, + 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB, + 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, + 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, + 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, + 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, + 0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, + 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A, + 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, + 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E, + 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, + 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, + 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, + 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16 +}; + +/* forward tables */ + +#define FT \ +\ + V(C6,63,63,A5), V(F8,7C,7C,84), V(EE,77,77,99), V(F6,7B,7B,8D), \ + V(FF,F2,F2,0D), V(D6,6B,6B,BD), V(DE,6F,6F,B1), V(91,C5,C5,54), \ + V(60,30,30,50), V(02,01,01,03), V(CE,67,67,A9), V(56,2B,2B,7D), \ + V(E7,FE,FE,19), V(B5,D7,D7,62), V(4D,AB,AB,E6), V(EC,76,76,9A), \ + V(8F,CA,CA,45), V(1F,82,82,9D), V(89,C9,C9,40), V(FA,7D,7D,87), \ + V(EF,FA,FA,15), V(B2,59,59,EB), V(8E,47,47,C9), V(FB,F0,F0,0B), \ + V(41,AD,AD,EC), V(B3,D4,D4,67), V(5F,A2,A2,FD), V(45,AF,AF,EA), \ + V(23,9C,9C,BF), V(53,A4,A4,F7), V(E4,72,72,96), V(9B,C0,C0,5B), \ + V(75,B7,B7,C2), V(E1,FD,FD,1C), V(3D,93,93,AE), V(4C,26,26,6A), \ + V(6C,36,36,5A), V(7E,3F,3F,41), V(F5,F7,F7,02), V(83,CC,CC,4F), \ + V(68,34,34,5C), V(51,A5,A5,F4), V(D1,E5,E5,34), V(F9,F1,F1,08), \ + V(E2,71,71,93), V(AB,D8,D8,73), V(62,31,31,53), V(2A,15,15,3F), \ + V(08,04,04,0C), V(95,C7,C7,52), V(46,23,23,65), V(9D,C3,C3,5E), \ + V(30,18,18,28), V(37,96,96,A1), V(0A,05,05,0F), V(2F,9A,9A,B5), \ + V(0E,07,07,09), V(24,12,12,36), V(1B,80,80,9B), V(DF,E2,E2,3D), \ + V(CD,EB,EB,26), V(4E,27,27,69), V(7F,B2,B2,CD), V(EA,75,75,9F), \ + V(12,09,09,1B), V(1D,83,83,9E), V(58,2C,2C,74), V(34,1A,1A,2E), \ + V(36,1B,1B,2D), V(DC,6E,6E,B2), V(B4,5A,5A,EE), V(5B,A0,A0,FB), \ + V(A4,52,52,F6), V(76,3B,3B,4D), V(B7,D6,D6,61), V(7D,B3,B3,CE), \ + V(52,29,29,7B), V(DD,E3,E3,3E), V(5E,2F,2F,71), V(13,84,84,97), \ + V(A6,53,53,F5), V(B9,D1,D1,68), V(00,00,00,00), V(C1,ED,ED,2C), \ + V(40,20,20,60), V(E3,FC,FC,1F), V(79,B1,B1,C8), V(B6,5B,5B,ED), \ + V(D4,6A,6A,BE), V(8D,CB,CB,46), V(67,BE,BE,D9), V(72,39,39,4B), \ + V(94,4A,4A,DE), V(98,4C,4C,D4), V(B0,58,58,E8), V(85,CF,CF,4A), \ + V(BB,D0,D0,6B), V(C5,EF,EF,2A), V(4F,AA,AA,E5), V(ED,FB,FB,16), \ + V(86,43,43,C5), V(9A,4D,4D,D7), V(66,33,33,55), V(11,85,85,94), \ + V(8A,45,45,CF), V(E9,F9,F9,10), V(04,02,02,06), V(FE,7F,7F,81), \ + V(A0,50,50,F0), V(78,3C,3C,44), V(25,9F,9F,BA), V(4B,A8,A8,E3), \ + V(A2,51,51,F3), V(5D,A3,A3,FE), V(80,40,40,C0), V(05,8F,8F,8A), \ + V(3F,92,92,AD), V(21,9D,9D,BC), V(70,38,38,48), V(F1,F5,F5,04), \ + V(63,BC,BC,DF), V(77,B6,B6,C1), V(AF,DA,DA,75), V(42,21,21,63), \ + V(20,10,10,30), V(E5,FF,FF,1A), V(FD,F3,F3,0E), V(BF,D2,D2,6D), \ + V(81,CD,CD,4C), V(18,0C,0C,14), V(26,13,13,35), V(C3,EC,EC,2F), \ + V(BE,5F,5F,E1), V(35,97,97,A2), V(88,44,44,CC), V(2E,17,17,39), \ + V(93,C4,C4,57), V(55,A7,A7,F2), V(FC,7E,7E,82), V(7A,3D,3D,47), \ + V(C8,64,64,AC), V(BA,5D,5D,E7), V(32,19,19,2B), V(E6,73,73,95), \ + V(C0,60,60,A0), V(19,81,81,98), V(9E,4F,4F,D1), V(A3,DC,DC,7F), \ + V(44,22,22,66), V(54,2A,2A,7E), V(3B,90,90,AB), V(0B,88,88,83), \ + V(8C,46,46,CA), V(C7,EE,EE,29), V(6B,B8,B8,D3), V(28,14,14,3C), \ + V(A7,DE,DE,79), V(BC,5E,5E,E2), V(16,0B,0B,1D), V(AD,DB,DB,76), \ + V(DB,E0,E0,3B), V(64,32,32,56), V(74,3A,3A,4E), V(14,0A,0A,1E), \ + V(92,49,49,DB), V(0C,06,06,0A), V(48,24,24,6C), V(B8,5C,5C,E4), \ + V(9F,C2,C2,5D), V(BD,D3,D3,6E), V(43,AC,AC,EF), V(C4,62,62,A6), \ + V(39,91,91,A8), V(31,95,95,A4), V(D3,E4,E4,37), V(F2,79,79,8B), \ + V(D5,E7,E7,32), V(8B,C8,C8,43), V(6E,37,37,59), V(DA,6D,6D,B7), \ + V(01,8D,8D,8C), V(B1,D5,D5,64), V(9C,4E,4E,D2), V(49,A9,A9,E0), \ + V(D8,6C,6C,B4), V(AC,56,56,FA), V(F3,F4,F4,07), V(CF,EA,EA,25), \ + V(CA,65,65,AF), V(F4,7A,7A,8E), V(47,AE,AE,E9), V(10,08,08,18), \ + V(6F,BA,BA,D5), V(F0,78,78,88), V(4A,25,25,6F), V(5C,2E,2E,72), \ + V(38,1C,1C,24), V(57,A6,A6,F1), V(73,B4,B4,C7), V(97,C6,C6,51), \ + V(CB,E8,E8,23), V(A1,DD,DD,7C), V(E8,74,74,9C), V(3E,1F,1F,21), \ + V(96,4B,4B,DD), V(61,BD,BD,DC), V(0D,8B,8B,86), V(0F,8A,8A,85), \ + V(E0,70,70,90), V(7C,3E,3E,42), V(71,B5,B5,C4), V(CC,66,66,AA), \ + V(90,48,48,D8), V(06,03,03,05), V(F7,F6,F6,01), V(1C,0E,0E,12), \ + V(C2,61,61,A3), V(6A,35,35,5F), V(AE,57,57,F9), V(69,B9,B9,D0), \ + V(17,86,86,91), V(99,C1,C1,58), V(3A,1D,1D,27), V(27,9E,9E,B9), \ + V(D9,E1,E1,38), V(EB,F8,F8,13), V(2B,98,98,B3), V(22,11,11,33), \ + V(D2,69,69,BB), V(A9,D9,D9,70), V(07,8E,8E,89), V(33,94,94,A7), \ + V(2D,9B,9B,B6), V(3C,1E,1E,22), V(15,87,87,92), V(C9,E9,E9,20), \ + V(87,CE,CE,49), V(AA,55,55,FF), V(50,28,28,78), V(A5,DF,DF,7A), \ + V(03,8C,8C,8F), V(59,A1,A1,F8), V(09,89,89,80), V(1A,0D,0D,17), \ + V(65,BF,BF,DA), V(D7,E6,E6,31), V(84,42,42,C6), V(D0,68,68,B8), \ + V(82,41,41,C3), V(29,99,99,B0), V(5A,2D,2D,77), V(1E,0F,0F,11), \ + V(7B,B0,B0,CB), V(A8,54,54,FC), V(6D,BB,BB,D6), V(2C,16,16,3A) + +#define V(a,b,c,d) 0x##a##b##c##d +static const uint32 FT0[256] = { FT }; +#undef V + +#define V(a,b,c,d) 0x##d##a##b##c +static const uint32 FT1[256] = { FT }; +#undef V + +#define V(a,b,c,d) 0x##c##d##a##b +static const uint32 FT2[256] = { FT }; +#undef V + +#define V(a,b,c,d) 0x##b##c##d##a +static const uint32 FT3[256] = { FT }; +#undef V + +#undef FT + +/* reverse S-box */ + +static const uint32 RSb[256] = +{ + 0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, + 0xBF, 0x40, 0xA3, 0x9E, 0x81, 0xF3, 0xD7, 0xFB, + 0x7C, 0xE3, 0x39, 0x82, 0x9B, 0x2F, 0xFF, 0x87, + 0x34, 0x8E, 0x43, 0x44, 0xC4, 0xDE, 0xE9, 0xCB, + 0x54, 0x7B, 0x94, 0x32, 0xA6, 0xC2, 0x23, 0x3D, + 0xEE, 0x4C, 0x95, 0x0B, 0x42, 0xFA, 0xC3, 0x4E, + 0x08, 0x2E, 0xA1, 0x66, 0x28, 0xD9, 0x24, 0xB2, + 0x76, 0x5B, 0xA2, 0x49, 0x6D, 0x8B, 0xD1, 0x25, + 0x72, 0xF8, 0xF6, 0x64, 0x86, 0x68, 0x98, 0x16, + 0xD4, 0xA4, 0x5C, 0xCC, 0x5D, 0x65, 0xB6, 0x92, + 0x6C, 0x70, 0x48, 0x50, 0xFD, 0xED, 0xB9, 0xDA, + 0x5E, 0x15, 0x46, 0x57, 0xA7, 0x8D, 0x9D, 0x84, + 0x90, 0xD8, 0xAB, 0x00, 0x8C, 0xBC, 0xD3, 0x0A, + 0xF7, 0xE4, 0x58, 0x05, 0xB8, 0xB3, 0x45, 0x06, + 0xD0, 0x2C, 0x1E, 0x8F, 0xCA, 0x3F, 0x0F, 0x02, + 0xC1, 0xAF, 0xBD, 0x03, 0x01, 0x13, 0x8A, 0x6B, + 0x3A, 0x91, 0x11, 0x41, 0x4F, 0x67, 0xDC, 0xEA, + 0x97, 0xF2, 0xCF, 0xCE, 0xF0, 0xB4, 0xE6, 0x73, + 0x96, 0xAC, 0x74, 0x22, 0xE7, 0xAD, 0x35, 0x85, + 0xE2, 0xF9, 0x37, 0xE8, 0x1C, 0x75, 0xDF, 0x6E, + 0x47, 0xF1, 0x1A, 0x71, 0x1D, 0x29, 0xC5, 0x89, + 0x6F, 0xB7, 0x62, 0x0E, 0xAA, 0x18, 0xBE, 0x1B, + 0xFC, 0x56, 0x3E, 0x4B, 0xC6, 0xD2, 0x79, 0x20, + 0x9A, 0xDB, 0xC0, 0xFE, 0x78, 0xCD, 0x5A, 0xF4, + 0x1F, 0xDD, 0xA8, 0x33, 0x88, 0x07, 0xC7, 0x31, + 0xB1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xEC, 0x5F, + 0x60, 0x51, 0x7F, 0xA9, 0x19, 0xB5, 0x4A, 0x0D, + 0x2D, 0xE5, 0x7A, 0x9F, 0x93, 0xC9, 0x9C, 0xEF, + 0xA0, 0xE0, 0x3B, 0x4D, 0xAE, 0x2A, 0xF5, 0xB0, + 0xC8, 0xEB, 0xBB, 0x3C, 0x83, 0x53, 0x99, 0x61, + 0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, + 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D +}; + +/* reverse tables */ + +#define RT \ +\ + V(51,F4,A7,50), V(7E,41,65,53), V(1A,17,A4,C3), V(3A,27,5E,96), \ + V(3B,AB,6B,CB), V(1F,9D,45,F1), V(AC,FA,58,AB), V(4B,E3,03,93), \ + V(20,30,FA,55), V(AD,76,6D,F6), V(88,CC,76,91), V(F5,02,4C,25), \ + V(4F,E5,D7,FC), V(C5,2A,CB,D7), V(26,35,44,80), V(B5,62,A3,8F), \ + V(DE,B1,5A,49), V(25,BA,1B,67), V(45,EA,0E,98), V(5D,FE,C0,E1), \ + V(C3,2F,75,02), V(81,4C,F0,12), V(8D,46,97,A3), V(6B,D3,F9,C6), \ + V(03,8F,5F,E7), V(15,92,9C,95), V(BF,6D,7A,EB), V(95,52,59,DA), \ + V(D4,BE,83,2D), V(58,74,21,D3), V(49,E0,69,29), V(8E,C9,C8,44), \ + V(75,C2,89,6A), V(F4,8E,79,78), V(99,58,3E,6B), V(27,B9,71,DD), \ + V(BE,E1,4F,B6), V(F0,88,AD,17), V(C9,20,AC,66), V(7D,CE,3A,B4), \ + V(63,DF,4A,18), V(E5,1A,31,82), V(97,51,33,60), V(62,53,7F,45), \ + V(B1,64,77,E0), V(BB,6B,AE,84), V(FE,81,A0,1C), V(F9,08,2B,94), \ + V(70,48,68,58), V(8F,45,FD,19), V(94,DE,6C,87), V(52,7B,F8,B7), \ + V(AB,73,D3,23), V(72,4B,02,E2), V(E3,1F,8F,57), V(66,55,AB,2A), \ + V(B2,EB,28,07), V(2F,B5,C2,03), V(86,C5,7B,9A), V(D3,37,08,A5), \ + V(30,28,87,F2), V(23,BF,A5,B2), V(02,03,6A,BA), V(ED,16,82,5C), \ + V(8A,CF,1C,2B), V(A7,79,B4,92), V(F3,07,F2,F0), V(4E,69,E2,A1), \ + V(65,DA,F4,CD), V(06,05,BE,D5), V(D1,34,62,1F), V(C4,A6,FE,8A), \ + V(34,2E,53,9D), V(A2,F3,55,A0), V(05,8A,E1,32), V(A4,F6,EB,75), \ + V(0B,83,EC,39), V(40,60,EF,AA), V(5E,71,9F,06), V(BD,6E,10,51), \ + V(3E,21,8A,F9), V(96,DD,06,3D), V(DD,3E,05,AE), V(4D,E6,BD,46), \ + V(91,54,8D,B5), V(71,C4,5D,05), V(04,06,D4,6F), V(60,50,15,FF), \ + V(19,98,FB,24), V(D6,BD,E9,97), V(89,40,43,CC), V(67,D9,9E,77), \ + V(B0,E8,42,BD), V(07,89,8B,88), V(E7,19,5B,38), V(79,C8,EE,DB), \ + V(A1,7C,0A,47), V(7C,42,0F,E9), V(F8,84,1E,C9), V(00,00,00,00), \ + V(09,80,86,83), V(32,2B,ED,48), V(1E,11,70,AC), V(6C,5A,72,4E), \ + V(FD,0E,FF,FB), V(0F,85,38,56), V(3D,AE,D5,1E), V(36,2D,39,27), \ + V(0A,0F,D9,64), V(68,5C,A6,21), V(9B,5B,54,D1), V(24,36,2E,3A), \ + V(0C,0A,67,B1), V(93,57,E7,0F), V(B4,EE,96,D2), V(1B,9B,91,9E), \ + V(80,C0,C5,4F), V(61,DC,20,A2), V(5A,77,4B,69), V(1C,12,1A,16), \ + V(E2,93,BA,0A), V(C0,A0,2A,E5), V(3C,22,E0,43), V(12,1B,17,1D), \ + V(0E,09,0D,0B), V(F2,8B,C7,AD), V(2D,B6,A8,B9), V(14,1E,A9,C8), \ + V(57,F1,19,85), V(AF,75,07,4C), V(EE,99,DD,BB), V(A3,7F,60,FD), \ + V(F7,01,26,9F), V(5C,72,F5,BC), V(44,66,3B,C5), V(5B,FB,7E,34), \ + V(8B,43,29,76), V(CB,23,C6,DC), V(B6,ED,FC,68), V(B8,E4,F1,63), \ + V(D7,31,DC,CA), V(42,63,85,10), V(13,97,22,40), V(84,C6,11,20), \ + V(85,4A,24,7D), V(D2,BB,3D,F8), V(AE,F9,32,11), V(C7,29,A1,6D), \ + V(1D,9E,2F,4B), V(DC,B2,30,F3), V(0D,86,52,EC), V(77,C1,E3,D0), \ + V(2B,B3,16,6C), V(A9,70,B9,99), V(11,94,48,FA), V(47,E9,64,22), \ + V(A8,FC,8C,C4), V(A0,F0,3F,1A), V(56,7D,2C,D8), V(22,33,90,EF), \ + V(87,49,4E,C7), V(D9,38,D1,C1), V(8C,CA,A2,FE), V(98,D4,0B,36), \ + V(A6,F5,81,CF), V(A5,7A,DE,28), V(DA,B7,8E,26), V(3F,AD,BF,A4), \ + V(2C,3A,9D,E4), V(50,78,92,0D), V(6A,5F,CC,9B), V(54,7E,46,62), \ + V(F6,8D,13,C2), V(90,D8,B8,E8), V(2E,39,F7,5E), V(82,C3,AF,F5), \ + V(9F,5D,80,BE), V(69,D0,93,7C), V(6F,D5,2D,A9), V(CF,25,12,B3), \ + V(C8,AC,99,3B), V(10,18,7D,A7), V(E8,9C,63,6E), V(DB,3B,BB,7B), \ + V(CD,26,78,09), V(6E,59,18,F4), V(EC,9A,B7,01), V(83,4F,9A,A8), \ + V(E6,95,6E,65), V(AA,FF,E6,7E), V(21,BC,CF,08), V(EF,15,E8,E6), \ + V(BA,E7,9B,D9), V(4A,6F,36,CE), V(EA,9F,09,D4), V(29,B0,7C,D6), \ + V(31,A4,B2,AF), V(2A,3F,23,31), V(C6,A5,94,30), V(35,A2,66,C0), \ + V(74,4E,BC,37), V(FC,82,CA,A6), V(E0,90,D0,B0), V(33,A7,D8,15), \ + V(F1,04,98,4A), V(41,EC,DA,F7), V(7F,CD,50,0E), V(17,91,F6,2F), \ + V(76,4D,D6,8D), V(43,EF,B0,4D), V(CC,AA,4D,54), V(E4,96,04,DF), \ + V(9E,D1,B5,E3), V(4C,6A,88,1B), V(C1,2C,1F,B8), V(46,65,51,7F), \ + V(9D,5E,EA,04), V(01,8C,35,5D), V(FA,87,74,73), V(FB,0B,41,2E), \ + V(B3,67,1D,5A), V(92,DB,D2,52), V(E9,10,56,33), V(6D,D6,47,13), \ + V(9A,D7,61,8C), V(37,A1,0C,7A), V(59,F8,14,8E), V(EB,13,3C,89), \ + V(CE,A9,27,EE), V(B7,61,C9,35), V(E1,1C,E5,ED), V(7A,47,B1,3C), \ + V(9C,D2,DF,59), V(55,F2,73,3F), V(18,14,CE,79), V(73,C7,37,BF), \ + V(53,F7,CD,EA), V(5F,FD,AA,5B), V(DF,3D,6F,14), V(78,44,DB,86), \ + V(CA,AF,F3,81), V(B9,68,C4,3E), V(38,24,34,2C), V(C2,A3,40,5F), \ + V(16,1D,C3,72), V(BC,E2,25,0C), V(28,3C,49,8B), V(FF,0D,95,41), \ + V(39,A8,01,71), V(08,0C,B3,DE), V(D8,B4,E4,9C), V(64,56,C1,90), \ + V(7B,CB,84,61), V(D5,32,B6,70), V(48,6C,5C,74), V(D0,B8,57,42) + +#define V(a,b,c,d) 0x##a##b##c##d +static const uint32 RT0[256] = { RT }; +#undef V + +#define V(a,b,c,d) 0x##d##a##b##c +static const uint32 RT1[256] = { RT }; +#undef V + +#define V(a,b,c,d) 0x##c##d##a##b +static const uint32 RT2[256] = { RT }; +#undef V + +#define V(a,b,c,d) 0x##b##c##d##a +static const uint32 RT3[256] = { RT }; +#undef V + +#undef RT + +/* round constants */ + +static const uint32 RCON[10] = +{ + 0x01000000, 0x02000000, 0x04000000, 0x08000000, + 0x10000000, 0x20000000, 0x40000000, 0x80000000, + 0x1B000000, 0x36000000 +}; + +int do_init = 0; + +void aes_gen_tables( void ) +{ +} + +#endif + +/* platform-independant 32-bit integer manipulation macros */ + +#define GET_UINT32(n,b,i) \ +{ \ + (n) = ( (uint32) (b)[(i) ] << 24 ) \ + | ( (uint32) (b)[(i) + 1] << 16 ) \ + | ( (uint32) (b)[(i) + 2] << 8 ) \ + | ( (uint32) (b)[(i) + 3] ); \ +} + +#define PUT_UINT32(n,b,i) \ +{ \ + (b)[(i) ] = (uint8) ( (n) >> 24 ); \ + (b)[(i) + 1] = (uint8) ( (n) >> 16 ); \ + (b)[(i) + 2] = (uint8) ( (n) >> 8 ); \ + (b)[(i) + 3] = (uint8) ( (n) ); \ +} + +/* decryption key schedule tables */ + +int KT_init = 1; + +uint32 KT0[256]; +uint32 KT1[256]; +uint32 KT2[256]; +uint32 KT3[256]; + +/* AES key scheduling routine */ + +int aes_set_key( aes_context *ctx, uint8 *key, int nbits ) +{ + int i; + uint32 *RK, *SK; + + if( do_init ) + { + aes_gen_tables(); + + do_init = 0; + } + + switch( nbits ) + { + case 128: ctx->nr = 10; break; + case 192: ctx->nr = 12; break; + case 256: ctx->nr = 14; break; + default : return( 1 ); + } + + RK = ctx->erk; + + for( i = 0; i < (nbits >> 5); i++ ) + { + GET_UINT32( RK[i], key, i * 4 ); + } + + /* setup encryption round keys */ + + switch( nbits ) + { + case 128: + + for( i = 0; i < 10; i++, RK += 4 ) + { + RK[4] = RK[0] ^ RCON[i] ^ + ( FSb[ (uint8) ( RK[3] >> 16 ) ] << 24 ) ^ + ( FSb[ (uint8) ( RK[3] >> 8 ) ] << 16 ) ^ + ( FSb[ (uint8) ( RK[3] ) ] << 8 ) ^ + ( FSb[ (uint8) ( RK[3] >> 24 ) ] ); + + RK[5] = RK[1] ^ RK[4]; + RK[6] = RK[2] ^ RK[5]; + RK[7] = RK[3] ^ RK[6]; + } + break; + + case 192: + + for( i = 0; i < 8; i++, RK += 6 ) + { + RK[6] = RK[0] ^ RCON[i] ^ + ( FSb[ (uint8) ( RK[5] >> 16 ) ] << 24 ) ^ + ( FSb[ (uint8) ( RK[5] >> 8 ) ] << 16 ) ^ + ( FSb[ (uint8) ( RK[5] ) ] << 8 ) ^ + ( FSb[ (uint8) ( RK[5] >> 24 ) ] ); + + RK[7] = RK[1] ^ RK[6]; + RK[8] = RK[2] ^ RK[7]; + RK[9] = RK[3] ^ RK[8]; + RK[10] = RK[4] ^ RK[9]; + RK[11] = RK[5] ^ RK[10]; + } + break; + + case 256: + + for( i = 0; i < 7; i++, RK += 8 ) + { + RK[8] = RK[0] ^ RCON[i] ^ + ( FSb[ (uint8) ( RK[7] >> 16 ) ] << 24 ) ^ + ( FSb[ (uint8) ( RK[7] >> 8 ) ] << 16 ) ^ + ( FSb[ (uint8) ( RK[7] ) ] << 8 ) ^ + ( FSb[ (uint8) ( RK[7] >> 24 ) ] ); + + RK[9] = RK[1] ^ RK[8]; + RK[10] = RK[2] ^ RK[9]; + RK[11] = RK[3] ^ RK[10]; + + RK[12] = RK[4] ^ + ( FSb[ (uint8) ( RK[11] >> 24 ) ] << 24 ) ^ + ( FSb[ (uint8) ( RK[11] >> 16 ) ] << 16 ) ^ + ( FSb[ (uint8) ( RK[11] >> 8 ) ] << 8 ) ^ + ( FSb[ (uint8) ( RK[11] ) ] ); + + RK[13] = RK[5] ^ RK[12]; + RK[14] = RK[6] ^ RK[13]; + RK[15] = RK[7] ^ RK[14]; + } + break; + } + + /* setup decryption round keys */ + + if( KT_init ) + { + for( i = 0; i < 256; i++ ) + { + KT0[i] = RT0[ FSb[i] ]; + KT1[i] = RT1[ FSb[i] ]; + KT2[i] = RT2[ FSb[i] ]; + KT3[i] = RT3[ FSb[i] ]; + } + + KT_init = 0; + } + + SK = ctx->drk; + + *SK++ = *RK++; + *SK++ = *RK++; + *SK++ = *RK++; + *SK++ = *RK++; + + for( i = 1; i < ctx->nr; i++ ) + { + RK -= 8; + + *SK++ = KT0[ (uint8) ( *RK >> 24 ) ] ^ + KT1[ (uint8) ( *RK >> 16 ) ] ^ + KT2[ (uint8) ( *RK >> 8 ) ] ^ + KT3[ (uint8) ( *RK ) ]; RK++; + + *SK++ = KT0[ (uint8) ( *RK >> 24 ) ] ^ + KT1[ (uint8) ( *RK >> 16 ) ] ^ + KT2[ (uint8) ( *RK >> 8 ) ] ^ + KT3[ (uint8) ( *RK ) ]; RK++; + + *SK++ = KT0[ (uint8) ( *RK >> 24 ) ] ^ + KT1[ (uint8) ( *RK >> 16 ) ] ^ + KT2[ (uint8) ( *RK >> 8 ) ] ^ + KT3[ (uint8) ( *RK ) ]; RK++; + + *SK++ = KT0[ (uint8) ( *RK >> 24 ) ] ^ + KT1[ (uint8) ( *RK >> 16 ) ] ^ + KT2[ (uint8) ( *RK >> 8 ) ] ^ + KT3[ (uint8) ( *RK ) ]; RK++; + } + + RK -= 8; + + *SK++ = *RK++; + *SK++ = *RK++; + *SK++ = *RK++; + *SK++ = *RK++; + + return( 0 ); +} + +/* AES 128-bit block encryption routine */ + +void aes_encrypt( aes_context *ctx, uint8 input[16], uint8 output[16] ) +{ + uint32 *RK, X0, X1, X2, X3, Y0, Y1, Y2, Y3; + + RK = ctx->erk; + + GET_UINT32( X0, input, 0 ); X0 ^= RK[0]; + GET_UINT32( X1, input, 4 ); X1 ^= RK[1]; + GET_UINT32( X2, input, 8 ); X2 ^= RK[2]; + GET_UINT32( X3, input, 12 ); X3 ^= RK[3]; + +#define AES_FROUND(X0,X1,X2,X3,Y0,Y1,Y2,Y3) \ +{ \ + RK += 4; \ + \ + X0 = RK[0] ^ FT0[ (uint8) ( Y0 >> 24 ) ] ^ \ + FT1[ (uint8) ( Y1 >> 16 ) ] ^ \ + FT2[ (uint8) ( Y2 >> 8 ) ] ^ \ + FT3[ (uint8) ( Y3 ) ]; \ + \ + X1 = RK[1] ^ FT0[ (uint8) ( Y1 >> 24 ) ] ^ \ + FT1[ (uint8) ( Y2 >> 16 ) ] ^ \ + FT2[ (uint8) ( Y3 >> 8 ) ] ^ \ + FT3[ (uint8) ( Y0 ) ]; \ + \ + X2 = RK[2] ^ FT0[ (uint8) ( Y2 >> 24 ) ] ^ \ + FT1[ (uint8) ( Y3 >> 16 ) ] ^ \ + FT2[ (uint8) ( Y0 >> 8 ) ] ^ \ + FT3[ (uint8) ( Y1 ) ]; \ + \ + X3 = RK[3] ^ FT0[ (uint8) ( Y3 >> 24 ) ] ^ \ + FT1[ (uint8) ( Y0 >> 16 ) ] ^ \ + FT2[ (uint8) ( Y1 >> 8 ) ] ^ \ + FT3[ (uint8) ( Y2 ) ]; \ +} + + AES_FROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 1 */ + AES_FROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 2 */ + AES_FROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 3 */ + AES_FROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 4 */ + AES_FROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 5 */ + AES_FROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 6 */ + AES_FROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 7 */ + AES_FROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 8 */ + AES_FROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 9 */ + + if( ctx->nr > 10 ) + { + AES_FROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 10 */ + AES_FROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 11 */ + } + + if( ctx->nr > 12 ) + { + AES_FROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 12 */ + AES_FROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 13 */ + } + + /* last round */ + + RK += 4; + + X0 = RK[0] ^ ( FSb[ (uint8) ( Y0 >> 24 ) ] << 24 ) ^ + ( FSb[ (uint8) ( Y1 >> 16 ) ] << 16 ) ^ + ( FSb[ (uint8) ( Y2 >> 8 ) ] << 8 ) ^ + ( FSb[ (uint8) ( Y3 ) ] ); + + X1 = RK[1] ^ ( FSb[ (uint8) ( Y1 >> 24 ) ] << 24 ) ^ + ( FSb[ (uint8) ( Y2 >> 16 ) ] << 16 ) ^ + ( FSb[ (uint8) ( Y3 >> 8 ) ] << 8 ) ^ + ( FSb[ (uint8) ( Y0 ) ] ); + + X2 = RK[2] ^ ( FSb[ (uint8) ( Y2 >> 24 ) ] << 24 ) ^ + ( FSb[ (uint8) ( Y3 >> 16 ) ] << 16 ) ^ + ( FSb[ (uint8) ( Y0 >> 8 ) ] << 8 ) ^ + ( FSb[ (uint8) ( Y1 ) ] ); + + X3 = RK[3] ^ ( FSb[ (uint8) ( Y3 >> 24 ) ] << 24 ) ^ + ( FSb[ (uint8) ( Y0 >> 16 ) ] << 16 ) ^ + ( FSb[ (uint8) ( Y1 >> 8 ) ] << 8 ) ^ + ( FSb[ (uint8) ( Y2 ) ] ); + + PUT_UINT32( X0, output, 0 ); + PUT_UINT32( X1, output, 4 ); + PUT_UINT32( X2, output, 8 ); + PUT_UINT32( X3, output, 12 ); +} + +/* AES 128-bit block decryption routine */ + +void aes_decrypt( aes_context *ctx, uint8 input[16], uint8 output[16] ) +{ + uint32 *RK, X0, X1, X2, X3, Y0, Y1, Y2, Y3; + + RK = ctx->drk; + + GET_UINT32( X0, input, 0 ); X0 ^= RK[0]; + GET_UINT32( X1, input, 4 ); X1 ^= RK[1]; + GET_UINT32( X2, input, 8 ); X2 ^= RK[2]; + GET_UINT32( X3, input, 12 ); X3 ^= RK[3]; + +#define AES_RROUND(X0,X1,X2,X3,Y0,Y1,Y2,Y3) \ +{ \ + RK += 4; \ + \ + X0 = RK[0] ^ RT0[ (uint8) ( Y0 >> 24 ) ] ^ \ + RT1[ (uint8) ( Y3 >> 16 ) ] ^ \ + RT2[ (uint8) ( Y2 >> 8 ) ] ^ \ + RT3[ (uint8) ( Y1 ) ]; \ + \ + X1 = RK[1] ^ RT0[ (uint8) ( Y1 >> 24 ) ] ^ \ + RT1[ (uint8) ( Y0 >> 16 ) ] ^ \ + RT2[ (uint8) ( Y3 >> 8 ) ] ^ \ + RT3[ (uint8) ( Y2 ) ]; \ + \ + X2 = RK[2] ^ RT0[ (uint8) ( Y2 >> 24 ) ] ^ \ + RT1[ (uint8) ( Y1 >> 16 ) ] ^ \ + RT2[ (uint8) ( Y0 >> 8 ) ] ^ \ + RT3[ (uint8) ( Y3 ) ]; \ + \ + X3 = RK[3] ^ RT0[ (uint8) ( Y3 >> 24 ) ] ^ \ + RT1[ (uint8) ( Y2 >> 16 ) ] ^ \ + RT2[ (uint8) ( Y1 >> 8 ) ] ^ \ + RT3[ (uint8) ( Y0 ) ]; \ +} + + AES_RROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 1 */ + AES_RROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 2 */ + AES_RROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 3 */ + AES_RROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 4 */ + AES_RROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 5 */ + AES_RROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 6 */ + AES_RROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 7 */ + AES_RROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 8 */ + AES_RROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 9 */ + + if( ctx->nr > 10 ) + { + AES_RROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 10 */ + AES_RROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 11 */ + } + + if( ctx->nr > 12 ) + { + AES_RROUND( X0, X1, X2, X3, Y0, Y1, Y2, Y3 ); /* round 12 */ + AES_RROUND( Y0, Y1, Y2, Y3, X0, X1, X2, X3 ); /* round 13 */ + } + + /* last round */ + + RK += 4; + + X0 = RK[0] ^ ( RSb[ (uint8) ( Y0 >> 24 ) ] << 24 ) ^ + ( RSb[ (uint8) ( Y3 >> 16 ) ] << 16 ) ^ + ( RSb[ (uint8) ( Y2 >> 8 ) ] << 8 ) ^ + ( RSb[ (uint8) ( Y1 ) ] ); + + X1 = RK[1] ^ ( RSb[ (uint8) ( Y1 >> 24 ) ] << 24 ) ^ + ( RSb[ (uint8) ( Y0 >> 16 ) ] << 16 ) ^ + ( RSb[ (uint8) ( Y3 >> 8 ) ] << 8 ) ^ + ( RSb[ (uint8) ( Y2 ) ] ); + + X2 = RK[2] ^ ( RSb[ (uint8) ( Y2 >> 24 ) ] << 24 ) ^ + ( RSb[ (uint8) ( Y1 >> 16 ) ] << 16 ) ^ + ( RSb[ (uint8) ( Y0 >> 8 ) ] << 8 ) ^ + ( RSb[ (uint8) ( Y3 ) ] ); + + X3 = RK[3] ^ ( RSb[ (uint8) ( Y3 >> 24 ) ] << 24 ) ^ + ( RSb[ (uint8) ( Y2 >> 16 ) ] << 16 ) ^ + ( RSb[ (uint8) ( Y1 >> 8 ) ] << 8 ) ^ + ( RSb[ (uint8) ( Y0 ) ] ); + + PUT_UINT32( X0, output, 0 ); + PUT_UINT32( X1, output, 4 ); + PUT_UINT32( X2, output, 8 ); + PUT_UINT32( X3, output, 12 ); +} + +#ifdef TEST + +#include +#include + +/* + * Rijndael Monte Carlo Test: ECB mode + * source: NIST - rijndael-vals.zip + */ + +static unsigned char AES_enc_test[3][16] = +{ + { 0xA0, 0x43, 0x77, 0xAB, 0xE2, 0x59, 0xB0, 0xD0, + 0xB5, 0xBA, 0x2D, 0x40, 0xA5, 0x01, 0x97, 0x1B }, + { 0x4E, 0x46, 0xF8, 0xC5, 0x09, 0x2B, 0x29, 0xE2, + 0x9A, 0x97, 0x1A, 0x0C, 0xD1, 0xF6, 0x10, 0xFB }, + { 0x1F, 0x67, 0x63, 0xDF, 0x80, 0x7A, 0x7E, 0x70, + 0x96, 0x0D, 0x4C, 0xD3, 0x11, 0x8E, 0x60, 0x1A } +}; + +static unsigned char AES_dec_test[3][16] = +{ + { 0xF5, 0xBF, 0x8B, 0x37, 0x13, 0x6F, 0x2E, 0x1F, + 0x6B, 0xEC, 0x6F, 0x57, 0x20, 0x21, 0xE3, 0xBA }, + { 0xF1, 0xA8, 0x1B, 0x68, 0xF6, 0xE5, 0xA6, 0x27, + 0x1A, 0x8C, 0xB2, 0x4E, 0x7D, 0x94, 0x91, 0xEF }, + { 0x4D, 0xE0, 0xC6, 0xDF, 0x7C, 0xB1, 0x69, 0x72, + 0x84, 0x60, 0x4D, 0x60, 0x27, 0x1B, 0xC5, 0x9A } +}; + +int main( void ) +{ + int m, n, i, j; + aes_context ctx; + unsigned char buf[16]; + unsigned char key[32]; + + for( m = 0; m < 2; m++ ) + { + printf( "\n Rijndael Monte Carlo Test (ECB mode) - " ); + + if( m == 0 ) printf( "encryption\n\n" ); + if( m == 1 ) printf( "decryption\n\n" ); + + for( n = 0; n < 3; n++ ) + { + printf( " Test %d, key size = %3d bits: ", + n + 1, 128 + n * 64 ); + + fflush( stdout ); + + memset( buf, 0, 16 ); + memset( key, 0, 16 + n * 8 ); + + for( i = 0; i < 400; i++ ) + { + aes_set_key( &ctx, key, 128 + n * 64 ); + + for( j = 0; j < 9999; j++ ) + { + if( m == 0 ) aes_encrypt( &ctx, buf, buf ); + if( m == 1 ) aes_decrypt( &ctx, buf, buf ); + } + + if( n > 0 ) + { + for( j = 0; j < (n << 3); j++ ) + { + key[j] ^= buf[j + 16 - (n << 3)]; + } + } + + if( m == 0 ) aes_encrypt( &ctx, buf, buf ); + if( m == 1 ) aes_decrypt( &ctx, buf, buf ); + + for( j = 0; j < 16; j++ ) + { + key[j + (n << 3)] ^= buf[j]; + } + } + + if( ( m == 0 && memcmp( buf, AES_enc_test[n], 16 ) ) || + ( m == 1 && memcmp( buf, AES_dec_test[n], 16 ) ) ) + { + printf( "failed!\n" ); + return( 1 ); + } + + printf( "passed.\n" ); + } + } + + printf( "\n" ); + + return( 0 ); +} + +#endif + diff --git a/vendor/node-airtunes2/src/aes_utils.h b/vendor/node-airtunes2/src/aes_utils.h new file mode 100644 index 0000000..af81401 --- /dev/null +++ b/vendor/node-airtunes2/src/aes_utils.h @@ -0,0 +1,24 @@ +#ifndef _AES_H +#define _AES_H + +#ifndef uint8 +#define uint8 unsigned char +#endif + +#ifndef uint32 +#define uint32 unsigned long int +#endif + +typedef struct +{ + uint32 erk[64]; /* encryption round keys */ + uint32 drk[64]; /* decryption round keys */ + int nr; /* number of rounds */ +} +aes_context; + +int aes_set_key( aes_context *ctx, uint8 *key, int nbits ); +void aes_encrypt( aes_context *ctx, uint8 input[16], uint8 output[16] ); +void aes_decrypt( aes_context *ctx, uint8 input[16], uint8 output[16] ); + +#endif /* aes.h */ diff --git a/vendor/node-airtunes2/src/base64.c b/vendor/node-airtunes2/src/base64.c new file mode 100644 index 0000000..a2d7f76 --- /dev/null +++ b/vendor/node-airtunes2/src/base64.c @@ -0,0 +1,136 @@ +/* + * Copyright (c) 1995-2001 Kungliga Tekniska Högskolan + * (Royal Institute of Technology, Stockholm, Sweden). + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the Institute nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE INSTITUTE AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE INSTITUTE OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ + +#ifdef HAVE_CONFIG_H +#include +RCSID("$Id: base64.c,v 1.1.1.1 2005/07/23 13:57:04 shiro Exp $"); +#endif +#include +#include +#include "base64.h" + +static char base64_chars[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static int +pos(char c) +{ + char *p; + for (p = base64_chars; *p; p++) + if (*p == c) + return p - base64_chars; + return -1; +} + +int +base64_encode(const void *data, int size, char **str) +{ + char *s, *p; + int i; + int c; + const unsigned char *q; + + p = s = (char *) malloc(size * 4 / 3 + 4); + if (p == NULL) + return -1; + q = (const unsigned char *) data; + i = 0; + for (i = 0; i < size;) { + c = q[i++]; + c *= 256; + if (i < size) + c += q[i]; + i++; + c *= 256; + if (i < size) + c += q[i]; + i++; + p[0] = base64_chars[(c & 0x00fc0000) >> 18]; + p[1] = base64_chars[(c & 0x0003f000) >> 12]; + p[2] = base64_chars[(c & 0x00000fc0) >> 6]; + p[3] = base64_chars[(c & 0x0000003f) >> 0]; + if (i > size) + p[3] = '='; + if (i > size + 1) + p[2] = '='; + p += 4; + } + *p = 0; + *str = s; + return strlen(s); +} + +#define DECODE_ERROR 0xffffffff + +static unsigned int +token_decode(const char *token) +{ + int i; + unsigned int val = 0; + int marker = 0; + if (strlen(token) < 4) + return DECODE_ERROR; + for (i = 0; i < 4; i++) { + val *= 64; + if (token[i] == '=') + marker++; + else if (marker > 0) + return DECODE_ERROR; + else + val += pos(token[i]); + } + if (marker > 2) + return DECODE_ERROR; + return (marker << 24) | val; +} + +int +base64_decode(const char *str, void *data) +{ + const char *p; + unsigned char *q; + + q = (unsigned char*)data; + for (p = str; *p && (*p == '=' || strchr(base64_chars, *p)); p += 4) { + unsigned int val = token_decode(p); + unsigned int marker = (val >> 24) & 0xff; + if (val == DECODE_ERROR) + return -1; + *q++ = (val >> 16) & 0xff; + if (marker < 2) + *q++ = (val >> 8) & 0xff; + if (marker < 1) + *q++ = val & 0xff; + } + return q - (unsigned char *) data; +} diff --git a/vendor/node-airtunes2/src/base64.h b/vendor/node-airtunes2/src/base64.h new file mode 100644 index 0000000..2a6919c --- /dev/null +++ b/vendor/node-airtunes2/src/base64.h @@ -0,0 +1,42 @@ +/* + * Copyright (c) 1995, 1996, 1997 Kungliga Tekniska Högskolan + * (Royal Institute of Technology, Stockholm, Sweden). + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the Institute nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE INSTITUTE AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE INSTITUTE OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ + +/* $Id: base64.h,v 1.1.1.1 2005/07/23 13:57:04 shiro Exp $ */ + +#ifndef _BASE64_H_ +#define _BASE64_H_ + +int base64_encode(const void *data, int size, char **str); +int base64_decode(const char *str, void *data); + +#endif diff --git a/vendor/node-airtunes2/src/bindings.cc b/vendor/node-airtunes2/src/bindings.cc new file mode 100644 index 0000000..5c34014 --- /dev/null +++ b/vendor/node-airtunes2/src/bindings.cc @@ -0,0 +1,29 @@ +#include +#include + + +using namespace v8; +using namespace node; + + +namespace nodeairtunes { + +void InitCodec(Local); +#ifdef __APPLE__ +void InitCoreAudio(Local); +#endif + +void Initialize(Local target) { + + Isolate* isolate = v8::Isolate::GetCurrent(); + HandleScope scope(isolate); + + InitCodec(target); +#ifdef __APPLE__ + InitCoreAudio(target); +#endif +} + +} // nodeairtunes namespace + +NODE_MODULE(airtunes, nodeairtunes::Initialize); diff --git a/vendor/node-airtunes2/src/codec.cc b/vendor/node-airtunes2/src/codec.cc new file mode 100644 index 0000000..89ea337 --- /dev/null +++ b/vendor/node-airtunes2/src/codec.cc @@ -0,0 +1,170 @@ +#include +#include +#include +#include + +extern "C" { +#include "aes_utils.h" +} + +#include "base64.h" + + +using namespace v8; +using namespace node; + +const int kBlockSize = 16; +static int kFramesPerPacket = 352; + +// These values should be changed at each iteration +static uint8_t iv [] = { 0x78, 0xf4, 0x41, 0x2c, 0x8d, 0x17, 0x37, 0x90, 0x2b, 0x15, 0xa6, 0xb3, 0xee, 0x77, 0x0d, 0x67 }; +static uint8_t aes_key [] = { 0x14, 0x49, 0x7d, 0xcc, 0x98, 0xe1, 0x37, 0xa8, 0x55, 0xc1, 0x45, 0x5a, 0x6b, 0xc0, 0xc9, 0x79 }; + +namespace nodeairtunes { + + +void EncodeALAC(const FunctionCallbackInfo& args) { + Isolate* isolate = Isolate::GetCurrent(); + Local ctx = isolate->GetCurrentContext(); + EscapableHandleScope scope(isolate); + + if(args.Length() < 4) { + printf("expected: EncodeALAC(encoder, pcmData, pcmSize, alacData, alacSize)\n"); + args.GetReturnValue().Set(Null(isolate)); + } + + // Local wrapper; + // args[0]->ToObject(ctx).ToLocal(&wrapper); + // ALACEncoder *encoder = (ALACEncoder*)wrapper->GetAlignedPointerFromInternalField(0); + + Local pcmBuffer = args[1]; + Local pcmObj; + pcmBuffer->ToObject(ctx).ToLocal(&pcmObj); + unsigned char* pcmData = (unsigned char*)Buffer::Data(pcmObj); + + Local alacBuffer = args[2]; + Local alacObj; + alacBuffer->ToObject(ctx).ToLocal(&alacObj); + unsigned char* alacData = (unsigned char*)Buffer::Data(alacObj); + + int32_t pcmSize = args[3]->Int32Value(ctx).FromJust(); + + // AudioFormatDescription inputFormat, outputFormat; + // FillInputAudioFormat(&inputFormat); + // FillOutputAudioFormat(&outputFormat); + + int32_t alacSize = pcmSize; + + int bsize = 352; + int frames = 352; + int *size = &alacSize; + uint8_t *sample = pcmData; + uint8_t **out = &alacData; + + #ifndef min + #define min(a,b) (((a) < (b)) ? (a) : (b)) + #endif + + uint8_t *p; + uint32_t *in = (uint32_t*) sample; + int count; + + frames = min(frames, bsize); + + // *out = (uint8_t*) malloc(bsize * 4 + 8); + p = *out; + + *p++ = (1 << 5); + *p++ = 0; + *p++ = (1 << 4) | (1 << 1) | ((bsize & 0x80000000) >> 31); // b31 + *p++ = ((bsize & 0x7f800000) << 1) >> 24; // b30--b23 + *p++ = ((bsize & 0x007f8000) << 1) >> 16; // b22--b15 + *p++ = ((bsize & 0x00007f80) << 1) >> 8; // b14--b7 + *p = ((bsize & 0x0000007f) << 1); // b6--b0 + *p++ |= (*in & 0x00008000) >> 15; // LB1 b7 + + count = frames - 1; + + while (count--) { + // LB1 b6--b0 + LB0 b7 + *p++ = ((*in & 0x00007f80) >> 7); + // LB0 b6--b0 + RB1 b7 + *p++ = ((*in & 0x0000007f) << 1) | ((*in & 0x80000000) >> 31); + // RB1 b6--b0 + RB0 b7 + *p++ = ((*in & 0x7f800000) >> 23); + // RB0 b6--b0 + next LB1 b7 + *p++ = ((*in & 0x007f0000) >> 15) | ((*(in + 1) & 0x00008000) >> 15); + + in++; + } + + // last sample + // LB1 b6--b0 + LB0 b7 + *p++ = ((*in & 0x00007f80) >> 7); + // LB0 b6--b0 + RB1 b7 + *p++ = ((*in & 0x0000007f) << 1) | ((*in & 0x80000000) >> 31); + // RB1 b6--b0 + RB0 b7 + *p++ = ((*in & 0x7f800000) >> 23); + // RB0 b6--b0 + next LB1 b7 + *p++ = ((*in & 0x007f0000) >> 15); + + // when readable size is less than bsize, fill 0 at the bottom + count = (bsize - frames) * 4; + while (count--) *p++ = 0; + + // frame footer ?? + *(p-1) |= 1; + *p = (7 >> 1) << 6; + + *size = p - *out + 1; + //encoder->Encode(inputFormat, outputFormat, pcmData, alacData, &alacSize); + + args.GetReturnValue().Set(Integer::New(isolate, alacSize)); +} + +void EncryptAES(const FunctionCallbackInfo& args) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local ctx = isolate->GetCurrentContext(); + EscapableHandleScope scope(isolate); + + if(args.Length() < 2) { + printf("expected: EncryptAES(alacData, alacSize)\n"); + args.GetReturnValue().Set(Null(isolate)); + } + + Local alacBuffer = args[0]; + Local alacObj; + alacBuffer->ToObject(ctx).ToLocal(&alacObj); + unsigned char* alacData = (unsigned char*)Buffer::Data(alacObj); + int32_t alacSize = args[1]->Int32Value(isolate->GetCurrentContext()).FromJust(); + + // This will encrypt data in-place + uint8_t *buf; + int i = 0, j; + uint8_t nv[kBlockSize]; + + aes_context aes_ctx; + aes_set_key(&aes_ctx, aes_key, 128); + memcpy(nv, iv, kBlockSize); + + while(i + kBlockSize <= alacSize) { + buf = alacData + i; + + for(j = 0; j < kBlockSize; j++) + buf[j] ^= nv[j]; + + aes_encrypt(&aes_ctx, buf, buf); + memcpy(nv, buf, kBlockSize); + + i += kBlockSize; + } + + args.GetReturnValue().Set(Null(isolate)); +} + +void InitCodec(Local target) { + NODE_SET_METHOD(target, "encodeALAC", EncodeALAC); + NODE_SET_METHOD(target, "encryptAES", EncryptAES); +} + +} // nodeairtunes namespace \ No newline at end of file diff --git a/vendor/node-airtunes2/src/coreaudio.cc b/vendor/node-airtunes2/src/coreaudio.cc new file mode 100644 index 0000000..618d733 --- /dev/null +++ b/vendor/node-airtunes2/src/coreaudio.cc @@ -0,0 +1,332 @@ +// Conversion to modern node (v4) +// +// TODO: understand. I have no idea what I'm doing here. HandleScope/Isolate? +// +// https://nodejs.org/api/addons.html +// https://developers.google.com/v8/embed?hl=en +// https://strongloop.com/strongblog/node-js-v0-12-c-apis-breaking/ +// https://github.com/nodejs/nan/blob/master/doc/persistent.md + + +#include +#include +#include +//Includes from phoenixLib iOS HAL +#include +#include +#include +#include +#include +#include +#include +#include "CAHostTimeBase.h" +#include +#include + +#define FRAME_SIZE 1408 +#define NUMBER_OF_BUFFERS 100 +#define BUFFER_SIZE FRAME_SIZE * 4 + +using namespace v8; +using namespace node; + +namespace nodeairtunes { + + struct coreAudioObjects { + AudioQueueRef audioQueue; + + unsigned int fillBufferIndex; // the index of the audioQueueBuffer that is being filled + + size_t bytesFilled; // how many bytes have been filled + + AudioQueueBufferRef audioQueueBuffer[NUMBER_OF_BUFFERS]; // audio queue buffers + bool inuse[NUMBER_OF_BUFFERS]; // flags to indicate that a buffer is still in use + + bool isPlaying; + unsigned int buffersUsed; + + OSStatus err; + + pthread_mutex_t queueBuffersMutex; // a mutex to protect the inuse flags + pthread_cond_t queueBufferReadyCondition; // a condition varable for handling the inuse flags + + }; + + // This will free the AudioQueue when the wraping JS object is released by the GC + void coreAudio_weak_callback(const v8::WeakCallbackInfo &data) { + struct coreAudioObjects *coreAudio = (struct coreAudioObjects *) data.GetParameter(); + AudioQueueDispose(coreAudio->audioQueue, true); + } + + void OnAudioQueueBufferConsumed(void *inUserData, AudioQueueRef inAQ, AudioQueueBufferRef inBuffer) { + struct coreAudioObjects* coreAudio; + coreAudio = (struct coreAudioObjects*) inUserData; + + int bufIndex = -1; + for (unsigned int i = 0; i < NUMBER_OF_BUFFERS; i++) { + if (inBuffer == coreAudio->audioQueueBuffer[i]) { + bufIndex = i; + break; + } + } + + if (bufIndex == -1) { + pthread_mutex_lock(&(coreAudio->queueBuffersMutex)); + pthread_cond_signal(&(coreAudio->queueBufferReadyCondition)); + pthread_mutex_unlock(&(coreAudio->queueBuffersMutex)); + //DEBUG_AUDIOMANAGER_ERROR("Buffer mismatch !"); + return; + } + // signal waiting thread that the buffer is free. + pthread_mutex_lock(&(coreAudio->queueBuffersMutex)); + coreAudio->inuse[bufIndex] = false; + coreAudio->buffersUsed--; + pthread_cond_signal(&(coreAudio->queueBufferReadyCondition)); + pthread_mutex_unlock(&(coreAudio->queueBuffersMutex)); + } + + static void enqueueBuffer(struct coreAudioObjects *coreAudio) { + + OSStatus err = 0; + + // enqueue buffer + pthread_mutex_lock(&(coreAudio->queueBuffersMutex)); + + AudioQueueBufferRef fillBuf = coreAudio->audioQueueBuffer[coreAudio->fillBufferIndex]; + fillBuf->mAudioDataByteSize = coreAudio->bytesFilled; + err = AudioQueueEnqueueBuffer(coreAudio->audioQueue, fillBuf, 0, NULL); + if (err) { + //DEBUG_AUDIOMANAGER_ERROR("enqueue failed !!!!! %d", coreAudio->fillBufferIndex); + coreAudio->bytesFilled = 0; + pthread_mutex_unlock(&(coreAudio->queueBuffersMutex)); + return; + } + + coreAudio->inuse[coreAudio->fillBufferIndex] = true; // set in use flag + coreAudio->buffersUsed++; + + // go to next buffer + if (++(coreAudio->fillBufferIndex) >= NUMBER_OF_BUFFERS) coreAudio->fillBufferIndex = 0; + + coreAudio->bytesFilled = 0; // reset bytes filled + + // wait until next buffer is not in use + while (coreAudio->inuse[coreAudio->fillBufferIndex]) { + pthread_cond_wait(&(coreAudio->queueBufferReadyCondition), &(coreAudio->queueBuffersMutex)); + } + + pthread_mutex_unlock(&(coreAudio->queueBuffersMutex)); + } + + void NewCoreAudio(const v8::FunctionCallbackInfo& args) { + v8::Isolate* isolate = args.GetIsolate(); + + v8::HandleScope handle_scope(isolate); + + struct coreAudioObjects *coreAudio + = (struct coreAudioObjects *) malloc(sizeof (*coreAudio)); + + v8::Persistent o; + o.SetWeak( + coreAudio, + coreAudio_weak_callback, + v8::WeakCallbackType::kParameter + ); + + coreAudio->isPlaying = false; + coreAudio->buffersUsed = 0; + coreAudio->bytesFilled = 0; + coreAudio->fillBufferIndex = 0; + // initialize a mutex and condition so that we can block on buffers in use. + pthread_mutex_init(&(coreAudio->queueBuffersMutex), NULL); + pthread_cond_init(&(coreAudio->queueBufferReadyCondition), NULL); + + AudioStreamBasicDescription LFormat; + OSStatus LRet; + + LFormat.mSampleRate = 44100; + LFormat.mFormatID = kAudioFormatLinearPCM; + LFormat.mFormatFlags = kAudioFormatFlagIsSignedInteger; // signed int + LFormat.mFramesPerPacket = 1; // for uncompressed audio + LFormat.mBytesPerFrame = 4; //AChannels * 2; // interleaved pcm datas (16bits per chan) + LFormat.mBytesPerPacket = 4; // for pcm: a packet contains a single frame + LFormat.mChannelsPerFrame = 2; + LFormat.mBitsPerChannel = 16; + + // Allocating AudioQueue + + if ((LRet = AudioQueueNewOutput(&LFormat, OnAudioQueueBufferConsumed, coreAudio, NULL, NULL, 0, &(coreAudio->audioQueue)))) { + args.GetReturnValue().Set(v8::Null(isolate)); + return; + } + + OSStatus status = 0; + + // Allocate buffers for the AudioQueue + for (int i = 0; i < NUMBER_OF_BUFFERS; ++i) { + status = AudioQueueAllocateBuffer(coreAudio->audioQueue, BUFFER_SIZE, &(coreAudio->audioQueueBuffer[i])); + coreAudio->inuse[i] = false; + } + + args.GetReturnValue().Set(o.Get(isolate)); + } + + void EnqueuePacket(const v8::FunctionCallbackInfo& args) { + v8::Isolate* isolate = args.GetIsolate(); + Local ctx = isolate->GetCurrentContext(); + + if (args.Length() < 3) { + printf("expected: EnqueuePacket(coreAudio, pcmData, pcmSize)\n"); + args.GetReturnValue().Set(v8::Null(isolate)); + return; + } + + v8::Local wrapper; + args[0]->ToObject(ctx).ToLocal(&wrapper); + struct coreAudioObjects *coreAudio = (struct coreAudioObjects *) *wrapper; + + v8::Local pcmBuffer = args[1]; + Local pcmObject; + pcmBuffer->ToObject(ctx).ToLocal(&pcmObject); + unsigned char* pcmData = (unsigned char*) (Buffer::Data(pcmObject)); + + int32_t pcmSize = args[2]->Int32Value(ctx).FromJust(); + + //We have all infos needed + + size_t offset = 0; + while (pcmSize) { + // if the space remaining in the buffer is not enough for this packet, then enqueue the buffer. + size_t bufSpaceRemaining = BUFFER_SIZE - coreAudio->bytesFilled; + if (bufSpaceRemaining < pcmSize) { + //DEBUG_AUDIOMANAGER_VERBOSE("Before enqueue"); + enqueueBuffer(coreAudio); + //DEBUG_AUDIOMANAGER_VERBOSE("After enqueue"); + } + + bufSpaceRemaining = BUFFER_SIZE - coreAudio->bytesFilled; + size_t copySize; + if (bufSpaceRemaining < pcmSize) { + copySize = bufSpaceRemaining; + } else { + copySize = pcmSize; + } + + // copy data to the audio queue buffer + AudioQueueBufferRef fillBuf = coreAudio->audioQueueBuffer[coreAudio->fillBufferIndex]; + memcpy((char*) fillBuf->mAudioData + coreAudio->bytesFilled, (const char*) (pcmData + offset), copySize); + + // keep track of bytes filled and packets filled + coreAudio->bytesFilled += copySize; + + pcmSize -= copySize; + offset += copySize; + } + + args.GetReturnValue().Set(v8::Null(isolate)); + } + + void Play(const v8::FunctionCallbackInfo& args) { + v8::Isolate* isolate = args.GetIsolate(); + Local ctx = isolate->GetCurrentContext(); + + if (args.Length() < 2) { + printf("expected: Play(coreAudio, audioQueueTimeRef)\n"); + args.GetReturnValue().Set(v8::Null(isolate)); + return; + } + + v8::Local wrapper; + args[0]->ToObject(ctx).ToLocal(&wrapper); + struct coreAudioObjects *coreAudio = (struct coreAudioObjects *) *wrapper; + + int64_t timeStamp = args[1]->IntegerValue(ctx).FromJust(); + + AudioTimeStamp myAudioQueueStartTime; // = {0}; + Float64 theNumberOfSecondsInTheFuture = timeStamp/44100.0; + + Float64 hostTimeFreq = CAHostTimeBase::GetFrequency(); + UInt64 startHostTime = CAHostTimeBase::GetCurrentTime() + theNumberOfSecondsInTheFuture * hostTimeFreq; + + myAudioQueueStartTime.mFlags = kAudioTimeStampHostTimeValid; + myAudioQueueStartTime.mHostTime = startHostTime; + + if (coreAudio->isPlaying == false) { + if (AudioQueueStart(coreAudio->audioQueue, &myAudioQueueStartTime)) { + printf("AudioQueueStart() Failed!\n"); + } else { + coreAudio->isPlaying = true; + } + } + + args.GetReturnValue().Set(v8::Null(isolate)); + } + + void Stop(const v8::FunctionCallbackInfo& args) { + v8::Isolate* isolate = args.GetIsolate(); + Local ctx = isolate->GetCurrentContext(); + + if (args.Length() < 1) { + printf("expected: Play(coreAudio)\n"); + args.GetReturnValue().Set(v8::Null(isolate)); + return; + } + + v8::Local wrapper; + args[0]->ToObject(ctx).ToLocal(&wrapper); + struct coreAudioObjects *coreAudio = (struct coreAudioObjects *) *wrapper; + + if (coreAudio->isPlaying) { + if (AudioQueueStop(coreAudio->audioQueue, true)) { + printf("AudioQueueStop() Failed!\n"); + } else { + coreAudio->isPlaying = false; + } + } + + args.GetReturnValue().Set(v8::Null(isolate)); + } + + void SetVolume(const v8::FunctionCallbackInfo& args) { + v8::Isolate* isolate = args.GetIsolate(); + Local ctx = isolate->GetCurrentContext(); + + if (args.Length() < 1) { + printf("expected: Play(coreAudio)\n"); + args.GetReturnValue().Set(v8::Null(isolate)); + return; + } + + v8::Local wrapper; + args[0]->ToObject(ctx).ToLocal(&wrapper); + struct coreAudioObjects *coreAudio = (struct coreAudioObjects *) *wrapper; + float volumeToSet=args[1]->IntegerValue(ctx).FromJust()/100.0; + + if (coreAudio->isPlaying) + AudioQueueSetParameter(coreAudio->audioQueue, kAudioQueueParam_Volume, volumeToSet); + + args.GetReturnValue().Set(v8::Null(isolate)); + } + + void GetBufferLevel(const v8::FunctionCallbackInfo& args) { + v8::Isolate* isolate = args.GetIsolate(); + Local ctx = isolate->GetCurrentContext(); + + v8::Local wrapper; + args[0]->ToObject(ctx).ToLocal(&wrapper); + struct coreAudioObjects *coreAudio = (struct coreAudioObjects *) *wrapper; + v8::Local o = v8::Integer::New(isolate, (int)((coreAudio->buffersUsed/(float)NUMBER_OF_BUFFERS)*100)); + + args.GetReturnValue().Set(o); + } + + void InitCoreAudio(v8::Local target) { + NODE_SET_METHOD(target, "enqueuePacket", EnqueuePacket); + NODE_SET_METHOD(target, "newCoreAudio", NewCoreAudio); + NODE_SET_METHOD(target, "play", Play); + NODE_SET_METHOD(target, "stop", Stop); + NODE_SET_METHOD(target, "setVolume", SetVolume); + NODE_SET_METHOD(target, "getBufferLevel", GetBufferLevel); + } + +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..acffbfc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + // Release artifacts and isolated checkouts must not become extra suites. + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'] + } +});