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..d109cd71
--- /dev/null
+++ b/docs/IPODNANO.md
@@ -0,0 +1,132 @@
+# 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.
+
+## 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
+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**.
+
+Converting an HFS+ iPod to FAT32 erases its music and settings. Before conversion,
+confirm all of these device-specific facts:
+
+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;
+ 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..96acd47b
--- /dev/null
+++ b/hosts/ipodnano/PocketMusicDaemon.m
@@ -0,0 +1,399 @@
+#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;
+ 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"];
+}
+
+@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/hosts/ipodnano/rockbox/usblogo.128x37x16.bmp b/hosts/ipodnano/rockbox/usblogo.128x37x16.bmp
new file mode 100644
index 00000000..47bf4c07
Binary files /dev/null and b/hosts/ipodnano/rockbox/usblogo.128x37x16.bmp differ
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 @@
+
diff --git a/package.json b/package.json
index 8e08fdc3..98d918a6 100644
--- a/package.json
+++ b/package.json
@@ -189,6 +189,8 @@
"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",
+ "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
new file mode 100644
index 00000000..d2f94e7d
--- /dev/null
+++ b/tests/pocket-music.test.ts
@@ -0,0 +1,129 @@
+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";
+import { parseBmpFacts } from "../tools/ipodnano-rockbox.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("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),
+ "utf8",
+ );
+ 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/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/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
new file mode 100644
index 00000000..e747b545
--- /dev/null
+++ b/tools/pocket-music.ts
@@ -0,0 +1,251 @@
+// 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|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];
+ 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\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";
+}
+
+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}`);
+ 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");
+ 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