From 5135618109154cec96a18621b80e805b8fae6d73 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:22:44 +0800 Subject: [PATCH 1/2] feat(ipodnano): add Pocket Music click-wheel control --- apps/pocket-music/app.tsx | 117 ++++++ apps/pocket-music/main.tsx | 5 + apps/pocket-music/pocket.json | 30 ++ apps/pocket-music/service.ts | 134 ++++++ docs/IPODNANO.md | 106 +++++ .../ipod-nano-2/pocket-music-profile.json | 87 ++++ .../pocket3d/examples/handheld/src/device.rs | 58 +++ engine/pocket3d/examples/handheld/src/main.rs | 45 +- .../examples/handheld/src/pocket_music.rs | 292 +++++++++++++ hosts/ipodnano/PocketMusicDaemon.m | 393 ++++++++++++++++++ package.json | 1 + tests/pocket-music.test.ts | 99 +++++ tests/widget-args.test.ts | 12 + tools/pocket-music.ts | 244 +++++++++++ tools/test.ts | 1 + tools/widget.ts | 9 +- 16 files changed, 1630 insertions(+), 3 deletions(-) create mode 100644 apps/pocket-music/app.tsx create mode 100644 apps/pocket-music/main.tsx create mode 100644 apps/pocket-music/pocket.json create mode 100644 apps/pocket-music/service.ts create mode 100644 docs/IPODNANO.md create mode 100644 engine/pocket3d/examples/handheld/assets/ipod-nano-2/pocket-music-profile.json create mode 100644 engine/pocket3d/examples/handheld/src/pocket_music.rs create mode 100644 hosts/ipodnano/PocketMusicDaemon.m create mode 100644 tests/pocket-music.test.ts create mode 100644 tools/pocket-music.ts diff --git a/apps/pocket-music/app.tsx b/apps/pocket-music/app.tsx new file mode 100644 index 00000000..4f66bf8d --- /dev/null +++ b/apps/pocket-music/app.tsx @@ -0,0 +1,117 @@ +import { Show, createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { BTN } from "@pocketjs/framework/input"; +import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; +import { + connectPocketMusic, + type PocketMusicOperation, + type PocketMusicState, +} from "./service.ts"; + +const OFFLINE: PocketMusicState = { + daemonConnected: false, + deviceConnected: false, + playerRunning: false, + playing: false, + positionMs: 0, + volume: 0, + sequence: 0, +}; + +function timeLabel(milliseconds: number): string { + const seconds = Math.max(0, Math.floor(milliseconds / 1_000)); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} + +function statusLabel(state: PocketMusicState): string { + if (!state.daemonConnected) return "START DAEMON"; + if (!state.deviceConnected) return "CONNECT iPOD"; + if (!state.playerRunning) return "OPEN MUSIC"; + return state.playing ? "PLAYING" : "PAUSED"; +} + +export default function PocketMusic() { + const connection = connectPocketMusic(); + const [state, setState] = createSignal(OFFLINE); + + const send = (op: PocketMusicOperation): void => connection?.send(op); + onButtonPress(BTN.START, () => send("toggle")); + onButtonPress(BTN.RIGHT, () => send("next")); + onButtonPress(BTN.LEFT, () => send("previous")); + onButtonPress(BTN.TRIANGLE, () => send("stop")); + onButtonPress(BTN.CIRCLE, () => send("mute")); + onButtonPress(BTN.DOWN, () => send("volume-up")); + onButtonPress(BTN.UP, () => send("volume-down")); + + onFrame(() => { + if (!connection) return; + for (const next of connection.poll()) setState(next); + }); + + const duration = () => state().track?.durationMs ?? 0; + const progress = () => + duration() > 0 ? Math.min(1, state().positionMs / duration()) : 0; + + return ( + + + {state().playing ? ">" : "II"} + + Pocket Music + + {state().deviceConnected ? "iP" : "--"} + + + + {statusLabel(state())} + Rockbox USB HID + + } + > + {(track) => ( + + + {track().title} + + + {track().artist} + + + {track().album || "Music"} + + + + + + {timeLabel(state().positionMs)} + -{timeLabel(duration() - state().positionMs)} + + + )} + + + + VOL + + + + {Math.round(state().volume)} + + + ); +} diff --git a/apps/pocket-music/main.tsx b/apps/pocket-music/main.tsx new file mode 100644 index 00000000..146d3c10 --- /dev/null +++ b/apps/pocket-music/main.tsx @@ -0,0 +1,5 @@ +// @title Pocket Music +import { mount } from "@pocketjs/framework"; +import PocketMusic from "./app.tsx"; + +mount(() => ); diff --git a/apps/pocket-music/pocket.json b/apps/pocket-music/pocket.json new file mode 100644 index 00000000..b6e5016c --- /dev/null +++ b/apps/pocket-music/pocket.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.pocket-music", + "name": "pocket-music", + "title": "Pocket Music", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": [ + "text.glyphs.baked", + "input.buttons" + ] + } + }, + "app": { + "entry": "apps/pocket-music/main.tsx", + "output": "pocket-music-main", + "framework": "solid", + "viewport": { + "fixed": { + "logical": [ + 176, + 132 + ], + "presentation": "native" + } + } + } +} diff --git a/apps/pocket-music/service.ts b/apps/pocket-music/service.ts new file mode 100644 index 00000000..f774b30b --- /dev/null +++ b/apps/pocket-music/service.ts @@ -0,0 +1,134 @@ +import { getOps } from "@pocketjs/framework"; + +export const POCKET_MUSIC_SERVICE = "pocket-music"; + +export type PocketMusicOperation = + | "toggle" + | "next" + | "previous" + | "stop" + | "mute" + | "volume-up" + | "volume-down"; + +export interface PocketMusicTrack { + readonly id: string; + readonly title: string; + readonly artist: string; + readonly album: string; + readonly durationMs: number; +} + +export interface PocketMusicState { + readonly daemonConnected: boolean; + readonly deviceConnected: boolean; + readonly playerRunning: boolean; + readonly playing: boolean; + readonly positionMs: number; + readonly volume: number; + readonly sequence: number; + readonly track?: PocketMusicTrack; + readonly lastControl?: string; + readonly error?: string; +} + +export interface PocketMusicServiceOps { + svcOpen?(app: string): boolean; + svcPoll?(): string | undefined; + svcSend?(line: string): void; +} + +export interface PocketMusicConnection { + poll(): PocketMusicState[]; + send(op: PocketMusicOperation): void; +} + +function nonNegative(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function parseTrack(value: unknown): PocketMusicTrack | undefined | null { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const track = value as Record; + if ( + typeof track.id !== "string" || + typeof track.title !== "string" || + typeof track.artist !== "string" || + typeof track.album !== "string" || + !nonNegative(track.durationMs) + ) { + return null; + } + return { + id: track.id, + title: track.title, + artist: track.artist, + album: track.album, + durationMs: track.durationMs, + }; +} + +export function parsePocketMusicState(line: string): PocketMusicState | null { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + return null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const event = value as Record; + if (event.t !== "pocket-music.state") return null; + if ( + typeof event.daemonConnected !== "boolean" || + typeof event.deviceConnected !== "boolean" || + typeof event.playerRunning !== "boolean" || + typeof event.playing !== "boolean" || + !nonNegative(event.positionMs) || + !nonNegative(event.volume) || + event.volume > 100 || + !Number.isInteger(event.sequence) || + (event.sequence as number) < 0 + ) { + return null; + } + const track = parseTrack(event.track); + if (track === null) return null; + if (event.lastControl !== undefined && typeof event.lastControl !== "string") return null; + if (event.error !== undefined && typeof event.error !== "string") return null; + return { + daemonConnected: event.daemonConnected, + deviceConnected: event.deviceConnected, + playerRunning: event.playerRunning, + playing: event.playing, + positionMs: event.positionMs, + volume: event.volume, + sequence: event.sequence as number, + ...(track === undefined ? {} : { track }), + ...(event.lastControl === undefined ? {} : { lastControl: event.lastControl as string }), + ...(event.error === undefined ? {} : { error: event.error as string }), + }; +} + +export function parsePocketMusicBatch(batch: string | undefined): PocketMusicState[] { + if (!batch) return []; + const states: PocketMusicState[] = []; + for (const line of batch.split("\n")) { + const state = parsePocketMusicState(line); + if (state) states.push(state); + } + return states; +} + +export function connectPocketMusic( + ops: PocketMusicServiceOps = getOps(), +): PocketMusicConnection | null { + if (!ops.svcOpen || !ops.svcPoll || !ops.svcSend) return null; + if (!ops.svcOpen(POCKET_MUSIC_SERVICE)) return null; + const poll = ops.svcPoll.bind(ops); + const send = ops.svcSend.bind(ops); + return { + poll: () => parsePocketMusicBatch(poll()), + send: (op) => send(JSON.stringify({ t: "pocket-music.command", op })), + }; +} diff --git a/docs/IPODNANO.md b/docs/IPODNANO.md new file mode 100644 index 00000000..e9ab55cf --- /dev/null +++ b/docs/IPODNANO.md @@ -0,0 +1,106 @@ +# iPod nano 2G and Pocket Music + +Pocket Music uses an iPod nano 2nd generation as a wired controller for Music.app on +macOS. The macOS window runs the `apps/pocket-music` PocketJS bundle inside the +authored iPod nano Stage package. + +## Hardware contract + +The supported controller is identified by USB vendor `0x05ac` and product `0x1260`. +The current Rockbox target calls it `ipodnano2g`; it has a **176×132 RGB565 display, +32 MB RAM, a click wheel, and USB HID support**. + +Run the non-mutating check with the iPod attached: + +```sh +bun pocket-music doctor +``` + +The command reports the USB identity, serial, filesystem, Rockbox directory, HID +enumeration, and daemon installation. It does not write to the iPod. + +## Control path + +**Rockbox USB Keypad Mode must be set to Multimedia.** Its current iPod keymap emits +standard Consumer Page usages: + +| iPod control | HID usage | Pocket Music action | +| --- | --- | --- | +| wheel clockwise | Volume Increment (`0x00e9`) | Music volume +2 | +| wheel counter-clockwise | Volume Decrement (`0x00ea`) | Music volume -2 | +| center | Mute (`0x00e2`) | toggle Music volume between 0 and 48 | +| Play/Pause | Play/Pause (`0x00cd`) | play or pause | +| Menu or long Play | Stop (`0x00b7`) | stop | +| Previous | Scan Previous (`0x00b6`) | previous track | +| Next | Scan Next (`0x00b5`) | next track | + +`pocket-music-daemon` opens only the `0x05ac:0x1260` HID device and requests +`kIOHIDOptionsTypeSeizeDevice`. **Seizing prevents macOS from applying the same media +key twice.** The first real launch can require Input Monitoring permission. Music.app +control uses Apple events and can require Automation permission. + +The daemon exposes a mode-`0600` Unix socket under +`~/Library/Application Support/Pocket Music/`. Pocket Stage validates the service name, +guest command namespace, allowed operations, daemon event namespace, and a 64 KiB line +limit. The PocketJS guest cannot invoke AppleScript or open the HID device directly. + +## Build and run + +Build the Objective-C daemon with warnings as errors, run its mapping self-test, build +the PocketJS bundle, and render the deterministic app proof: + +```sh +bun pocket-music build +``` + +Run the daemon in the foreground, then start Pocket Music in another terminal: + +```sh +bun pocket-music daemon +bun pocket-music run +``` + +Install the daemon as a per-user LaunchAgent: + +```sh +bun pocket-music install-daemon +``` + +For development without changing the iPod or Music.app, start the fixture daemon: + +```sh +bun pocket-music daemon --fixture +bun pocket-music run --focus +``` + +## Rockbox installation gate + +The stock firmware exposes the iPod as storage and does not send click-wheel events to +macOS. Rockbox adds the USB HID interface used by the daemon. The official Rockbox +manual states that **Rockbox on this target requires FAT32 and does not run from an +HFS+ iPod**. + +The attached 4 GB unit is currently HFS. Converting it to FAT32 erases its music and +settings. Do not convert or install the bootloader until all of these checks pass: + +1. Copy the mounted volume to a separate local backup and verify the copied file count + and hashes. +2. Save a raw image of the whole 4.1 GB device and verify that the image size matches + `diskutil info`. +3. Confirm Finder can restore this exact iPod and that the original Apple firmware can + still boot. +4. Use the official `ipodnano2g` Rockbox build and the Nano 2G `.ipodx` bootloader; + do not use Nano 1G or iPod Classic files. +5. After conversion, verify USB `05ac:1260`, a FAT32 data partition, Rockbox boot, + original-firmware boot, USB HID enumeration, and every control in the table above. + +The software checkout deliberately contains no automatic erase or bootloader-write +command. Device conversion remains a separate, explicit operation after the backup and +recovery checkpoints are observed. + +Primary references: + +- [Rockbox iPod nano 2G target configuration](https://github.com/Rockbox/rockbox/blob/master/firmware/export/config/ipodnano2g.h) +- [Rockbox iPod keymap](https://github.com/Rockbox/rockbox/blob/master/apps/keymaps/keymap-ipod.c) +- [Rockbox iPod nano manual](https://download.rockbox.org/daily/manual/rockbox-ipodnano2g.pdf) +- [Rockbox iPod bootloader files](https://download.rockbox.org/bootloader/ipod/) diff --git a/engine/pocket3d/examples/handheld/assets/ipod-nano-2/pocket-music-profile.json b/engine/pocket3d/examples/handheld/assets/ipod-nano-2/pocket-music-profile.json new file mode 100644 index 00000000..ad166950 --- /dev/null +++ b/engine/pocket3d/examples/handheld/assets/ipod-nano-2/pocket-music-profile.json @@ -0,0 +1,87 @@ +{ + "schema_version": 1, + "name": "Pocket Music on iPod nano (2nd generation)", + "window_title": "Pocket Music", + "attribution": "ATTRIBUTION.md", + "lods": { + "settled": "ipod-nano-2.glb", + "orbit": "ipod-nano-2.glb" + }, + "target_width_mm": 40.0, + "rotation_degrees": [0.0, 0.0, 0.0], + "display": { + "logical_size": [176, 132], + "raster_density": 1, + "window_size": [320, 600] + }, + "view": { + "desk_position_mm": [0.0, 12.0, 190.0], + "desk_target_mm": [0.0, 0.0, 0.0], + "focus_distance_mm": 110.0, + "fov_y_degrees": 30.0 + }, + "screen": { + "material_role": "dynamic_screen", + "material_name_prefix": "P3D_dynamic_screen__", + "expected_primitives": 1 + }, + "suppressed_materials": [], + "parts": [ + { + "name": "screen", + "center_mm": [0.0, 28.9, 3.6], + "half_extents_mm": [14.8, 11.1, 0.8] + }, + { + "name": "wheel_select", + "button": "circle", + "center_mm": [0.0, -13.1, 3.9], + "half_extents_mm": [6.8, 6.8, 0.7] + }, + { + "name": "click_wheel", + "center_mm": [0.0, -13.1, 3.7], + "half_extents_mm": [15.0, 15.0, 0.6] + } + ], + "rotary": { + "adapter": "rotary-wheel@1", + "name": "click_wheel", + "center_mm": [0.0, -13.1, 3.7], + "inner_radius_mm": 6.8, + "outer_radius_mm": 15.0, + "step_degrees": 12.0, + "clockwise_button": "down", + "counterclockwise_button": "up", + "sectors": [ + { + "name": "wheel_menu", + "center_degrees": 90.0, + "half_width_degrees": 30.0, + "button": "triangle" + }, + { + "name": "wheel_next", + "center_degrees": 0.0, + "half_width_degrees": 30.0, + "button": "right" + }, + { + "name": "wheel_play", + "center_degrees": 270.0, + "half_width_degrees": 30.0, + "button": "start" + }, + { + "name": "wheel_previous", + "center_degrees": 180.0, + "half_width_degrees": 30.0, + "button": "left" + } + ] + }, + "companion": { + "service": "pocket-music@1", + "channel": "pocket-music" + } +} diff --git a/engine/pocket3d/examples/handheld/src/device.rs b/engine/pocket3d/examples/handheld/src/device.rs index db8f0dfb..a94c45de 100644 --- a/engine/pocket3d/examples/handheld/src/device.rs +++ b/engine/pocket3d/examples/handheld/src/device.rs @@ -41,6 +41,10 @@ struct DeviceProfile { rotary: Option, #[serde(default)] media: Option, + #[serde(default)] + companion: Option, + #[serde(default)] + window_title: Option, } /// Every package states its own display facts; the runtime carries no @@ -105,6 +109,13 @@ struct MediaTrackProfile { duration_ms: u64, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CompanionProfile { + service: String, + channel: String, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct LodProfile { @@ -164,6 +175,12 @@ pub struct MediaSettings { pub tracks: Vec, } +#[derive(Clone, Debug)] +pub struct CompanionSettings { + pub service: String, + pub channel: String, +} + #[derive(Clone, Debug)] pub struct StageSettings { pub logical_size: (u32, u32), @@ -172,6 +189,8 @@ pub struct StageSettings { pub window_size: (u32, u32), pub view: ViewSettings, pub media: Option, + pub companion: Option, + pub window_title: String, } #[derive(Clone, Debug)] @@ -311,6 +330,10 @@ pub fn load_settings(profile_path: &Path) -> Result { }) }) .transpose()?; + let companion = profile.companion.map(|companion| CompanionSettings { + service: companion.service, + channel: companion.channel, + }); Ok(StageSettings { logical_size: (logical[0], logical[1]), raster_density: density, @@ -326,6 +349,8 @@ pub fn load_settings(profile_path: &Path) -> Result { fov_y: profile.view.fov_y_degrees.to_radians(), }, media, + companion, + window_title: profile.window_title.unwrap_or_else(|| "Pocket Stage".into()), }) } @@ -782,6 +807,24 @@ fn validate_profile(profile: &DeviceProfile) -> Result<()> { ); } } + ensure!( + profile.media.is_none() || profile.companion.is_none(), + "a stage profile cannot declare both media and companion services" + ); + if let Some(companion) = &profile.companion { + ensure!( + companion.service == "pocket-music@1", + "unsupported companion service {}", + companion.service + ); + ensure!( + !companion.channel.trim().is_empty(), + "companion service channel is empty" + ); + } + if let Some(title) = &profile.window_title { + ensure!(!title.trim().is_empty(), "window title is empty"); + } Ok(()) } @@ -818,6 +861,21 @@ mod tests { assert!(media.tracks.iter().all(|track| track.path.is_file())); } + #[test] + fn pocket_music_profile_declares_only_its_bounded_companion() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("assets/ipod-nano-2/pocket-music-profile.json"); + let profile = read_profile(&path).unwrap(); + validate_profile(&profile).unwrap(); + let settings = load_settings(&path).unwrap(); + assert_eq!(settings.logical_size, (176, 132)); + assert_eq!(settings.window_title, "Pocket Music"); + assert!(settings.media.is_none()); + let companion = settings.companion.unwrap(); + assert_eq!(companion.service, "pocket-music@1"); + assert_eq!(companion.channel, "pocket-music"); + } + #[test] fn canonical_transform_centers_and_scales_width() { let aabb = (Vec3::new(2.0, 4.0, 6.0), Vec3::new(4.0, 5.0, 7.0)); diff --git a/engine/pocket3d/examples/handheld/src/main.rs b/engine/pocket3d/examples/handheld/src/main.rs index 758d0543..6c9e3793 100644 --- a/engine/pocket3d/examples/handheld/src/main.rs +++ b/engine/pocket3d/examples/handheld/src/main.rs @@ -24,6 +24,7 @@ mod device; mod media; +mod pocket_music; use std::collections::VecDeque; use std::path::PathBuf; @@ -46,6 +47,7 @@ use winit::keyboard::KeyCode; use device::Device; use media::MediaService; +use pocket_music::PocketMusicService; /// Keys the widget polls for held state (the shared uihost map + I/J/K/L /// as a keyboard nub). @@ -430,6 +432,7 @@ struct StageGame { profile_path: PathBuf, settings: device::StageSettings, media: Option, + pocket_music: Option, svc_booted: bool, last_unknown_svc_warning_tick: Option, @@ -481,6 +484,7 @@ impl StageGame { profile_path: PathBuf, settings: device::StageSettings, media: Option, + pocket_music: Option, ) -> Self { let initial_window = settings.window_size; let scene = Scene { @@ -501,6 +505,7 @@ impl StageGame { profile_path, settings, media, + pocket_music, svc_booted: false, last_unknown_svc_warning_tick: None, embedded: None, @@ -806,6 +811,11 @@ impl WidgetGame for StageGame { { embedded.surface().svc_push(media.hello_line()); } + if let Some(pocket_music) = &self.pocket_music + && let Some(embedded) = self.embedded.as_ref() + { + embedded.surface().svc_push(pocket_music.disconnected_line()); + } self.svc_booted = true; } self.guest.frame_with_analog(buttons, analog)?; @@ -816,6 +826,20 @@ impl WidgetGame for StageGame { } if let Some(embedded) = self.embedded.as_ref() { let surface = embedded.surface(); + if let Some(pocket_music) = self.pocket_music.as_mut() { + for line in surface.svc_drain_matching(PocketMusicService::is_guest_line) { + match pocket_music.handle_guest_line(&line) { + Ok(true) => {} + Ok(false) => unreachable!("Pocket Music predicate and handler disagree"), + Err(error) => { + log::warn!("pocket-stage: bad Pocket Music svc line: {error:#}") + } + } + } + for line in pocket_music.tick(self.ticks) { + surface.svc_push(line); + } + } if let Some(media) = self.media.as_mut() { // Each registered adapter selectively takes its namespace in // FIFO order. `svc_push` is the opposite (host → guest) path. @@ -1125,7 +1149,18 @@ fn boot(args: &Args, settings: &device::StageSettings) -> Result<(Guest, UiSurfa // A package declares both its host adapter contract and the guest-facing // channel name. Only that exact channel may open; a typo or unrelated app // cannot discover the media companion accidentally. - surface.set_svc_allowlist(settings.media.iter().map(|media| media.channel.as_str())); + surface.set_svc_allowlist( + settings + .media + .iter() + .map(|media| media.channel.as_str()) + .chain( + settings + .companion + .iter() + .map(|companion| companion.channel.as_str()), + ), + ); surface.feed_pak(&pak); let guest = Guest::new()?; surface.mount(&guest)?; @@ -1209,6 +1244,11 @@ fn main() -> Result<()> { ); } let media = settings.media.clone().map(MediaService::new).transpose()?; + let pocket_music = settings + .companion + .clone() + .map(PocketMusicService::new) + .transpose()?; let (guest, surface) = boot(&args, &settings)?; let mut game = StageGame::new( guest, @@ -1217,6 +1257,7 @@ fn main() -> Result<()> { args.profile.clone(), settings.clone(), media, + pocket_music, ); game.orbit = OrbitState::new(args.orbit.map(f32::to_radians)); game.quit_after = args.auto_quit.map(|s| (s * 60.0) as u64); @@ -1229,7 +1270,7 @@ fn main() -> Result<()> { } else { pocket_widget::run( WidgetConfig { - title: "Pocket Stage".into(), + title: settings.window_title, size: settings.window_size, max_fps: args.max_fps, ..Default::default() diff --git a/engine/pocket3d/examples/handheld/src/pocket_music.rs b/engine/pocket3d/examples/handheld/src/pocket_music.rs new file mode 100644 index 00000000..02937507 --- /dev/null +++ b/engine/pocket3d/examples/handheld/src/pocket_music.rs @@ -0,0 +1,292 @@ +//! Pocket Music's bounded bridge to the per-user macOS daemon. +//! +//! The PocketJS guest sees only its declared svc channel. The stage owns the +//! Unix socket, validates both directions, and reconnects without blocking the +//! fixed-rate guest turn when the daemon is restarted. + +use std::collections::VecDeque; +use std::io::{ErrorKind, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; + +use anyhow::{Result, anyhow, ensure}; +use serde_json::{Value, json}; + +use crate::device::CompanionSettings; + +const RECONNECT_TICKS: u64 = 30; +const MAX_LINE_BYTES: usize = 64 * 1024; + +pub struct PocketMusicService { + socket_path: PathBuf, + stream: Option, + read_buffer: Vec, + writes: VecDeque>, + last_connect_tick: Option, + connection_announced: bool, +} + +impl PocketMusicService { + pub fn new(settings: CompanionSettings) -> Result { + ensure!( + settings.service == "pocket-music@1", + "unsupported Pocket Music companion {}", + settings.service + ); + Ok(Self { + socket_path: socket_path(), + stream: None, + read_buffer: Vec::new(), + writes: VecDeque::new(), + last_connect_tick: None, + connection_announced: false, + }) + } + + pub fn is_guest_line(line: &str) -> bool { + serde_json::from_str::(line) + .ok() + .and_then(|value| value.get("t").and_then(Value::as_str).map(str::to_owned)) + .is_some_and(|kind| kind == "pocket-music.command") + } + + pub fn disconnected_line(&self) -> String { + json!({ + "t": "pocket-music.state", + "daemonConnected": false, + "deviceConnected": false, + "playerRunning": false, + "playing": false, + "positionMs": 0, + "volume": 0, + "sequence": 0, + }) + .to_string() + } + + pub fn handle_guest_line(&mut self, line: &str) -> Result { + let value: Value = serde_json::from_str(line)?; + if value["t"].as_str() != Some("pocket-music.command") { + return Ok(false); + } + let op = value["op"] + .as_str() + .ok_or_else(|| anyhow!("Pocket Music command is missing op"))?; + ensure!( + matches!( + op, + "toggle" | "next" | "previous" | "stop" | "mute" | "volume-up" | "volume-down" + ), + "unsupported Pocket Music command {op:?}" + ); + ensure!( + value.as_object().is_some_and(|object| object.len() == 2), + "Pocket Music command contains unsupported fields" + ); + let mut wire = line.as_bytes().to_vec(); + wire.push(b'\n'); + ensure!( + wire.len() <= MAX_LINE_BYTES, + "Pocket Music command is too large" + ); + self.writes.push_back(wire); + Ok(true) + } + + /// Advance the nonblocking connection and return validated daemon lines. + pub fn tick(&mut self, tick: u64) -> Vec { + let mut messages = Vec::new(); + if self.stream.is_none() + && self + .last_connect_tick + .is_none_or(|last| tick.saturating_sub(last) >= RECONNECT_TICKS) + { + self.last_connect_tick = Some(tick); + match UnixStream::connect(&self.socket_path) { + Ok(stream) => { + if let Err(error) = stream.set_nonblocking(true) { + log::warn!("Pocket Music socket cannot become nonblocking: {error}"); + } else { + self.stream = Some(stream); + self.read_buffer.clear(); + self.connection_announced = true; + messages.push( + json!({ + "t": "pocket-music.connection", + "daemonConnected": true, + }) + .to_string(), + ); + } + } + Err(error) + if error.kind() == ErrorKind::NotFound + || error.kind() == ErrorKind::ConnectionRefused => {} + Err(error) => log::warn!( + "Pocket Music daemon connection {} failed: {error}", + self.socket_path.display() + ), + } + } + + if self.stream.is_some() { + if let Err(error) = self.flush_writes() { + log::warn!("Pocket Music daemon write failed: {error}"); + self.disconnect(&mut messages); + return messages; + } + if let Err(error) = self.read_lines(&mut messages) { + log::warn!("Pocket Music daemon read failed: {error}"); + self.disconnect(&mut messages); + } + } + messages + } + + fn flush_writes(&mut self) -> std::io::Result<()> { + let Some(stream) = self.stream.as_mut() else { + return Ok(()); + }; + while let Some(front) = self.writes.front_mut() { + match stream.write(front) { + Ok(0) => { + return Err(std::io::Error::new( + ErrorKind::WriteZero, + "daemon socket closed", + )); + } + Ok(written) => { + front.drain(..written); + if front.is_empty() { + self.writes.pop_front(); + } + } + Err(error) if error.kind() == ErrorKind::WouldBlock => break, + Err(error) => return Err(error), + } + } + Ok(()) + } + + fn read_lines(&mut self, messages: &mut Vec) -> std::io::Result<()> { + let Some(stream) = self.stream.as_mut() else { + return Ok(()); + }; + let mut chunk = [0_u8; 4096]; + loop { + match stream.read(&mut chunk) { + Ok(0) => { + return Err(std::io::Error::new( + ErrorKind::UnexpectedEof, + "daemon socket closed", + )); + } + Ok(read) => { + self.read_buffer.extend_from_slice(&chunk[..read]); + if self.read_buffer.len() > MAX_LINE_BYTES { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + "daemon line is too large", + )); + } + while let Some(newline) = + self.read_buffer.iter().position(|byte| *byte == b'\n') + { + let line = self.read_buffer.drain(..=newline).collect::>(); + let line = std::str::from_utf8(&line[..line.len() - 1]) + .map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?; + if valid_daemon_line(line) { + messages.push(line.to_owned()); + } else { + log::warn!("Pocket Music daemon sent an invalid message"); + } + } + } + Err(error) if error.kind() == ErrorKind::WouldBlock => break, + Err(error) => return Err(error), + } + } + Ok(()) + } + + fn disconnect(&mut self, messages: &mut Vec) { + self.stream = None; + self.read_buffer.clear(); + if self.connection_announced { + self.connection_announced = false; + messages.push(self.disconnected_line()); + } + } +} + +fn socket_path() -> PathBuf { + if let Some(path) = std::env::var_os("POCKET_MUSIC_SOCKET") { + return PathBuf::from(path); + } + let base = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + base.join("Library/Application Support/Pocket Music/pocket-music.sock") +} + +fn valid_daemon_line(line: &str) -> bool { + if line.len() > MAX_LINE_BYTES { + return false; + } + serde_json::from_str::(line) + .ok() + .and_then(|value| value.get("t").and_then(Value::as_str).map(str::to_owned)) + .is_some_and(|kind| { + matches!( + kind.as_str(), + "pocket-music.state" | "pocket-music.input" | "pocket-music.connection" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service() -> PocketMusicService { + PocketMusicService::new(CompanionSettings { + service: "pocket-music@1".into(), + channel: "pocket-music".into(), + }) + .unwrap() + } + + #[test] + fn command_filter_is_exact_and_bounded() { + let mut service = service(); + assert!(PocketMusicService::is_guest_line( + r#"{"t":"pocket-music.command","op":"toggle"}"# + )); + assert!(!PocketMusicService::is_guest_line( + r#"{"t":"pocket-music.state"}"# + )); + assert!( + service + .handle_guest_line(r#"{"t":"pocket-music.command","op":"next"}"#) + .unwrap() + ); + assert_eq!( + service.writes.pop_front().unwrap(), + b"{\"t\":\"pocket-music.command\",\"op\":\"next\"}\n" + ); + assert!( + service + .handle_guest_line(r#"{"t":"pocket-music.command","op":"volume","delta":99}"#) + .is_err() + ); + } + + #[test] + fn daemon_namespace_is_not_an_open_json_pipe() { + assert!(valid_daemon_line( + r#"{"t":"pocket-music.state","volume":50}"# + )); + assert!(!valid_daemon_line(r#"{"t":"media.state"}"#)); + assert!(!valid_daemon_line("not json")); + } +} diff --git a/hosts/ipodnano/PocketMusicDaemon.m b/hosts/ipodnano/PocketMusicDaemon.m new file mode 100644 index 00000000..336c1d05 --- /dev/null +++ b/hosts/ipodnano/PocketMusicDaemon.m @@ -0,0 +1,393 @@ +#import +#import +#import +#import +#import +#import +#import +#import + +static const int kIPodVendorID = 0x05ac; +static const int kIPodNano2ProductID = 0x1260; +static const size_t kMaximumLineBytes = 64 * 1024; + +static void HIDDeviceMatched(void *context, IOReturn result, void *sender, IOHIDDeviceRef device); +static void HIDDeviceRemoved(void *context, IOReturn result, void *sender, IOHIDDeviceRef device); +static void HIDValueReceived(void *context, IOReturn result, void *sender, IOHIDValueRef value); + +static NSString *ControlForConsumerUsage(uint32_t usage) { + switch (usage) { + case 0x00e9: return @"volume-up"; + case 0x00ea: return @"volume-down"; + case 0x00e2: return @"mute"; + case 0x00cd: return @"toggle"; + case 0x00b7: return @"stop"; + case 0x00b5: return @"next"; + case 0x00b6: return @"previous"; + default: return nil; + } +} + +static NSString *DefaultSocketPath(void) { + NSString *override = NSProcessInfo.processInfo.environment[@"POCKET_MUSIC_SOCKET"]; + if (override.length > 0) return override; + return [NSHomeDirectory() stringByAppendingPathComponent: + @"Library/Application Support/Pocket Music/pocket-music.sock"]; +} + +static NSDictionary *Track(NSString *identifier, NSString *title, NSString *artist, + NSString *album, double durationMilliseconds) { + return @{ + @"id": identifier ?: @"", + @"title": title ?: @"", + @"artist": artist ?: @"", + @"album": album ?: @"", + @"durationMs": @(MAX(0, durationMilliseconds)), + }; +} + +@interface PocketMusicDaemon : NSObject +@property(nonatomic) BOOL fixture; +@property(nonatomic) BOOL deviceConnected; +@property(nonatomic) BOOL fixturePlaying; +@property(nonatomic) NSInteger fixtureVolume; +@property(nonatomic) NSInteger sequence; +@property(nonatomic, copy) NSString *lastControl; +@property(nonatomic, copy) NSString *socketPath; +@property(nonatomic) int listener; +@property(nonatomic) IOHIDManagerRef hidManager; +@property(nonatomic, strong) dispatch_source_t listenerSource; +@property(nonatomic, strong) NSMutableDictionary *clients; +@property(nonatomic, strong) NSMutableDictionary *clientBuffers; +@property(nonatomic, copy) NSData *lastBroadcast; +@end + +@implementation PocketMusicDaemon + +- (instancetype)initWithSocketPath:(NSString *)socketPath fixture:(BOOL)fixture { + self = [super init]; + if (self) { + _fixture = fixture; + _deviceConnected = fixture; + _fixturePlaying = YES; + _fixtureVolume = 48; + _socketPath = [socketPath copy]; + _listener = -1; + _clients = [NSMutableDictionary dictionary]; + _clientBuffers = [NSMutableDictionary dictionary]; + } + return self; +} + +- (void)dealloc { + if (_hidManager) CFRelease(_hidManager); + if (_listener >= 0) close(_listener); +} + +- (NSDictionary *)musicState { + NSMutableDictionary *state = [@{ + @"t": @"pocket-music.state", + @"daemonConnected": @YES, + @"deviceConnected": @(self.deviceConnected), + @"playerRunning": @NO, + @"playing": @NO, + @"positionMs": @0, + @"volume": @0, + @"sequence": @(self.sequence), + } mutableCopy]; + if (self.lastControl.length > 0) state[@"lastControl"] = self.lastControl; + + if (self.fixture) { + state[@"playerRunning"] = @YES; + state[@"playing"] = @(self.fixturePlaying); + state[@"positionMs"] = @(42000 + self.sequence * 250); + state[@"volume"] = @(self.fixtureVolume); + state[@"track"] = Track(@"fixture-window-seat", @"Window Seat", + @"Pocket Music", @"Hardware Sessions", 240000); + return state; + } + + NSArray *music = + [NSRunningApplication runningApplicationsWithBundleIdentifier:@"com.apple.Music"]; + if (music.count == 0) return state; + NSAppleScript *script = [[NSAppleScript alloc] initWithSource: + @"tell application \"Music\"\n" + "set t to current track\n" + "return {persistent ID of t as text, name of t as text, artist of t as text, " + "album of t as text, duration of t, player position, player state as text, sound volume}\n" + "end tell"]; + NSDictionary *error = nil; + NSAppleEventDescriptor *result = [script executeAndReturnError:&error]; + if (!result || result.numberOfItems < 8) { + state[@"playerRunning"] = @YES; + state[@"error"] = @"Music state unavailable"; + return state; + } + NSString *(^textAt)(NSInteger) = ^NSString *(NSInteger index) { + return [result descriptorAtIndex:index].stringValue ?: @""; + }; + double duration = [result descriptorAtIndex:5].doubleValue * 1000.0; + double position = [result descriptorAtIndex:6].doubleValue * 1000.0; + NSString *playerState = textAt(7).lowercaseString; + state[@"playerRunning"] = @YES; + state[@"playing"] = @([playerState isEqualToString:@"playing"]); + state[@"positionMs"] = @(MAX(0, position)); + state[@"volume"] = @((NSInteger)MAX(0, MIN(100, [result descriptorAtIndex:8].int32Value))); + state[@"track"] = Track(textAt(1), textAt(2), textAt(3), textAt(4), duration); + return state; +} + +- (BOOL)runAppleScript:(NSString *)source error:(NSString **)errorText { + NSAppleScript *script = [[NSAppleScript alloc] initWithSource:source]; + NSDictionary *error = nil; + NSAppleEventDescriptor *result = [script executeAndReturnError:&error]; + if (result) return YES; + if (errorText) *errorText = error[NSAppleScriptErrorMessage] ?: @"Music command failed"; + return NO; +} + +- (void)performControl:(NSString *)control source:(NSString *)source { + NSSet *allowed = [NSSet setWithArray:@[ + @"toggle", @"next", @"previous", @"stop", @"mute", @"volume-up", @"volume-down" + ]]; + if (![allowed containsObject:control]) return; + self.sequence += 1; + self.lastControl = control; + if (self.fixture) { + if ([control isEqualToString:@"toggle"]) self.fixturePlaying = !self.fixturePlaying; + if ([control isEqualToString:@"stop"]) self.fixturePlaying = NO; + if ([control isEqualToString:@"volume-up"]) self.fixtureVolume = MIN(100, self.fixtureVolume + 2); + if ([control isEqualToString:@"volume-down"]) self.fixtureVolume = MAX(0, self.fixtureVolume - 2); + if ([control isEqualToString:@"mute"]) self.fixtureVolume = self.fixtureVolume == 0 ? 48 : 0; + } else { + NSDictionary *scripts = @{ + @"toggle": @"tell application \"Music\" to playpause", + @"next": @"tell application \"Music\" to next track", + @"previous": @"tell application \"Music\" to previous track", + @"stop": @"tell application \"Music\" to stop", + @"volume-up": @"tell application \"Music\"\nset v to sound volume + 2\nif v > 100 then set v to 100\nset sound volume to v\nend tell", + @"volume-down": @"tell application \"Music\"\nset v to sound volume - 2\nif v < 0 then set v to 0\nset sound volume to v\nend tell", + @"mute": @"tell application \"Music\"\nif sound volume is 0 then\nset sound volume to 48\nelse\nset sound volume to 0\nend if\nend tell", + }; + NSString *error = nil; + if (![self runAppleScript:scripts[control] error:&error]) { + fprintf(stderr, "pocket-music-daemon: %s\n", error.UTF8String); + } + } + NSDictionary *input = @{ + @"t": @"pocket-music.input", + @"control": control, + @"source": source, + @"sequence": @(self.sequence), + }; + [self broadcastDictionary:input force:YES]; + [self broadcastState:YES]; +} + +- (void)broadcastDictionary:(NSDictionary *)value force:(BOOL)force { + NSError *error = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:value options:0 error:&error]; + if (!json) { + fprintf(stderr, "pocket-music-daemon: JSON encode failed: %s\n", error.localizedDescription.UTF8String); + return; + } + NSMutableData *line = [json mutableCopy]; + const uint8_t newline = '\n'; + [line appendBytes:&newline length:1]; + if (!force && [line isEqualToData:self.lastBroadcast]) return; + if (!force) self.lastBroadcast = line; + for (NSNumber *descriptor in self.clients.allKeys.copy) { + ssize_t sent = send(descriptor.intValue, line.bytes, line.length, MSG_NOSIGNAL | MSG_DONTWAIT); + if ((sent < 0 && errno != EAGAIN && errno != EWOULDBLOCK) || + (sent >= 0 && (NSUInteger)sent != line.length)) { + [self removeClient:descriptor.intValue]; + } + } +} + +- (void)broadcastState:(BOOL)force { + [self broadcastDictionary:[self musicState] force:force]; +} + +- (void)removeClient:(int)descriptor { + NSNumber *key = @(descriptor); + dispatch_source_t source = self.clients[key]; + if (source) dispatch_source_cancel(source); + [self.clients removeObjectForKey:key]; + [self.clientBuffers removeObjectForKey:key]; + close(descriptor); +} + +- (void)handleClientBytes:(int)descriptor { + uint8_t bytes[4096]; + ssize_t count = recv(descriptor, bytes, sizeof(bytes), 0); + if (count <= 0) { + if (count == 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) [self removeClient:descriptor]; + return; + } + NSMutableData *buffer = self.clientBuffers[@(descriptor)]; + [buffer appendBytes:bytes length:(NSUInteger)count]; + if (buffer.length > kMaximumLineBytes) { + [self removeClient:descriptor]; + return; + } + while (YES) { + const uint8_t *raw = buffer.bytes; + NSUInteger newline = NSNotFound; + for (NSUInteger i = 0; i < buffer.length; i++) { + if (raw[i] == '\n') { newline = i; break; } + } + if (newline == NSNotFound) break; + NSData *line = [buffer subdataWithRange:NSMakeRange(0, newline)]; + [buffer replaceBytesInRange:NSMakeRange(0, newline + 1) withBytes:NULL length:0]; + NSDictionary *command = [NSJSONSerialization JSONObjectWithData:line options:0 error:nil]; + if (![command isKindOfClass:NSDictionary.class] || + ![command[@"t"] isEqual:@"pocket-music.command"] || + ![command[@"op"] isKindOfClass:NSString.class] || command.count != 2) continue; + [self performControl:command[@"op"] source:@"pocketjs-app"]; + } +} + +- (BOOL)startSocket:(NSError **)error { + NSString *directory = self.socketPath.stringByDeletingLastPathComponent; + if (![NSFileManager.defaultManager createDirectoryAtPath:directory + withIntermediateDirectories:YES attributes:nil error:error]) return NO; + self.listener = socket(AF_UNIX, SOCK_STREAM, 0); + if (self.listener < 0) return NO; + fcntl(self.listener, F_SETFL, O_NONBLOCK); + struct sockaddr_un address = {0}; + address.sun_family = AF_UNIX; + NSData *path = [self.socketPath dataUsingEncoding:NSUTF8StringEncoding]; + if (path.length >= sizeof(address.sun_path)) return NO; + memcpy(address.sun_path, path.bytes, path.length); + unlink(address.sun_path); + if (bind(self.listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(self.listener, 4) != 0) return NO; + chmod(address.sun_path, 0600); + self.listenerSource = dispatch_source_create( + DISPATCH_SOURCE_TYPE_READ, (uintptr_t)self.listener, 0, dispatch_get_main_queue()); + dispatch_source_set_event_handler(self.listenerSource, ^{ + while (YES) { + int client = accept(self.listener, NULL, NULL); + if (client < 0) break; + fcntl(client, F_SETFL, O_NONBLOCK); + NSNumber *key = @(client); + self.clientBuffers[key] = [NSMutableData data]; + dispatch_source_t readSource = dispatch_source_create( + DISPATCH_SOURCE_TYPE_READ, (uintptr_t)client, 0, dispatch_get_main_queue()); + dispatch_source_set_event_handler(readSource, ^{ [self handleClientBytes:client]; }); + self.clients[key] = readSource; + dispatch_resume(readSource); + [self broadcastState:YES]; + } + }); + dispatch_resume(self.listenerSource); + return YES; +} + +- (BOOL)startHIDSeized:(BOOL)seized { + self.hidManager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone); + NSDictionary *match = @{ + @kIOHIDVendorIDKey: @(kIPodVendorID), + @kIOHIDProductIDKey: @(kIPodNano2ProductID), + }; + IOHIDManagerSetDeviceMatching(self.hidManager, (__bridge CFDictionaryRef)match); + IOHIDManagerRegisterDeviceMatchingCallback(self.hidManager, HIDDeviceMatched, (__bridge void *)self); + IOHIDManagerRegisterDeviceRemovalCallback(self.hidManager, HIDDeviceRemoved, (__bridge void *)self); + IOHIDManagerRegisterInputValueCallback(self.hidManager, HIDValueReceived, (__bridge void *)self); + IOHIDManagerScheduleWithRunLoop(self.hidManager, CFRunLoopGetMain(), kCFRunLoopDefaultMode); + IOReturn result = IOHIDManagerOpen( + self.hidManager, seized ? kIOHIDOptionsTypeSeizeDevice : kIOHIDOptionsTypeNone); + if (result != kIOReturnSuccess) { + fprintf(stderr, "pocket-music-daemon: cannot open iPod HID manager (0x%x)\n", result); + return NO; + } + return YES; +} + +- (void)runSeized:(BOOL)seized { + NSError *error = nil; + if (![self startSocket:&error]) { + fprintf(stderr, "pocket-music-daemon: cannot listen on %s: %s\n", + self.socketPath.UTF8String, error.localizedDescription.UTF8String); + exit(1); + } + if (!self.fixture && ![self startHIDSeized:seized]) exit(1); + dispatch_source_t timer = dispatch_source_create( + DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()); + dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, 0), NSEC_PER_SEC, NSEC_PER_MSEC * 50); + dispatch_source_set_event_handler(timer, ^{ [self broadcastState:NO]; }); + dispatch_resume(timer); + fprintf(stderr, "pocket-music-daemon: listening on %s%s\n", + self.socketPath.UTF8String, self.fixture ? " (fixture)" : ""); + CFRunLoopRun(); +} + +static void HIDDeviceMatched(void *context, IOReturn result, void *sender, IOHIDDeviceRef device) { + (void)result; (void)sender; (void)device; + PocketMusicDaemon *daemon = (__bridge PocketMusicDaemon *)context; + daemon.deviceConnected = YES; + [daemon broadcastState:YES]; +} + +static void HIDDeviceRemoved(void *context, IOReturn result, void *sender, IOHIDDeviceRef device) { + (void)result; (void)sender; (void)device; + PocketMusicDaemon *daemon = (__bridge PocketMusicDaemon *)context; + daemon.deviceConnected = NO; + [daemon broadcastState:YES]; +} + +static void HIDValueReceived(void *context, IOReturn result, void *sender, IOHIDValueRef value) { + (void)result; (void)sender; + if (IOHIDValueGetIntegerValue(value) <= 0) return; + IOHIDElementRef element = IOHIDValueGetElement(value); + if (IOHIDElementGetUsagePage(element) != 0x0c) return; + NSString *control = ControlForConsumerUsage(IOHIDElementGetUsage(element)); + if (!control) return; + PocketMusicDaemon *daemon = (__bridge PocketMusicDaemon *)context; + [daemon performControl:control source:@"ipod-nano-2g"]; +} + +@end + +static BOOL SelfTest(void) { + NSDictionary *expected = @{ + @0x00e9: @"volume-up", @0x00ea: @"volume-down", @0x00e2: @"mute", + @0x00cd: @"toggle", @0x00b7: @"stop", @0x00b5: @"next", @0x00b6: @"previous", + }; + for (NSNumber *usage in expected) { + if (![ControlForConsumerUsage(usage.unsignedIntValue) isEqual:expected[usage]]) return NO; + } + if (ControlForConsumerUsage(0x1234) != nil) return NO; + PocketMusicDaemon *daemon = [[PocketMusicDaemon alloc] initWithSocketPath:@"/tmp/not-used" fixture:YES]; + [daemon performControl:@"volume-up" source:@"self-test"]; + [daemon performControl:@"toggle" source:@"self-test"]; + NSDictionary *state = [daemon musicState]; + return [state[@"volume"] integerValue] == 50 && ![state[@"playing"] boolValue] && + [state[@"sequence"] integerValue] == 2; +} + +int main(int argc, const char *argv[]) { + @autoreleasepool { + (void)argc; + (void)argv; + NSArray *arguments = NSProcessInfo.processInfo.arguments; + if ([arguments containsObject:@"--self-test"]) { + if (!SelfTest()) return 1; + puts("pocket-music-daemon: self-test passed"); + return 0; + } + BOOL fixture = [arguments containsObject:@"--fixture"]; + BOOL seized = ![arguments containsObject:@"--no-seize"]; + PocketMusicDaemon *daemon = [[PocketMusicDaemon alloc] + initWithSocketPath:DefaultSocketPath() fixture:fixture]; + if ([arguments containsObject:@"--once"]) { + NSData *json = [NSJSONSerialization dataWithJSONObject:[daemon musicState] options:0 error:nil]; + fwrite(json.bytes, 1, json.length, stdout); + fputc('\n', stdout); + return 0; + } + [daemon runSeized:seized]; + } + return 0; +} diff --git a/package.json b/package.json index 8e08fdc3..55dfb5ac 100644 --- a/package.json +++ b/package.json @@ -189,6 +189,7 @@ "play": "bun tools/play.ts", "widget": "bun tools/widget.ts", "widget:ipod": "bun tools/widget.ts --stage ipod", + "pocket-music": "bun tools/pocket-music.ts", "note": "bun tools/note.ts", "psp": "bun tools/psp.ts", "psp:all": "bun tools/psp-all.ts", diff --git a/tests/pocket-music.test.ts b/tests/pocket-music.test.ts new file mode 100644 index 00000000..18165ff7 --- /dev/null +++ b/tests/pocket-music.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + POCKET_MUSIC_SERVICE, + connectPocketMusic, + parsePocketMusicBatch, + parsePocketMusicState, +} from "../apps/pocket-music/service.ts"; +import { parseIPodFilesystem, parseIPodNanoUSB } from "../tools/pocket-music.ts"; + +const state = { + t: "pocket-music.state", + daemonConnected: true, + deviceConnected: true, + playerRunning: true, + playing: true, + positionMs: 42_000, + volume: 48, + sequence: 7, + track: { + id: "window-seat", + title: "Window Seat", + artist: "Pocket Music", + album: "Hardware Sessions", + durationMs: 240_000, + }, +} as const; + +describe("Pocket Music guest protocol", () => { + test("accepts complete daemon state and rejects malformed values", () => { + const parsed = parsePocketMusicState(JSON.stringify(state)); + expect(parsed?.daemonConnected).toBe(true); + expect(parsed?.sequence).toBe(7); + expect(parsed?.track?.title).toBe("Window Seat"); + expect(parsePocketMusicState(JSON.stringify({ ...state, volume: 101 }))).toBeNull(); + expect(parsePocketMusicState(JSON.stringify({ ...state, deviceConnected: "yes" }))).toBeNull(); + expect(parsePocketMusicState('{"t":"pocket-music.input"}')).toBeNull(); + }); + + test("retains only valid state lines from a daemon batch", () => { + expect( + parsePocketMusicBatch([JSON.stringify(state), "not json", '{"t":"future"}'].join("\n")), + ).toHaveLength(1); + }); + + test("opens only its authored channel and serializes bounded commands", () => { + const opened: string[] = []; + const sent: string[] = []; + const service = connectPocketMusic({ + svcOpen(channel) { + opened.push(channel); + return channel === POCKET_MUSIC_SERVICE; + }, + svcPoll() { + return `${JSON.stringify(state)}\n`; + }, + svcSend(line) { + sent.push(line); + }, + }); + expect(opened).toEqual(["pocket-music"]); + expect(service?.poll()).toHaveLength(1); + service?.send("volume-up"); + expect(JSON.parse(sent[0]!)).toEqual({ t: "pocket-music.command", op: "volume-up" }); + }); +}); + +describe("iPod nano 2G discovery", () => { + test("identifies the attached model from Apple USB ids", () => { + const ioreg = `+-o iPod@01130000 \n {\n "idVendor" = 1452\n "idProduct" = 4704\n "USB Serial Number" = "NANO2G-TEST"\n }`; + expect(parseIPodNanoUSB(ioreg)).toEqual({ + connected: true, + vendorId: 0x05ac, + productId: 0x1260, + serial: "NANO2G-TEST", + model: "ipod-nano-2g", + }); + expect(parseIPodNanoUSB("no device")).toEqual({ connected: false }); + }); + + test("keeps the destructive HFS to FAT32 gate explicit", () => { + expect(parseIPodFilesystem("3: Apple_HFS iPod 4.0 GB disk6s3")).toBe("hfs"); + expect(parseIPodFilesystem("3: Microsoft Basic Data iPod 4.0 GB disk6s3")).toBe("fat32"); + expect(parseIPodFilesystem("internal APFS")).toBe("unknown"); + }); +}); + +test("native daemon maps the Rockbox consumer usages and seizes only the nano", () => { + const daemon = readFileSync( + new URL("../hosts/ipodnano/PocketMusicDaemon.m", import.meta.url), + "utf8", + ); + expect(daemon).toContain("kIPodVendorID = 0x05ac"); + expect(daemon).toContain("kIPodNano2ProductID = 0x1260"); + expect(daemon).toContain("kIOHIDOptionsTypeSeizeDevice"); + for (const usage of ["0x00e9", "0x00ea", "0x00e2", "0x00cd", "0x00b7", "0x00b5", "0x00b6"]) { + expect(daemon).toContain(usage); + } +}); diff --git a/tests/widget-args.test.ts b/tests/widget-args.test.ts index d8553bcf..373efaad 100644 --- a/tests/widget-args.test.ts +++ b/tests/widget-args.test.ts @@ -111,6 +111,18 @@ describe("widget wrapper arguments", () => { }); }); + test("selects Pocket Music's app and companion profile", () => { + expect(parseWidgetArgs(["--stage", "pocket-music", "--focus"])).toEqual({ + stage: "pocket-music", + app: "pocket-music-main", + proof: false, + pass: ["--focus"], + }); + const stage = widgetStageConfig("pocket-music"); + expect(stage.profile.endsWith("/ipod-nano-2/pocket-music-profile.json")).toBe(true); + expect(stage.display).toEqual({ logicalSize: [176, 132], rasterDensity: 1 }); + }); + test("keeps profile admission within the native density contract", () => { expect( stageDisplayFacts({ display: { logical_size: [176, 132], raster_density: 4 } }), diff --git a/tools/pocket-music.ts b/tools/pocket-music.ts new file mode 100644 index 00000000..f5c1017e --- /dev/null +++ b/tools/pocket-music.ts @@ -0,0 +1,244 @@ +// Pocket Music: build/run the PocketJS app and its macOS click-wheel daemon. + +import { $ } from "bun"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPOSITORY = fileURLToPath(new URL("../", import.meta.url)); +const SOURCE = join(REPOSITORY, "hosts/ipodnano/PocketMusicDaemon.m"); +const BUILD_DIRECTORY = join(REPOSITORY, ".pocket/pocket-music/bin"); +const BUILD_BINARY = join(BUILD_DIRECTORY, "pocket-music-daemon"); +const INSTALL_DIRECTORY = join(homedir(), "Library/Application Support/Pocket Music"); +const INSTALL_BINARY = join(INSTALL_DIRECTORY, "pocket-music-daemon"); +const SOCKET = join(INSTALL_DIRECTORY, "pocket-music.sock"); +const LAUNCH_AGENT = join( + homedir(), + "Library/LaunchAgents/dev.pocket-stack.pocket-music.daemon.plist", +); + +export interface IPodNanoFacts { + readonly connected: boolean; + readonly vendorId?: number; + readonly productId?: number; + readonly serial?: string; + readonly model?: "ipod-nano-2g"; +} + +export function parseIPodNanoUSB(ioreg: string): IPodNanoFacts { + const block = ioreg.match(/\+-o iPod@[^\n]*[\s\S]*?(?=\n\s*[+|]?-o |$)/)?.[0] ?? ""; + const vendorId = Number(block.match(/"idVendor"\s*=\s*(\d+)/)?.[1]); + const productId = Number(block.match(/"idProduct"\s*=\s*(\d+)/)?.[1]); + const serial = block.match(/"USB Serial Number"\s*=\s*"([^"]+)"/)?.[1]; + if (!block || !Number.isInteger(vendorId) || !Number.isInteger(productId)) { + return { connected: false }; + } + return { + connected: true, + vendorId, + productId, + ...(serial ? { serial } : {}), + ...(vendorId === 0x05ac && productId === 0x1260 ? { model: "ipod-nano-2g" as const } : {}), + }; +} + +export function parseIPodFilesystem(diskutil: string): "hfs" | "fat32" | "unknown" { + if (/Apple_HFS\s+iPod/.test(diskutil)) return "hfs"; + if (/(Microsoft Basic Data|DOS_FAT_32|Windows_FAT_32)\s+iPod/.test(diskutil)) return "fat32"; + return "unknown"; +} + +async function run(command: string[], label: string, env = process.env): Promise { + const process = Bun.spawn(command, { + cwd: REPOSITORY, + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const exit = await process.exited; + if (exit !== 0) throw new Error(`${label} failed with exit code ${exit}`); +} + +async function buildAppProof(): Promise { + const proofDirectory = mkdtempSync(join(tmpdir(), "pocket-music-proof-")); + const socket = join(proofDirectory, "daemon.sock"); + const env = { ...process.env, POCKET_MUSIC_SOCKET: socket }; + const fixture = Bun.spawn([BUILD_BINARY, "--fixture"], { + cwd: REPOSITORY, + env, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + try { + for (let attempt = 0; attempt < 100 && !existsSync(socket); attempt += 1) { + if (fixture.exitCode !== null) { + throw new Error(`Pocket Music fixture exited with code ${fixture.exitCode}`); + } + await Bun.sleep(20); + } + if (!existsSync(socket)) throw new Error("Pocket Music fixture did not create its socket"); + await run( + [ + "bun", + "tools/widget.ts", + "--stage", + "pocket-music", + "--screenshot", + "dist/pocket-music-proof.png", + "--frames", + "120", + "--expect-ui-hash", + "0xba84c1ade3dbcc3f", + "--focus", + ], + "Pocket Music app build", + env, + ); + } finally { + if (fixture.exitCode === null) fixture.kill("SIGTERM"); + await fixture.exited; + rmSync(proofDirectory, { recursive: true, force: true }); + } +} + +export async function buildDaemon(output = BUILD_BINARY): Promise { + mkdirSync(dirname(output), { recursive: true }); + await run( + [ + "xcrun", + "clang", + "-fobjc-arc", + "-Wall", + "-Wextra", + "-Werror", + "-framework", + "Foundation", + "-framework", + "AppKit", + "-framework", + "IOKit", + SOURCE, + "-o", + output, + ], + "Pocket Music daemon build", + ); + await run(["codesign", "--force", "--sign", "-", output], "Pocket Music daemon signing"); + await run([output, "--self-test"], "Pocket Music daemon self-test"); +} + +function launchAgentPlist(): string { + return ` + + + + Label + dev.pocket-stack.pocket-music.daemon + ProgramArguments + + ${INSTALL_BINARY} + + EnvironmentVariables + + POCKET_MUSIC_SOCKET + ${SOCKET} + + KeepAlive + + ProcessType + Interactive + StandardOutPath + ${join(INSTALL_DIRECTORY, "daemon.log")} + StandardErrorPath + ${join(INSTALL_DIRECTORY, "daemon.log")} + + +`; +} + +async function installDaemon(): Promise { + await buildDaemon(INSTALL_BINARY); + mkdirSync(dirname(LAUNCH_AGENT), { recursive: true }); + writeFileSync(LAUNCH_AGENT, launchAgentPlist(), { mode: 0o600 }); + await run(["plutil", "-lint", LAUNCH_AGENT], "LaunchAgent validation"); + const domain = `gui/${process.getuid?.() ?? 0}`; + Bun.spawnSync(["launchctl", "bootout", domain, LAUNCH_AGENT], { + stdout: "ignore", + stderr: "ignore", + }); + await run(["launchctl", "bootstrap", domain, LAUNCH_AGENT], "LaunchAgent install"); + await run( + ["launchctl", "kickstart", "-k", `${domain}/dev.pocket-stack.pocket-music.daemon`], + "Pocket Music daemon start", + ); + console.log(`Pocket Music daemon installed: ${INSTALL_BINARY}`); +} + +async function doctor(): Promise { + const [ioreg, disks, hid] = await Promise.all([ + $`ioreg -p IOUSB -l -w 0`.text(), + $`diskutil list`.text(), + $`ioreg -r -c IOHIDDevice -l -w 0`.text(), + ]); + const device = parseIPodNanoUSB(ioreg); + const filesystem = parseIPodFilesystem(disks); + console.log(`device: ${device.model === "ipod-nano-2g" ? "iPod nano 2G (05ac:1260)" : "not verified"}`); + console.log(`serial: ${device.serial ?? "unavailable"}`); + console.log(`filesystem: ${filesystem}`); + console.log(`rockbox: ${existsSync("/Volumes/iPod/.rockbox/rockbox-info.txt") ? "installed" : "not installed"}`); + console.log(`hid: ${/"VendorID"\s*=\s*1452[\s\S]*"ProductID"\s*=\s*4704/.test(hid) ? "available" : "not enumerated"}`); + console.log(`daemon: ${existsSync(INSTALL_BINARY) ? "installed" : "not installed"}`); + if (device.model !== "ipod-nano-2g") throw new Error("the attached device is not iPod nano 2G"); + if (filesystem === "hfs") { + console.log("gate: Rockbox requires FAT32; back up and restore/format the iPod before installation"); + } +} + +function usage(message?: string): never { + if (message) console.error(`pocket-music: ${message}\n`); + console.error( + "usage: bun pocket-music doctor\n" + + " bun pocket-music build\n" + + " bun pocket-music daemon [--fixture] [--no-seize]\n" + + " bun pocket-music run [pocket-stage flags]\n" + + " bun pocket-music install-daemon", + ); + process.exit(message ? 2 : 0); +} + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + if (!command || command === "--help" || command === "-h") usage(); + switch (command) { + case "doctor": + if (args.length) usage("doctor takes no arguments"); + await doctor(); + break; + case "build": + if (args.length) usage("build takes no arguments"); + await buildDaemon(); + await buildAppProof(); + break; + case "daemon": + if (args.some((arg) => arg !== "--fixture" && arg !== "--no-seize")) { + usage("daemon accepts only --fixture and --no-seize"); + } + await buildDaemon(); + await run([BUILD_BINARY, ...args], "Pocket Music daemon"); + break; + case "run": + await run(["bun", "tools/widget.ts", "--stage", "pocket-music", ...args], "Pocket Music"); + break; + case "install-daemon": + if (args.length) usage("install-daemon takes no arguments"); + await installDaemon(); + break; + default: + usage(`unknown command ${command}`); + } +} + +if (import.meta.main) await main(); diff --git a/tools/test.ts b/tools/test.ts index bbdb6ee5..dafc0c06 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -43,6 +43,7 @@ const SUITE: readonly Stage[] = [ "tests/pocket-package.test.ts", "tests/widget-args.test.ts", "tests/ipod-nano.test.ts", + "tests/pocket-music.test.ts", "tests/note.test.ts", "tests/site-stage.test.ts", "tests/host-build-inputs.test.ts", diff --git a/tools/widget.ts b/tools/widget.ts index 37ed58f6..4c7adb29 100644 --- a/tools/widget.ts +++ b/tools/widget.ts @@ -33,7 +33,7 @@ export const STAGE_TARGET_ID = "macos-embedded"; // Same current desktop HostOps wire generation as macos-widget; form and // capabilities differ even though the native UI surface implementation is shared. export const STAGE_HOST_ABI = 3; -export type WidgetStage = "psp" | "ipod"; +export type WidgetStage = "psp" | "ipod" | "pocket-music"; export interface StageDisplayFacts { readonly logicalSize: readonly [number, number]; @@ -58,6 +58,13 @@ const STAGE_REGISTRY: Record Date: Sun, 16 Aug 2026 16:20:41 +0800 Subject: [PATCH 2/2] feat(ipodnano): brand Rockbox USB screen --- docs/IPODNANO.md | 38 +++++- hosts/ipodnano/PocketMusicDaemon.m | 8 +- hosts/ipodnano/rockbox/usblogo.128x37x16.bmp | Bin 0 -> 14262 bytes hosts/ipodnano/rockbox/usblogo.128x37x16.svg | 5 + package.json | 1 + tests/platform-contracts.test.ts | 1 + tests/pocket-music.test.ts | 30 +++++ tools/ipodnano-rockbox.ts | 118 +++++++++++++++++++ tools/pocket-music.ts | 15 ++- 9 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 hosts/ipodnano/rockbox/usblogo.128x37x16.bmp create mode 100644 hosts/ipodnano/rockbox/usblogo.128x37x16.svg create mode 100644 tools/ipodnano-rockbox.ts diff --git a/docs/IPODNANO.md b/docs/IPODNANO.md index e9ab55cf..d109cd71 100644 --- a/docs/IPODNANO.md +++ b/docs/IPODNANO.md @@ -44,6 +44,32 @@ The daemon exposes a mode-`0600` Unix socket under guest command namespace, allowed operations, daemon event namespace, and a 64 KiB line limit. The PocketJS guest cannot invoke AppleScript or open the HID device directly. +## USB connection branding + +Rockbox compiles the nano 2G connection graphic from +`apps/bitmaps/native/usblogo.128x37x16.bmp`. The PocketJS replacement is stored at +`hosts/ipodnano/rockbox/usblogo.128x37x16.bmp`; its SVG source is next to it. +**The bitmap remains 128×37, 24-bit BMP, and is positioned so `PocketJS` is centered +on the 176-pixel display.** The `Multimedia` line remains the active HID mode name. + +Apply the bitmap to a Rockbox checkout: + +```sh +bun ipodnano:rockbox apply /path/to/rockbox +``` + +Rockbox recommends `arm-elf-eabi-gcc` 9.5.0 for this target. With that toolchain in +`PATH`, build only the firmware core needed for deployment: + +```sh +bun ipodnano:rockbox build /path/to/rockbox /path/to/build-ipodnano2g +``` + +The output is `/path/to/build-ipodnano2g/rockbox.ipod`. Replace +`.rockbox/rockbox.ipod` on the mounted FAT32 iPod, eject it, reboot, and reconnect it. +**Only the Rockbox firmware file changes; the installed bootloader and its preserved +Apple firmware entry do not change.** + ## Build and run Build the Objective-C daemon with warnings as errors, run its mapping self-test, build @@ -80,13 +106,13 @@ macOS. Rockbox adds the USB HID interface used by the daemon. The official Rockb manual states that **Rockbox on this target requires FAT32 and does not run from an HFS+ iPod**. -The attached 4 GB unit is currently HFS. Converting it to FAT32 erases its music and -settings. Do not convert or install the bootloader until all of these checks pass: +Converting an HFS+ iPod to FAT32 erases its music and settings. Before conversion, +confirm all of these device-specific facts: -1. Copy the mounted volume to a separate local backup and verify the copied file count - and hashes. -2. Save a raw image of the whole 4.1 GB device and verify that the image size matches - `diskutil info`. +1. Confirm the exact generation and capacity; Nano 1G, Nano 2G, and iPod Classic + partition layouts are not interchangeable. +2. Decide whether the existing music and settings are disposable. If they are not, + copy them elsewhere before formatting. 3. Confirm Finder can restore this exact iPod and that the original Apple firmware can still boot. 4. Use the official `ipodnano2g` Rockbox build and the Nano 2G `.ipodx` bootloader; diff --git a/hosts/ipodnano/PocketMusicDaemon.m b/hosts/ipodnano/PocketMusicDaemon.m index 336c1d05..96acd47b 100644 --- a/hosts/ipodnano/PocketMusicDaemon.m +++ b/hosts/ipodnano/PocketMusicDaemon.m @@ -339,10 +339,16 @@ static void HIDDeviceRemoved(void *context, IOReturn result, void *sender, IOHID static void HIDValueReceived(void *context, IOReturn result, void *sender, IOHIDValueRef value) { (void)result; (void)sender; - if (IOHIDValueGetIntegerValue(value) <= 0) return; + CFIndex integerValue = IOHIDValueGetIntegerValue(value); + if (integerValue <= 0) return; IOHIDElementRef element = IOHIDValueGetElement(value); if (IOHIDElementGetUsagePage(element) != 0x0c) return; NSString *control = ControlForConsumerUsage(IOHIDElementGetUsage(element)); + if (!control && integerValue <= UINT32_MAX) { + // Rockbox emits Consumer Control as an array. In that report shape the + // selected usage is the value; the element usage describes the array. + control = ControlForConsumerUsage((uint32_t)integerValue); + } if (!control) return; PocketMusicDaemon *daemon = (__bridge PocketMusicDaemon *)context; [daemon performControl:control source:@"ipod-nano-2g"]; diff --git a/hosts/ipodnano/rockbox/usblogo.128x37x16.bmp b/hosts/ipodnano/rockbox/usblogo.128x37x16.bmp new file mode 100644 index 0000000000000000000000000000000000000000..47bf4c07c420c65988ffbb1f0f4a50a38d1e49af GIT binary patch literal 14262 zcmeI12T)Z<6o$nvpvHn-Y{^6v(HIMeJ*E+jnnV$>YYb7;XiO}lG4_I8K-5UYNQk|F zN>Nm-SW!X2-UY#mC3Pkr9%tvyg-8sJ&zbC-`|h0m_w4T3e|OJ0`(EpIn;YkO@@d4o zCg1P*zQNZjPdVO{pVmJq=$B?dGoTsJ3}^;41DXNNfM!55pc&8%Xa+O`ngPwg|AK+P z9%qF9v^2-5)~@BSDs*|;^~;a%-8y?JVa|-{cBM-jTfJ4c?xyu??%up{JLTG*oiSZH zce1jw5`~zkEr3Slphba<`$J%WfMx9L{O8WTdf^P6UpkuvZeO{w(x;zgL5uIFr`}1u zUb<8%@aom7f?PhA{OE3aa>B966UJFvTPuALI=25feBJ7lD;IvtxPw&(_QsAMJEnX& z`60}tYsuoj;lELXx9_E;Fp;$@Lt#ELd;_gcCmvTz(70iPj9aM>@1%tU2L<}gyKwp> z3e%@dl!=-6r?)>J-A}!Gu~f;DC{!q4{(N#G`ca!UvI<)^uA?^n#+BD=RF~#Wo48PW zE-7*DOy6R~ik2%|_U5%qAjb~HZCta8RYTsoC0s3c-n@B(mn;ILpCfU5H>_S65w`x? z#j_yH;v=^YEJ=5kv9T)0{m)EMYu&1)YVFhC8)Q*{pCIuE_cK{n7iTHj+S<_MkB9fz zs31oEVQJs49klJ)y{jm5tSEYUbde(O7R{Tnv3G6XDu{=BXA}ne43OfmA%iKh%2H(K zLo$dOU2ID@ddQ*Xo1(*;aT$X1#UWx56>f0>%b(4EJtrr0*z-nH^XJdc(SC6ICez3B zpui3^D!6z03~<1gUjQ2Uhj#ECTO(z=zE-?A%XIX>K4V+tPdj!PI(SgQf(4P`O?5?a z$-)H`o4GboO-6k4H3;h>$nnDmC_2?~R0>MKvr*Nqt(!tDE@02I`S0rKfzIfW!%e3C zyzEDC=SB^?clAP;GI6{~K^5U2l35Wp-t|l8e!YL2Xd<=ZTUd_v?b>F2#6g+(mvJ3y z*HZnF!uo#=-%rTB9&?`V>r6N6%PgDH(g7&40hXz0v9K-Ops9@6<`)?4{59 z^gp0f-?$>wysuko9^clT5OkAH)&4Mx}W&7aUTefJ9VU;UYBDNmdzuRQw;kZ3C zBzr*tAu;~2Q33x|}0ufFeIY>5Ds8Au~|G+<| z!?TM!nGbodnjOjW)JYSJt8X09QZnWl{ZGDp`8cQZXU_!ZTofo!0K>x8uBJ#HqKxe3 z{S7Kbju7V_<1Ql zFaMB_Vf4U5mdm*wGH4)iim9nVUmXD>jr=Ra_Qr0f3ikrSZRp~eQ=9_kwa|Y)?t&_O zsq#@(?Cr(1?%Wm?5*!4{D8xi=mXogdm#)b6NiU^m5E~oo{ktfYou|jZQid&$`h0 zEZ?aFS*en3fF<%^hTz7xiGM;B%6)tHH0p@VD7c`kxCIeR&6fXPmIqTs(wKYOO#DA@ zhtKPO@HSXJ$fv()Ap9ygXVZjRwwNvdBz}ay-yhs1w&vcvm&`vuToC2CXw|j z%7vQAs$WG^m1PA?!gUj2)}!Yo0bUOisaSh?gsM>9|HIHO~85wVF mId-cRGy|Fe&46Y=GoTsJ3}^;41DXNNfM!55pc!~k4EzlhQiAmW literal 0 HcmV?d00001 diff --git a/hosts/ipodnano/rockbox/usblogo.128x37x16.svg b/hosts/ipodnano/rockbox/usblogo.128x37x16.svg new file mode 100644 index 00000000..e3de65ed --- /dev/null +++ b/hosts/ipodnano/rockbox/usblogo.128x37x16.svg @@ -0,0 +1,5 @@ + + + + PocketJS + diff --git a/package.json b/package.json index 55dfb5ac..98d918a6 100644 --- a/package.json +++ b/package.json @@ -190,6 +190,7 @@ "widget": "bun tools/widget.ts", "widget:ipod": "bun tools/widget.ts --stage ipod", "pocket-music": "bun tools/pocket-music.ts", + "ipodnano:rockbox": "bun tools/ipodnano-rockbox.ts", "note": "bun tools/note.ts", "psp": "bun tools/psp.ts", "psp:all": "bun tools/psp-all.ts", diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index c5be4d56..2f970885 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -350,6 +350,7 @@ describe("semantic resolution", () => { "meizu-m8-demo": [false, false, false], // admitted only by the private meizu-m8-dev profile nsengine: [false, true, false], // targets the private ios-dev profile; vita shares its touch + integer-fit contract "ipod-nano": [false, false, false], // admitted by the package-shaped macos-embedded target + "pocket-music": [false, false, false], // admitted by the package-shaped macos-embedded target launcher: [true, true, false], // the Cover Flow deck (docs/LAUNCHER.md) is an ordinary console app library: [true, true, false], motions: [true, true, false], diff --git a/tests/pocket-music.test.ts b/tests/pocket-music.test.ts index 18165ff7..d2f94e7d 100644 --- a/tests/pocket-music.test.ts +++ b/tests/pocket-music.test.ts @@ -7,6 +7,7 @@ import { parsePocketMusicState, } from "../apps/pocket-music/service.ts"; import { parseIPodFilesystem, parseIPodNanoUSB } from "../tools/pocket-music.ts"; +import { parseBmpFacts } from "../tools/ipodnano-rockbox.ts"; const state = { t: "pocket-music.state", @@ -78,13 +79,41 @@ describe("iPod nano 2G discovery", () => { expect(parseIPodNanoUSB("no device")).toEqual({ connected: false }); }); + test("identifies the same hardware under Rockbox's USB product name", () => { + const ioreg = `+-o Rockbox media player@01130000 \n {\n "idVendor" = 1452\n "idProduct" = 4704\n }`; + expect(parseIPodNanoUSB(ioreg)).toEqual({ + connected: true, + vendorId: 0x05ac, + productId: 0x1260, + model: "ipod-nano-2g", + }); + }); + test("keeps the destructive HFS to FAT32 gate explicit", () => { expect(parseIPodFilesystem("3: Apple_HFS iPod 4.0 GB disk6s3")).toBe("hfs"); expect(parseIPodFilesystem("3: Microsoft Basic Data iPod 4.0 GB disk6s3")).toBe("fat32"); + expect(parseIPodFilesystem("1: DOS_FAT_32 IPOD 4.0 GB disk6s2")).toBe("fat32"); + expect(parseIPodFilesystem("2: Apple_HFS ipodpatcher-5.0 10.6 MB disk9s2")).toBe( + "unknown", + ); expect(parseIPodFilesystem("internal APFS")).toBe("unknown"); }); }); +test("Rockbox USB branding is the exact nano 2G bitmap contract", () => { + const logo = readFileSync( + new URL("../hosts/ipodnano/rockbox/usblogo.128x37x16.bmp", import.meta.url), + ); + const source = readFileSync( + new URL("../hosts/ipodnano/rockbox/usblogo.128x37x16.svg", import.meta.url), + "utf8", + ); + expect(parseBmpFacts(logo)).toEqual({ width: 128, height: 37, bitsPerPixel: 24 }); + expect(source).toContain(">PocketJS"); + expect(source).toContain('x="40"'); + expect(source).toContain('text-anchor="middle"'); +}); + test("native daemon maps the Rockbox consumer usages and seizes only the nano", () => { const daemon = readFileSync( new URL("../hosts/ipodnano/PocketMusicDaemon.m", import.meta.url), @@ -93,6 +122,7 @@ test("native daemon maps the Rockbox consumer usages and seizes only the nano", expect(daemon).toContain("kIPodVendorID = 0x05ac"); expect(daemon).toContain("kIPodNano2ProductID = 0x1260"); expect(daemon).toContain("kIOHIDOptionsTypeSeizeDevice"); + expect(daemon).toContain("ControlForConsumerUsage((uint32_t)integerValue)"); for (const usage of ["0x00e9", "0x00ea", "0x00e2", "0x00cd", "0x00b7", "0x00b5", "0x00b6"]) { expect(daemon).toContain(usage); } diff --git a/tools/ipodnano-rockbox.ts b/tools/ipodnano-rockbox.ts new file mode 100644 index 00000000..3865a34d --- /dev/null +++ b/tools/ipodnano-rockbox.ts @@ -0,0 +1,118 @@ +// Apply PocketJS USB branding to a Rockbox source tree and build the nano 2G core. + +import { availableParallelism } from "node:os"; +import { dirname, join } from "node:path"; +import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const REPOSITORY = fileURLToPath(new URL("../", import.meta.url)); +const POCKETJS_USB_LOGO = join( + REPOSITORY, + "hosts/ipodnano/rockbox/usblogo.128x37x16.bmp", +); +const ROCKBOX_USB_LOGO = "apps/bitmaps/native/usblogo.128x37x16.bmp"; + +export interface BmpFacts { + readonly width: number; + readonly height: number; + readonly bitsPerPixel: number; +} + +export function parseBmpFacts(bytes: Uint8Array): BmpFacts { + if (bytes.byteLength < 30 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) { + throw new Error("USB logo is not a Windows BMP"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { + width: view.getInt32(18, true), + height: Math.abs(view.getInt32(22, true)), + bitsPerPixel: view.getUint16(28, true), + }; +} + +export function applyPocketJSUsbLogo(rockboxSource: string): string { + const configure = join(rockboxSource, "tools/configure"); + const destination = join(rockboxSource, ROCKBOX_USB_LOGO); + if (!existsSync(configure) || !existsSync(destination)) { + throw new Error(`${rockboxSource} is not a Rockbox source tree with the nano USB logo`); + } + const facts = parseBmpFacts(readFileSync(POCKETJS_USB_LOGO)); + if (facts.width !== 128 || facts.height !== 37 || facts.bitsPerPixel !== 24) { + throw new Error(`unexpected PocketJS USB logo format: ${JSON.stringify(facts)}`); + } + copyFileSync(POCKETJS_USB_LOGO, destination); + return destination; +} + +async function run(command: string[], cwd: string): Promise { + const child = Bun.spawn(command, { + cwd, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const exit = await child.exited; + if (exit !== 0) throw new Error(`${command[0]} exited with code ${exit}`); +} + +async function build( + rockboxSource: string, + buildDirectory: string, + compilerPrefix?: string, +): Promise { + const logo = applyPocketJSUsbLogo(rockboxSource); + mkdirSync(buildDirectory, { recursive: true }); + const configure = [ + join(rockboxSource, "tools/configure"), + "--target=ipodnano2g", + "--type=N", + ...(compilerPrefix ? [`--compiler-prefix=${compilerPrefix}`] : []), + ]; + await run(configure, buildDirectory); + await run( + ["make", `-j${Math.max(1, availableParallelism())}`, "bin"], + buildDirectory, + ); + const output = join(buildDirectory, "rockbox.ipod"); + if (!existsSync(output)) throw new Error("Rockbox build did not produce rockbox.ipod"); + const hash = new Bun.CryptoHasher("sha256").update(readFileSync(output)).digest("hex"); + console.log(`PocketJS USB logo: ${logo}`); + console.log(`Rockbox firmware: ${output}`); + console.log(`SHA-256: ${hash}`); +} + +function usage(message?: string): never { + if (message) console.error(`ipodnano-rockbox: ${message}\n`); + console.error( + "usage: bun ipodnano:rockbox apply \n" + + " bun ipodnano:rockbox build " + + "[--compiler-prefix=]", + ); + process.exit(message ? 2 : 0); +} + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + if (!command || command === "--help" || command === "-h") usage(); + if (command === "apply") { + if (args.length !== 1) usage("apply requires one Rockbox source directory"); + console.log(`PocketJS USB logo: ${applyPocketJSUsbLogo(args[0]!)}`); + return; + } + if (command === "build") { + const compilerArg = args.find((arg) => arg.startsWith("--compiler-prefix=")); + const positional = args.filter((arg) => !arg.startsWith("--compiler-prefix=")); + if (positional.length !== 2 || args.some((arg) => arg.startsWith("--") && arg !== compilerArg)) { + usage("build requires source and build directories"); + } + await build( + positional[0]!, + positional[1]!, + compilerArg?.slice("--compiler-prefix=".length), + ); + return; + } + usage(`unknown command ${command}`); +} + +if (import.meta.main) await main(); diff --git a/tools/pocket-music.ts b/tools/pocket-music.ts index f5c1017e..e747b545 100644 --- a/tools/pocket-music.ts +++ b/tools/pocket-music.ts @@ -27,7 +27,9 @@ export interface IPodNanoFacts { } export function parseIPodNanoUSB(ioreg: string): IPodNanoFacts { - const block = ioreg.match(/\+-o iPod@[^\n]*[\s\S]*?(?=\n\s*[+|]?-o |$)/)?.[0] ?? ""; + const block = + ioreg.match(/\+-o (?:iPod|Rockbox media player)@[^\n]*[\s\S]*?(?=\n\s*[+|]?-o |$)/)?.[0] ?? + ""; const vendorId = Number(block.match(/"idVendor"\s*=\s*(\d+)/)?.[1]); const productId = Number(block.match(/"idProduct"\s*=\s*(\d+)/)?.[1]); const serial = block.match(/"USB Serial Number"\s*=\s*"([^"]+)"/)?.[1]; @@ -44,8 +46,10 @@ export function parseIPodNanoUSB(ioreg: string): IPodNanoFacts { } export function parseIPodFilesystem(diskutil: string): "hfs" | "fat32" | "unknown" { - if (/Apple_HFS\s+iPod/.test(diskutil)) return "hfs"; - if (/(Microsoft Basic Data|DOS_FAT_32|Windows_FAT_32)\s+iPod/.test(diskutil)) return "fat32"; + if (/Apple_HFS\s+iPod\s/i.test(diskutil)) return "hfs"; + if (/(Microsoft Basic Data|DOS_FAT_32|Windows_FAT_32)\s+iPod\s/i.test(diskutil)) { + return "fat32"; + } return "unknown"; } @@ -188,7 +192,10 @@ async function doctor(): Promise { console.log(`device: ${device.model === "ipod-nano-2g" ? "iPod nano 2G (05ac:1260)" : "not verified"}`); console.log(`serial: ${device.serial ?? "unavailable"}`); console.log(`filesystem: ${filesystem}`); - console.log(`rockbox: ${existsSync("/Volumes/iPod/.rockbox/rockbox-info.txt") ? "installed" : "not installed"}`); + const rockboxInstalled = ["/Volumes/iPod", "/Volumes/IPOD"].some((mount) => + existsSync(join(mount, ".rockbox/rockbox-info.txt")), + ); + console.log(`rockbox: ${rockboxInstalled ? "installed" : "not installed"}`); console.log(`hid: ${/"VendorID"\s*=\s*1452[\s\S]*"ProductID"\s*=\s*4704/.test(hid) ? "available" : "not enumerated"}`); console.log(`daemon: ${existsSync(INSTALL_BINARY) ? "installed" : "not installed"}`); if (device.model !== "ipod-nano-2g") throw new Error("the attached device is not iPod nano 2G");