Compare commits

..
12 changed files with 189 additions and 122 deletions
+2 -2
View File
@@ -53,8 +53,8 @@ User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu
Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity-checklist.md` (generated; the hand-written assessment is `docs/target-app-parity.md`); a test enforces a floor that only rises.
## Running and looking at the app
- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port and the launch's `SOUNDCRAFT_CONTROL_TOKEN` are printed, or export the token first so both sides share it).
- Drive it: JSON lines over TCP, e.g. `{"id":1,"method":"engine.execute","params":{"command":"track.new","params":{"count":2,"format":"stereo"}},"token":"…"}`, then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"},"token":"…"}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed).
- Drive it: JSON lines over TCP, e.g. `{"id":1,"method":"engine.execute","params":{"command":"track.new","params":{"count":2,"format":"stereo"}}}`, then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- Offscreen UI render (no window, no focus stealing): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl]`.
- Headless: `soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce out.wav`; MCP: `soundcraft-cli mcp [--connect PORT]`.
- **For UI work, look at the result** (render, read the PNG) and compare with `plan/protools/screenshots/`.
Generated
-1
View File
@@ -4304,7 +4304,6 @@ dependencies = [
"craft-update",
"eframe",
"egui",
"getrandom 0.3.4",
"log",
"muda",
"objc2 0.6.5",
+1 -2
View File
@@ -214,8 +214,7 @@ soundcraft-cli run --demo \
--cmd 'mix.insert={"track":"Bass","plugin":"eq_7band"}' \
--bounce mix.wav
# Drive the running app (the token keeps other local accounts off the loopback port).
export SOUNDCRAFT_CONTROL_TOKEN=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
# Drive the running app.
soundcraft --demo --control 7801 &
soundcraft-cli app --port 7801 transport.play
soundcraft-cli app --port 7801 ui.screenshot '{"path":"shot.png"}'
-2
View File
@@ -26,8 +26,6 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"
serde_json.workspace = true
log.workspace = true
chrono.workspace = true
# The per-launch control token (already in the graph through egui's ahash).
getrandom = "0.3"
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+9 -73
View File
@@ -3,13 +3,8 @@
//!
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
//!
//! Every request carries the launch's secret as `"token"` (`SOUNDCRAFT_CONTROL_TOKEN`, or one made
//! here and printed after the port): the port is loopback, which any local account can reach, and
//! a request acts with this process's authority.
use serde_json::{Value, json};
use soundcraft_automation::CONTROL_TOKEN_VAR;
use soundcraft_ui_egui::ControlRequest;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
@@ -33,20 +28,9 @@ const MAX_CONNECTIONS: usize = 16;
const MAX_QUEUED_REQUESTS: usize = MAX_CONNECTIONS;
/// Bind the loopback control listener and return the request receiver. Port 0 picks a free one and the actual port is
/// printed; bind or spawn failures are logged instead of panicking. Requests must carry `token`; with `None` one is
/// made for this launch and printed after the port, so only the launching process learns it.
pub fn start(port: u16, token: Option<String>, ctx: egui::Context) -> Receiver<ControlRequest> {
/// printed; bind or spawn failures are logged instead of panicking.
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
let (token, made) = match token {
Some(t) => (t, false),
None => match new_token() {
Ok(t) => (t, true),
Err(e) => {
log::error!("control server not started: no {CONTROL_TOKEN_VAR} and no random token: {e}");
return rx;
}
},
};
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -57,29 +41,13 @@ pub fn start(port: u16, token: Option<String>, ctx: egui::Context) -> Receiver<C
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
log::info!("control server listening on 127.0.0.1:{actual}");
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
if made {
println!("{CONTROL_TOKEN_VAR}={token}");
}
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx, Arc::from(token)));
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx));
if let Err(e) = spawned {
log::error!("control thread failed: {e}");
}
rx
}
/// 32 random bytes as hex, from the OS.
fn new_token() -> Result<String, getrandom::Error> {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes)?;
Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
/// `token` equals `expected`, compared in time that doesn't depend on where they differ.
fn token_matches(token: Option<&str>, expected: &str) -> bool {
let Some(token) = token else { return false };
token.len() == expected.len() && token.bytes().zip(expected.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
}
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
/// its thread can't be started, or panics).
struct Slot(Arc<AtomicUsize>);
@@ -92,7 +60,7 @@ impl Drop for Slot {
/// Accept until the listener closes, admitting at most [`MAX_CONNECTIONS`] peers; excess peers get one error line.
/// Each admitted connection is served on its own thread, releasing its slot on exit.
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, token: Arc<str>) {
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
@@ -103,10 +71,9 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
let slot = Slot(Arc::clone(&open));
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || {
let _slot = slot;
serve(stream, tx, ctx, &token);
serve(stream, tx, ctx);
});
}
}
@@ -136,9 +103,8 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
}
/// Answer newline-delimited JSON requests for one connection: bound each line, forward it to the UI thread with a 90
/// s reply deadline, and stop at EOF, invalid UTF-8, a closed request queue or a wrong `token`. Bad JSON still gets
/// an error reply.
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context, token: &str) {
/// s reply deadline, and stop at EOF, invalid UTF-8 or a closed request queue. Bad JSON still gets an error reply.
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
@@ -160,11 +126,6 @@ fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context,
let reply = match serde_json::from_str::<Value>(line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
if !token_matches(msg.get("token").and_then(Value::as_str), token) {
let error = format!("unauthorized: the request's `token` is not this app's {CONTROL_TOKEN_VAR}");
let _ = writeln!(out, "{}", json!({"ok": false, "id": id, "error": error}));
break;
}
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
@@ -224,14 +185,12 @@ mod tests {
assert!(state + request.to_string().len() <= MAX_LINE);
}
const TOKEN: &str = "s3cret";
/// The control server on a loopback port, with a stand-in UI that echoes each method.
fn serve_echo() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default(), Arc::from(TOKEN)));
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
@@ -245,7 +204,7 @@ mod tests {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\",\"token\":\"s3cret\"}\r\n\n{\"id\":4,\"method\":\"pong\",\"token\":\"s3cret\"}\n").unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new();
@@ -255,29 +214,6 @@ mod tests {
}
}
#[test]
fn a_request_without_the_token_is_refused_and_the_connection_closed() {
let addr = serve_echo();
for request in [r#"{"id":1,"method":"ping"}"#, r#"{"id":1,"method":"ping","token":"s3cre"}"#, r#"{"id":1,"method":"ping","token":"S3CRET"}"#]
{
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
writeln!(c, "{request}").unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
r.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["id"].as_i64(), v["ok"].as_bool()), (Some(1), Some(false)), "{line}");
assert!(v["error"].as_str().unwrap_or("").starts_with("unauthorized"), "{line}");
assert!(v.get("method").is_none(), "the request was not forwarded: {line}");
line.clear();
assert_eq!(r.read_line(&mut line).unwrap(), 0, "the connection is closed after a refusal");
}
assert!(token_matches(Some(TOKEN), TOKEN));
assert!(!token_matches(None, TOKEN));
assert_eq!(new_token().unwrap().len(), 64);
}
#[test]
fn extra_connections_are_refused() {
let addr = serve_echo();
+3 -4
View File
@@ -3,9 +3,8 @@
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`; `soundcraft --help` lists the flags and `SOUNDCRAFT_CONTROL_PORT`.
//!
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{},"token":"…"}` →
//! `{"id":1,"ok":true,"result":…}`, where `token` is `SOUNDCRAFT_CONTROL_TOKEN` (or the one made for
//! this launch, printed after the port). See `soundcraft_ui_egui::control` for the methods.
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
//! `{"id":1,"ok":true,"result":…}`. See `soundcraft_ui_egui::control` for the methods.
//!
//! `log` records go to standard error and `<settings dir>/logs/soundcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`].
@@ -261,7 +260,7 @@ fn main() -> eframe::Result {
app.extra.ui_scale = prefs.ui_scale;
app.ui = prefs;
if let Some(port) = control_port {
let rx = control_server::start(port, soundcraft_automation::control_token(), cc.egui_ctx.clone());
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
for f in &files {
+3 -16
View File
@@ -151,31 +151,18 @@ impl RemoteError {
type Connection = (TcpStream, BufReader<TcpStream>);
/// The variable carrying the control channel's secret: the app refuses every request whose
/// `token` differs from it, so another local account can't drive the app through the loopback
/// port. The app makes one per launch when the variable is unset (printed after the port line).
pub const CONTROL_TOKEN_VAR: &str = "SOUNDCRAFT_CONTROL_TOKEN";
/// [`CONTROL_TOKEN_VAR`] from the environment; a blank value counts as unset.
pub fn control_token() -> Option<String> {
std::env::var(CONTROL_TOKEN_VAR).ok().map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
}
/// A TCP client for a running app's control channel.
pub struct Remote {
/// The target as `host:port`, for connecting and for messages.
addr: String,
/// Sent with every request; without one the app refuses, naming the variable.
token: Option<String>,
conn: Option<Connection>,
next_id: u64,
}
impl Remote {
/// A client for the app at `target`, authenticating with [`control_token`]; it connects on the
/// first request.
/// A client for the app at `target`; it connects on the first request.
pub fn new(target: &ControlTarget) -> Self {
Remote { addr: target.to_string(), token: control_token(), conn: None, next_id: 1 }
Remote { addr: target.to_string(), conn: None, next_id: 1 }
}
/// The open connection, connecting first when there is none.
@@ -191,7 +178,7 @@ impl Remote {
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (RemoteError, bool)> {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params, "token": self.token}).to_string();
let line = json!({"id": id, "method": method, "params": params}).to_string();
let (w, r) = self.connection().map_err(|e| (e, false))?;
writeln!(w, "{line}").map_err(|e| (e.into(), false))?;
let mut buf = String::new();
+59 -2
View File
@@ -87,16 +87,23 @@ fn decode_group_audio(
cmd_id: &str,
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
// The folder as the file system knows it, so a link among the group's files can't lead out.
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let root = crate::io::canonical_path(dir)?;
for (old, rel) in audio_entries {
let full = dir.join(rel);
let escapes = |rel: &str| EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"));
if !full.starts_with(dir)
|| Path::new(rel)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
{
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
return Err(escapes(rel));
}
let key = crate::io::canonical_path(&full)?;
if !key.starts_with(&root) {
return Err(escapes(rel));
}
let key = crate::io::file_key(&full);
let n = match index.get(&key) {
Some(n) => *n,
None => {
@@ -268,9 +275,59 @@ mod tests {
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
// A link inside the folder that leads out of it is refused once resolved.
#[cfg(unix)]
{
let outside = std::env::temp_dir().join(format!("sc-grp-outside-{}.wav", std::process::id()));
std::fs::copy(dir.join("Wait... what_0.wav"), &outside).unwrap();
std::os::unix::fs::symlink(&outside, dir.join("link.wav")).unwrap();
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!("link.wav");
}
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "links out of the folder are refused: {r:?}");
let _ = std::fs::remove_file(&outside);
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Under a read root, a link in the group folder to a folder outside the root is refused the same way
/// whether or not the file asked for exists there, so the group cannot be used to probe for names.
#[cfg(unix)]
#[test]
fn links_out_of_the_read_root_are_refused_alike_for_existing_and_missing_files() {
let base = std::env::temp_dir().join(format!("sc-grp-root-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (dir, outside) = (base.join("group"), base.join("outside"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
let mut e = crate::demo::demo_engine();
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let exported = file["audio"].as_object().unwrap().values().next().unwrap().as_str().unwrap().to_string();
std::fs::copy(dir.join(&exported), outside.join("real.wav")).unwrap();
std::os::unix::fs::symlink(&outside, dir.join("out")).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[&dir], &[]).unwrap().unwrap();
let mut refusals = Vec::new();
for target in ["out/real.wav", "out/missing.wav"] {
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!(target);
}
std::fs::write(&path, file.to_string()).unwrap();
let r = crate::file_access::confine(Some(&roots), || {
e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}))
});
refusals.push(r.unwrap_err().to_string());
}
assert!(refusals[0].contains("outside the read root"), "{}", refusals[0]);
assert_eq!(refusals[0].replace("real.wav", "X"), refusals[1].replace("missing.wav", "X"), "existing and missing names are refused alike");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn imported_clips_carry_no_auto_rename_scheme() {
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
+110 -9
View File
@@ -85,6 +85,17 @@ pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
}
/// `path` with its links followed, inside the automation read root when there is one. The root is
/// checked on the path as named (links followed, a missing tail resolved by its nearest folder)
/// before anything is resolved, so a name that leads out of the root is refused the same way
/// whether or not it exists there.
pub(crate) fn canonical_path(path: impl AsRef<Path>) -> Result<PathBuf> {
let path = path.as_ref();
let checked = if path.as_os_str().is_empty() { Path::new(".") } else { path };
crate::file_access::check_read(checked).map_err(EngineError::Io)?;
std::fs::canonicalize(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
pub(crate) fn write_path(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
let path = path.as_ref();
@@ -329,7 +340,7 @@ impl Out {
if cfg!(target_arch = "wasm32") {
return Ok(Out::Memory(std::io::Cursor::new(Vec::new())));
}
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?)))
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, create_new(part)?)))
}
/// Puts the finished file at `path` and returns its size: the part file is synced and renamed
@@ -462,14 +473,27 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) ->
let stem = sanitize_name(&c.name);
let mut path = Path::new(dir).join(format!("{stem}.wav"));
let mut k = 2u32;
while path.exists() {
// The name is taken by creating the file, so whatever entry already has it (a link
// to elsewhere included) is left alone rather than written through.
let mut file = loop {
// An occupied name is taken whatever it is (a link whose target is outside the write
// root too): it is only looked at, never resolved, so the root check does not stop the
// search for a free name. The folder itself was checked above.
if std::fs::symlink_metadata(&path).is_err() {
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(f) => break f,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
}
}
path = Path::new(dir).join(format!("{stem}-{k}.wav"));
k = k.saturating_add(1);
if k > 10_000 {
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
}
}
write_path(&path, bytes)?;
};
std::io::Write::write_all(&mut file, &bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
n += 1;
}
Ok(n)
@@ -506,13 +530,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
{
src.path = f.join(&src.path).to_string_lossy().into_owned();
}
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && file_found(dir.join(&src.path));
let shared = shared_paths.get(i).copied().unwrap_or(false);
if exists_rel && !src.unsaved && !shared {
continue;
}
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
if let Some(f) = in_folder.filter(|_| !src.unsaved && file_found(&src.path)) {
src.path = format!("Audio Files/{f}");
continue;
}
@@ -528,7 +552,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
if overwrite {
// One file per source id so autosaves never collide or pile up.
stem = format!("{stem}_{}", src.id.0);
if audio_dir.join(format!("{stem}.wav")).exists() {
if file_found(audio_dir.join(format!("{stem}.wav"))) {
src.path = format!("Audio Files/{stem}.wav");
src.unsaved = false;
files.insert(Arc::as_ptr(audio), src.path.clone());
@@ -588,11 +612,22 @@ fn sync_dir(dir: &Path) {
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::File::create(path)?;
let mut f = create_new(path)?;
f.write_all(bytes)?;
f.sync_all()
}
/// Creates `path` as a new file. A leftover of that name (a part file from an interrupted write)
/// is removed first, but an existing entry is never opened: a link planted at a predictable name
/// in the destination folder can't redirect the write elsewhere.
fn create_new(path: &Path) -> std::io::Result<std::fs::File> {
match std::fs::remove_file(path) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => return Err(err),
_ => {}
}
std::fs::OpenOptions::new().write(true).create_new(true).open(path)
}
/// Copies `from` to `to`, which must not exist yet; an existing entry, even a link, is never followed.
fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
let mut src = std::fs::File::open(from)?;
@@ -687,7 +722,12 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
return Ok((name, true));
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) {
// Reused only when it may be read: whether it equals the audio must not be
// learnt about a file the roots allow writing beside but not reading.
if crate::file_access::check_read(&path).is_ok()
&& std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64)
&& std::fs::read(&path).is_ok_and(|b| b == bytes)
{
return Ok((name, false));
}
}
@@ -785,6 +825,67 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<
.collect()
}
#[cfg(test)]
mod exclusive_tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-io-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(unix)]
#[test]
fn a_planted_link_at_the_part_name_is_not_written_through() {
let dir = scratch("part-link");
let (target, part) = (dir.join("elsewhere"), dir.join("session.scraft.tmp"));
std::os::unix::fs::symlink(&target, &part).unwrap();
write_synced(&part, b"new").unwrap();
assert!(!target.exists(), "the link's target was created");
assert_eq!(std::fs::read(&part).unwrap(), b"new", "the link itself was replaced by the file");
// A leftover regular part file is still replaced.
write_synced(&part, b"newer").unwrap();
assert_eq!(std::fs::read(&part).unwrap(), b"newer");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn export_skips_a_name_held_by_a_dangling_link() {
let dir = scratch("export-link");
let e = crate::demo::demo_engine();
let (t, c) = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).map(|c| (t.id, c.clone()))).unwrap();
let _ = t;
let target = dir.join("elsewhere.wav");
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
assert_eq!(export_clips(&e, &[c.id], &dir.to_string_lossy()).unwrap(), 1);
assert!(!target.exists(), "the export followed the link");
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
let _ = std::fs::remove_dir_all(&dir);
}
/// With a write root, a link out of it that holds the name is still just an occupied name.
#[cfg(unix)]
#[test]
fn export_skips_a_link_out_of_the_write_root_too() {
let dir = scratch("export-root-link");
let e = crate::demo::demo_engine();
let c = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).cloned()).unwrap();
let elsewhere = scratch("export-root-elsewhere");
let target = elsewhere.join("target.wav");
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap().unwrap();
let n = crate::file_access::confine(Some(&roots), || export_clips(&e, &[c.id], &dir.to_string_lossy())).unwrap();
assert_eq!(n, 1);
assert!(!target.exists(), "the export followed the link out of the root");
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&elsewhere);
}
}
#[cfg(test)]
mod stem_tests {
#[test]
-2
View File
@@ -77,8 +77,6 @@ link stays. An existing file that is not an input is replaced without asking.
|---|---|---|
| `SOUNDCRAFT_CONTROL_PORT` | `app --port` (CLI), `--control` (desktop app) | A port or `host:port`, an IPv6 host in brackets (`[::1]:7979`). The desktop app takes the port of a loopback `host:port` (`127.0.0.1`, `localhost`). Its control server listens on `127.0.0.1` only, so it ignores another host from the variable, with a log warning, and starts no control server; given to `--control`, another host is a usage error |
| `SOUNDCRAFT_CONTROL_TOKEN` | none | The secret every control request carries as `token`. The desktop app refuses requests without it; unset, it makes one per launch and prints it after the port line (`SOUNDCRAFT_CONTROL_TOKEN=…`). `app` and `mcp --connect` send the variable's value |
- A flag wins over its variable.
- An empty variable counts as unset.
- A bad value is a usage error that names the variable.
+2 -8
View File
@@ -6,7 +6,7 @@ input), and every command is reachable from:
| Surface | How |
|---|---|
| Desktop app control channel | `soundcraft --control <port>` (`0` = pick a free port; printed as `SOUNDCRAFT_CONTROL_PORT=…`, then the launch's `SOUNDCRAFT_CONTROL_TOKEN=…` unless the variable was set) |
| Desktop app control channel | `soundcraft --control <port>` (`0` = pick a free port; printed as `SOUNDCRAFT_CONTROL_PORT=…`) |
| CLI against a running app | `soundcraft-cli app --port <port> <method or command id> [JSON]` (see [cli.md](cli.md)) |
| Headless CLI | `soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce out.wav` |
| MCP (headless) | `soundcraft-cli mcp [--demo]` |
@@ -29,17 +29,12 @@ input delivery or screen-reader acceptance. Native screenshots use the presented
Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line:
```json
{"id": 1, "method": "engine.execute", "params": {"command": "track.new", "params": {"count": 2, "format": "Stereo"}}, "token": "…"}
{"id": 1, "method": "engine.execute", "params": {"command": "track.new", "params": {"count": 2, "format": "Stereo"}}}
{"id": 1, "ok": true, "result": {"tracks": [12, 13]}}
```
Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no track named `Nope`"}`.
`token` is the launch's secret: `SOUNDCRAFT_CONTROL_TOKEN` when the app was started with it, otherwise
the one the app made and printed after the port line. Any local account can reach the loopback port,
so a request without the right token is refused (`"error": "unauthorized: …"`) and its connection
closed. `soundcraft-cli app` and `mcp --connect` read the variable.
## Methods
| Method | Params | What it does |
@@ -76,7 +71,6 @@ Scripted input (`ui.click`, `ui.drag`, `ui.scroll`, `ui.zoom`, `ui.key`, `ui.tex
## Examples
```sh
export SOUNDCRAFT_CONTROL_TOKEN=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
P=$(soundcraft --demo --control 0 | sed -n 's/SOUNDCRAFT_CONTROL_PORT=//p') # or read stderr
soundcraft-cli app --port $P mix.solo '{"track":"Bass"}'
soundcraft-cli app --port $P transport.play
-1
View File
@@ -8,7 +8,6 @@ Claude and other agents can make music with it. The server is the `mcp` subcomma
soundcraft-cli mcp # headless engine (no window, no audio device)
soundcraft-cli mcp --demo # headless, starting from the demo session
soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # headless, confined to folders
export SOUNDCRAFT_CONTROL_TOKEN=… # the secret both sides share (see control-protocol.md)
soundcraft --control 7801 & # the desktop app…
soundcraft-cli mcp --connect 7801 # …driven over MCP: same tools plus screenshots and clicks
```