commit f3d241bfc78dd5f669b8018660d7dd4bfbc0305f
parent 8a303ac6a762be9cd7e33b8e17bb161945ef6031
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Sun, 31 May 2026 23:39:12 -0500
P3a: rename the Hydra device from the TUI (manifest-driven device name)
The driver now reads its display name from the manifest the daemon writes, so
the virtual device can be renamed without rebuilding the driver. Full chain,
verified end-to-end on the host (no sudo): TUI 'n' → SetDeviceName IPC → daemon
writes /Library/Application Support/hydra/devices.json → driver's reader parses
it → name applied at next coreaudiod load.
- driver/hydra_cfg.{c,h}: dependency-free manifest reader (no JSON lib vendored).
Unit-tested 6 cases: real name, missing file, escaped quotes ("My \"Cool\"
Mix"), malformed JSON, top-level-name decoy (anchors to devices[]), empty
devices. All non-happy paths fall back to the compile-time default, so a
bad/missing manifest can NEVER produce a broken driver.
- BlackHole.c get_device_name(): reads cached manifest name, falls through to
the kDevice_Name default. Only edit to upstream; builds clean (0 errors),
factory symbol intact, universal+signed.
- build-driver.sh compiles hydra_cfg.c alongside BlackHole.c.
- hydrad: SetDeviceName command writes a 1-device manifest; clear error if the
manifest dir isn't writable ("is the driver installed?").
- install-driver.sh: manifest dir is now admin-group-writable + world-readable
so the user-session daemon can write it AND _coreaudiod can read it.
- TUI: 'n' opens a rename prompt (footer text entry, ⏎ apply / esc cancel);
auto-refresh pauses while typing.
SCOPE: this is P3a (rename). Multiple devices / arbitrary channel counts (P3b)
is the larger refactor — 78 kObjectID_Device + 41 Device2 refs make BlackHole's
object model fully static — and I won't half-build it blind since driver changes
can't be behavior-tested without your sudo install. Name-rename is the verifiable
slice of P3.
19 tests green, 0 warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
11 files changed, 238 insertions(+), 5 deletions(-)
diff --git a/crates/hydra-ipc/src/lib.rs b/crates/hydra-ipc/src/lib.rs
@@ -52,6 +52,10 @@ pub enum Command {
SetGain { id: String, gain: f32 },
/// Live mute/unmute a route.
SetMute { id: String, muted: bool },
+ /// Rename the Hydra virtual device. Writes the driver manifest
+ /// (/Library/Application Support/hydra/devices.json); the new name takes effect on the
+ /// next coreaudiod restart (the driver reads the manifest at load).
+ SetDeviceName { name: String },
/// Opt this connection into the server-push event stream (state deltas, meters).
Subscribe,
/// Ask the daemon to exit.
diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs
@@ -41,6 +41,8 @@ pub struct App {
pub app_sel: usize,
pub route_sel: usize,
pub status: String,
+ /// When `Some`, the device-rename prompt is open and holds the in-progress text.
+ pub rename_buf: Option<String>,
pub should_quit: bool,
}
@@ -58,12 +60,62 @@ impl App {
app_sel: 0,
route_sel: 0,
status: String::new(),
+ rename_buf: None,
should_quit: false,
};
app.refresh();
app
}
+ /// Open the device-rename prompt, seeded with the current text.
+ pub fn begin_rename(&mut self) {
+ self.rename_buf = Some(String::new());
+ self.status = "rename Hydra device: type a name, ⏎ to apply, esc to cancel".into();
+ }
+
+ /// Append a typed char to the rename buffer (ignored if the prompt isn't open).
+ pub fn rename_push(&mut self, c: char) {
+ if let Some(buf) = self.rename_buf.as_mut() {
+ buf.push(c);
+ }
+ }
+
+ /// Backspace in the rename buffer.
+ pub fn rename_backspace(&mut self) {
+ if let Some(buf) = self.rename_buf.as_mut() {
+ buf.pop();
+ }
+ }
+
+ pub fn rename_cancel(&mut self) {
+ self.rename_buf = None;
+ self.status = "rename cancelled".into();
+ }
+
+ /// Commit the rename: write the manifest via the daemon. Takes effect on the next
+ /// coreaudiod restart (the driver reads the device name at load).
+ pub fn rename_commit(&mut self) {
+ let Some(name) = self.rename_buf.take() else { return };
+ let name = name.trim().to_string();
+ if name.is_empty() {
+ self.status = "rename cancelled (empty)".into();
+ return;
+ }
+ match client::request(Command::SetDeviceName { name: name.clone() }) {
+ Ok(Response::Ok) => {
+ self.status = format!("device → \"{name}\" (restart coreaudiod to apply: sudo killall coreaudiod)")
+ }
+ Ok(Response::Error(e)) => self.status = e,
+ Ok(other) => self.status = format!("unexpected: {other:?}"),
+ Err(e) => self.status = format!("rename failed: {e}"),
+ }
+ }
+
+ /// Whether the rename prompt is currently capturing input.
+ pub fn is_renaming(&self) -> bool {
+ self.rename_buf.is_some()
+ }
+
/// Pull connection status, app list, output devices, and routes from the daemon.
pub fn refresh(&mut self) {
match client::request(Command::Ping) {
diff --git a/crates/hydra/src/main.rs b/crates/hydra/src/main.rs
@@ -56,7 +56,9 @@ fn run(terminal: &mut Tui, theme: Theme) -> Result<(), Box<dyn Error>> {
}
}
- if last_refresh.elapsed() >= REFRESH {
+ // Don't poll the daemon while the rename prompt is open — it would overwrite the
+ // status line and fight the text the user is typing.
+ if !app.is_renaming() && last_refresh.elapsed() >= REFRESH {
app.refresh();
last_refresh = Instant::now();
}
@@ -65,6 +67,17 @@ fn run(terminal: &mut Tui, theme: Theme) -> Result<(), Box<dyn Error>> {
}
fn handle_key(app: &mut App, code: KeyCode) {
+ // The rename prompt captures all keys while open.
+ if app.is_renaming() {
+ match code {
+ KeyCode::Enter => app.rename_commit(),
+ KeyCode::Esc => app.rename_cancel(),
+ KeyCode::Backspace => app.rename_backspace(),
+ KeyCode::Char(c) => app.rename_push(c),
+ _ => {}
+ }
+ return;
+ }
match code {
KeyCode::Char('q') | KeyCode::Esc => app.quit(),
KeyCode::Char('r') => app.refresh(),
@@ -76,6 +89,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
KeyCode::Char('c') => app.combine_marked(),
KeyCode::Char('o') => app.cycle_output(),
KeyCode::Char('a') => app.toggle_show_all(),
+ KeyCode::Char('n') => app.begin_rename(),
KeyCode::Char('m') => app.toggle_mute_selected(),
KeyCode::Char('d') | KeyCode::Char('x') => app.stop_selected(),
KeyCode::Char('+') | KeyCode::Char('=') => app.adjust_gain(true),
diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs
@@ -143,12 +143,25 @@ fn draw_routes(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
+ // While renaming, the footer becomes the text-entry prompt.
+ if let Some(buf) = &app.rename_buf {
+ let line = Line::from(vec![
+ Span::styled(" rename device ", Style::default().fg(theme.bg).bg(theme.accent).add_modifier(Modifier::BOLD)),
+ Span::styled(format!(" {buf}"), Style::default().fg(theme.fg)),
+ Span::styled("▏", Style::default().fg(theme.ghost)), // cursor
+ Span::styled(" ⏎ apply · esc cancel", Style::default().fg(theme.fg_dim)),
+ ]);
+ f.render_widget(Paragraph::new(line).style(Style::default().bg(theme.bg)), area);
+ return;
+ }
let mut spans = match app.focus {
Focus::Apps => vec![
key("↑↓", theme),
hint(" select ", theme),
key("⏎", theme),
hint(" monitor ", theme),
+ key("n", theme),
+ hint(" name ", theme),
key("␣", theme),
hint(" mark ", theme),
key("c", theme),
diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs
@@ -8,10 +8,42 @@ use std::sync::{Arc, Mutex};
use hydra_core::engine::Engine;
use hydra_core::ffi::{hal, process};
+use hydra_core::manifest::{Manifest, ManifestDevice};
use hydra_ipc::{read_msg, write_msg, Command, Response, PROTOCOL_VERSION};
type SharedEngine = Arc<Mutex<Engine>>;
+/// Stable UID for Hydra's single virtual device. The forked driver keeps a fixed UID
+/// (`Hydra_UID`); only the display name is manifest-driven for now.
+const HYDRA_DEVICE_UID: &str = "Hydra_UID";
+/// The driver's built-in channel count (compile-time). Advisory in the manifest until the
+/// driver's channel count is itself made dynamic (the larger P3b refactor).
+const HYDRA_DEVICE_CHANNELS: u32 = 16;
+
+/// Rename the Hydra virtual device by writing the driver manifest. The new name is picked
+/// up when coreaudiod next loads the driver (restart), not live.
+fn set_device_name(name: &str) -> Response {
+ let name = name.trim();
+ if name.is_empty() {
+ return Response::Error("device name cannot be empty".into());
+ }
+ let manifest = Manifest::new(vec![ManifestDevice {
+ uid: HYDRA_DEVICE_UID.to_string(),
+ name: name.to_string(),
+ channels: HYDRA_DEVICE_CHANNELS,
+ sample_rates: vec![],
+ }]);
+ match manifest.write() {
+ Ok(()) => Response::Ok,
+ // The manifest dir lives under /Library and is created by install-driver.sh; if the
+ // daemon can't write it, the driver isn't installed yet — say so, don't crash.
+ Err(e) => Response::Error(format!(
+ "could not write {} ({e}); is the driver installed? (run scripts/install-driver.sh)",
+ Manifest::path().display()
+ )),
+ }
+}
+
/// Persist the engine's current routes after any mutation, so they survive a restart.
fn persist(engine: &SharedEngine) {
engine.lock().unwrap().to_config().save();
@@ -92,6 +124,7 @@ fn dispatch(
notify_route_change();
r
}
+ Command::SetDeviceName { name } => set_device_name(&name),
// Server-push subscription lands in P4; acknowledge for now.
Command::Subscribe => Response::Ok,
Command::Shutdown => {
diff --git a/driver/build/Hydra.driver/Contents/MacOS/Hydra b/driver/build/Hydra.driver/Contents/MacOS/Hydra
Binary files differ.
diff --git a/driver/hydra_cfg.c b/driver/hydra_cfg.c
@@ -0,0 +1,74 @@
+// Hydra driver configuration reader — see hydra_cfg.h.
+//
+// Deliberately dependency-free: a tiny hand-rolled scan for the first device's "name"
+// string, so the driver vendors no JSON library and stays trivial to audit. The manifest
+// is small and written by us, so we don't need a full parser — just locate
+// "devices" : [ { ... "name" : "VALUE" ... } ... ]
+// and extract VALUE (handling \" and \\ escapes).
+
+#include "hydra_cfg.h"
+#include <stdio.h>
+#include <string.h>
+
+// Overridable at compile time for host-side testing; defaults to the real system path.
+#ifndef HYDRA_MANIFEST_PATH
+#define HYDRA_MANIFEST_PATH "/Library/Application Support/hydra/devices.json"
+#endif
+
+// Find the next `"key"` token at or after `p`, return a pointer just past the closing quote
+// of the key (i.e. at the ':' region), or NULL. Only matches the key name, not values.
+static const char *find_key(const char *p, const char *end, const char *key) {
+ size_t klen = strlen(key);
+ for (; p + klen + 1 < end; p++) {
+ if (*p == '"' && (size_t)(end - p) > klen + 1 &&
+ strncmp(p + 1, key, klen) == 0 && p[1 + klen] == '"') {
+ return p + 1 + klen + 1; // just past the closing quote of the key
+ }
+ }
+ return NULL;
+}
+
+// From `p`, skip to the next string value's opening quote and copy it (unescaped) into out.
+// Returns 1 on success.
+static int copy_next_string(const char *p, const char *end, char *out, int cap) {
+ while (p < end && *p != '"') p++; // find opening quote of the value
+ if (p >= end) return 0;
+ p++; // past opening quote
+ int n = 0;
+ while (p < end && n < cap - 1) {
+ char c = *p;
+ if (c == '\\' && p + 1 < end) { // escape: copy the next char verbatim (\" \\ etc.)
+ out[n++] = p[1];
+ p += 2;
+ continue;
+ }
+ if (c == '"') { // unescaped quote ⇒ end of value
+ break;
+ }
+ out[n++] = c;
+ p++;
+ }
+ out[n] = '\0';
+ return n > 0;
+}
+
+int hydra_cfg_device_name(char *out_name, int cap) {
+ if (!out_name || cap <= 0) return 0;
+
+ FILE *f = fopen(HYDRA_MANIFEST_PATH, "rb");
+ if (!f) return 0;
+
+ char buf[8192];
+ size_t len = fread(buf, 1, sizeof(buf) - 1, f);
+ fclose(f);
+ if (len == 0) return 0;
+ buf[len] = '\0';
+
+ const char *end = buf + len;
+ // Anchor to the devices array so we read a device's name, not some top-level field.
+ const char *p = find_key(buf, end, "devices");
+ if (!p) p = buf; // tolerate a flat shape too
+ p = find_key(p, end, "name");
+ if (!p) return 0;
+ return copy_next_string(p, end, out_name, cap);
+}
diff --git a/driver/hydra_cfg.h b/driver/hydra_cfg.h
@@ -0,0 +1,16 @@
+// Hydra driver configuration reader.
+//
+// Reads the first virtual-device definition from the manifest the Hydra daemon writes at
+// /Library/Application Support/hydra/devices.json (world-readable; coreaudiod runs as
+// _coreaudiod and can't read the user's home dir). Lets the device be renamed without
+// rebuilding the driver. If the manifest is missing or malformed, the caller falls back to
+// the compile-time default — so a bad manifest can never produce a broken driver.
+
+#ifndef HYDRA_CFG_H
+#define HYDRA_CFG_H
+
+// Copy the manifest's first-device "name" into out_name (capacity cap, NUL-terminated).
+// Returns 1 if a non-empty name was found, 0 otherwise (caller keeps its default).
+int hydra_cfg_device_name(char *out_name, int cap);
+
+#endif // HYDRA_CFG_H
diff --git a/driver/upstream/BlackHole/BlackHole.c b/driver/upstream/BlackHole/BlackHole.c
@@ -444,7 +444,25 @@ else \
static CFStringRef get_box_uid(void) { RETURN_FORMATTED_STRING(kBox_UID) }
static CFStringRef get_device_uid(void) { RETURN_FORMATTED_STRING(kDevice_UID) }
-static CFStringRef get_device_name(void) { RETURN_FORMATTED_STRING(kDevice_Name) }
+
+// Hydra: the device's display name comes from the manifest (set via the Hydra TUI) when
+// present, so it can be renamed without rebuilding the driver. Read once and cached; on any
+// failure we fall through to the compile-time default, so a missing/bad manifest is safe.
+#include "../../hydra_cfg.h"
+static CFStringRef get_device_name(void) {
+ static char nameBuf[256];
+ static int loaded = 0;
+ if (!loaded) {
+ loaded = 1;
+ if (!hydra_cfg_device_name(nameBuf, (int)sizeof(nameBuf))) {
+ nameBuf[0] = '\0';
+ }
+ }
+ if (nameBuf[0] != '\0') {
+ return CFStringCreateWithCString(NULL, nameBuf, kCFStringEncodingUTF8);
+ }
+ RETURN_FORMATTED_STRING(kDevice_Name)
+}
static CFStringRef get_device2_uid(void) { RETURN_FORMATTED_STRING(kDevice2_UID) }
static CFStringRef get_device2_name(void) { RETURN_FORMATTED_STRING(kDevice2_Name) }
static CFStringRef get_device_model_uid(void) { RETURN_FORMATTED_STRING(kDevice_ModelUID) }
diff --git a/scripts/build-driver.sh b/scripts/build-driver.sh
@@ -48,10 +48,12 @@ DEFS=(
# This toolchain can drop the cross slice when both -arch flags are passed at once, so
# compile each arch separately and lipo whatever succeeds into the final binary.
+# Hydra's manifest reader (device name from devices.json) compiles alongside BlackHole.c.
+CFG="$ROOT/driver/hydra_cfg.c"
SLICES=()
for arch in arm64 x86_64; do
obj="$OUT/Hydra-$arch"
- if clang -bundle "$SRC" -o "$obj" \
+ if clang -bundle "$SRC" "$CFG" -o "$obj" \
-fobjc-arc -O2 -arch "$arch" -mmacosx-version-min=11.0 \
-framework CoreAudio -framework CoreFoundation -framework Foundation -framework Accelerate \
"${DEFS[@]}" 2>/dev/null; then
diff --git a/scripts/install-driver.sh b/scripts/install-driver.sh
@@ -27,9 +27,16 @@ echo "• installing driver"
sudo rm -rf "$HAL/Hydra.driver"
sudo cp -R "$DRIVER" "$HAL/Hydra.driver"
-echo "• creating world-readable manifest dir for coreaudiod"
+echo "• creating manifest dir (you write it, coreaudiod reads it)"
+# Two readers with different identities:
+# - the Hydra daemon runs as YOU and writes devices.json (device name) → needs write
+# - coreaudiod runs as _coreaudiod and reads it at driver load → needs world-read
+# admin-group-owned + group-writable + world-readable satisfies both without 777.
sudo mkdir -p "$MANIFEST_DIR"
-sudo chmod 755 "$MANIFEST_DIR"
+sudo chgrp admin "$MANIFEST_DIR"
+sudo chmod 775 "$MANIFEST_DIR"
+# If a manifest already exists, make it group-writable too so renames can overwrite it.
+[ -f "$MANIFEST_DIR/devices.json" ] && sudo chmod 664 "$MANIFEST_DIR/devices.json" || true
echo "• restarting coreaudiod"
# SIP blocks `launchctl kickstart` of coreaudiod ("Operation not permitted while SIP is