diff --git a/Cargo.lock b/Cargo.lock index 162a526..3514c8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,12 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -450,6 +456,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -460,6 +472,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -490,6 +512,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "rosc", "semver", "serde", "serde_json", @@ -548,6 +571,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rosc" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e63d9e6b0d090be1485cf159b1e04c3973d2d3e1614963544ea2ff47a4a981" +dependencies = [ + "byteorder", + "nom", +] + [[package]] name = "rustix" version = "1.1.4" diff --git a/README.md b/README.md index 8edba49..b3bbf4d 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ pogly elements delete 42 | `elementdata list\|add\|update\|delete` | Manage element data assets | | `layouts list\|add\|duplicate\|rename\|delete\|set-active` | Manage layouts; `set-active` switches the live scene | | `folders list\|add\|update\|delete` | Manage asset folders | +| `osc` | Run the OSC listener server to control the overlay via UDP | | `version [list\|use\|upgrade]` | Show, switch, or upgrade the installed CLI version | Every command supports `--help` for its full flag list, `--json` for the raw API response, and `--overlay ` to target a specific profile. @@ -85,6 +86,82 @@ Writes require the matching permission on the token owner's account (e.g. `AddEl The [examples/](examples/) folder shows how to hook the CLI into your stream: Streamer.bot-triggered alerts on subs/raids/channel points, automatic Pogly↔OBS scene sync, live counters, and one rick roll. Anything that can run a program can drive your overlay. +## Model Context Protocol (MCP) Server + +`pogly-cli` can run as a Model Context Protocol (MCP) server, allowing AI assistants (like Claude Desktop, Cursor, or Windsurf) to view, build, and manage your Pogly overlay layouts and elements directly. + +To start the MCP server using `stdio` transport: + +``` +pogly mcp +``` + +### Claude Desktop Integration + +Add this to your `claude_desktop_config.json` (typically at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, or `%APPDATA%\Claude\claude_desktop_config.json` on Windows): + +```json +{ + "mcpServers": { + "pogly": { + "command": "pogly", + "args": ["mcp"] + } + } +} +``` + +Or for development / running from source: + +```json +{ + "mcpServers": { + "pogly": { + "command": "cargo", + "args": [ + "run", + "--manifest-path", + "/absolute/path/to/pogly-cli/Cargo.toml", + "--bin", + "pogly-cli", + "--", + "mcp" + ] + } + } +} +``` + +## Open Sound Control (OSC) Server + +`pogly-cli` can run as a UDP-based OSC listener, allowing you to instantly control your overlay layouts and elements with zero process-spawn overhead. This is perfect for integrations with Stream Deck (via an OSC plugin), TouchOSC, VRChat, or custom script controllers. + +To match the native 30Hz server-side update rate, the listener throttles outgoing API updates and automatically deduplicates incoming queues (e.g. merging rapid adjustments so only the latest values are sent within the same tick window). + +To start the OSC server: + +``` +pogly osc --port 9000 +``` + +### Supported OSC Address Routes + +| OSC Address | Arguments | Description | +|---|---|---| +| `/pogly/ping` | None | Pings the overlay API to verify connectivity. | +| `/pogly/whoami` | None | Prints the connected API token details and permissions. | +| `/pogly/layouts/set-active`
`/pogly/layouts/set_active` | `target` (Int, Long, or String) | Switches active layout (accepts layout ID or layout name). | +| `/pogly/elements/delete` | `id` (Int, Long, or String) | Deletes the element with the specified ID. | +| `/pogly/elements/update/position` | `id` (Int/Long/String), `x` (Int/Long/Float/String), `y` (Int/Long/Float/String) | Moves the element to coordinates `x` and `y`. | +| `/pogly/elements/update/transparency` | `id` (Int/Long/String), `transparency` (Int/Long/Float/String) | Sets element transparency (0-100). | +| `/pogly/elements/update/text` | `id` (Int/Long/String), `text` (String/Int/Float/Bool) | Updates text element text content. | +| `/pogly/elements/update/media/playing` | `id` (Int/Long/String), `playing` (Bool/Int) | Plays or pauses a media element. | +| `/pogly/elements/update/media/volume` | `id` (Int/Long/String), `volume` (Int/Long/Float/String) | Sets media element volume (0-100). | +| `/pogly/elements/update/media/timestamp` | `id` (Int/Long/String), `timestamp` (Int/Long/Float/String) | Seeks media element to timestamp in seconds. | +| `/pogly/elements/update` | `id` (Int/Long/String), `field_name` (String), `value` (Any) | Updates a single field dynamically (e.g. `color`, `textSize`, `alwaysLoaded`). | + +Values are automatically coerced to the required type (e.g. float arguments will be converted to integers or booleans where appropriate). + ## Building from source ``` diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8bb87e4..40eac4b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -17,3 +17,5 @@ serde_json = "1" toml = "0.8" anyhow = "1" semver = "1" +rosc = "0.10" + diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index ac56522..bfc8d98 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -38,6 +38,10 @@ pub enum Cmd { Folders(FoldersCmd), /// Show or manage the installed CLI version Version(VersionCmd), + /// Run the MCP (Model Context Protocol) server + Mcp, + /// Run the OSC (Open Sound Control) listener server + Osc(OscCmd), } #[derive(Args)] @@ -443,3 +447,14 @@ pub enum FoldersSub { no_preserve_elements: bool, }, } + +#[derive(Args)] +pub struct OscCmd { + /// Host interface to bind to + #[arg(long, default_value = "127.0.0.1")] + pub host: String, + /// UDP port to listen on + #[arg(long, default_value = "9000")] + pub port: u16, +} + diff --git a/crates/cli/src/commands/mcp.rs b/crates/cli/src/commands/mcp.rs new file mode 100644 index 0000000..1b79e40 --- /dev/null +++ b/crates/cli/src/commands/mcp.rs @@ -0,0 +1,1046 @@ +use anyhow::{bail, Result}; +use serde_json::{json, Map, Value}; +use std::io::{self, BufRead, Write}; + +use crate::api::client::{qs, ApiClient}; +use crate::cli::GlobalArgs; +use crate::config::Config; + +pub fn run(_global: &GlobalArgs) -> Result<()> { + let stdin = io::stdin(); + let mut stdout = io::stdout(); + let handle = stdin.lock(); + + for line in handle.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + eprintln!("Error reading line from stdin: {}", e); + break; + } + }; + + let request: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + let response = json!({ + "jsonrpc": "2.0", + "error": { + "code": -32700, + "message": format!("Parse error: {}", e) + } + }); + if let Err(write_err) = writeln!(stdout, "{}", response) { + eprintln!("Error writing response to stdout: {}", write_err); + break; + } + let _ = io::Write::flush(&mut stdout); + continue; + } + }; + + let has_id = request.get("id").is_some(); + let response = handle_mcp_request(request); + + if has_id && response != Value::Null { + if let Err(e) = writeln!(stdout, "{}", response) { + eprintln!("Error writing response to stdout: {}", e); + break; + } + let _ = io::Write::flush(&mut stdout); + } + } + Ok(()) +} + +fn handle_mcp_request(req: Value) -> Value { + let id = req.get("id").cloned().unwrap_or(Value::Null); + let method = match req.get("method").and_then(Value::as_str) { + Some(m) => m, + None => { + return json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32600, + "message": "Invalid request: missing method" + } + }); + } + }; + + let params = req.get("params").cloned().unwrap_or(Value::Null); + + match method { + "initialize" => { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "pogly-mcp", + "version": env!("CARGO_PKG_VERSION") + } + } + }) + } + "notifications/initialized" => Value::Null, + "tools/list" => { + let tools = get_tools_list(); + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "tools": tools + } + }) + } + "tools/call" => { + let tool_name = match params.get("name").and_then(Value::as_str) { + Some(n) => n, + None => { + return json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32602, + "message": "Invalid params: missing name" + } + }); + } + }; + let args = params.get("arguments").cloned().unwrap_or(Value::Null); + + match execute_tool(tool_name, args) { + Ok(res) => { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [ + { + "type": "text", + "text": res.to_string() + } + ] + } + }) + } + Err(e) => { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [ + { + "type": "text", + "text": format!("Error: {:#}", e) + } + ], + "isError": true + } + }) + } + } + } + _ => { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32601, + "message": format!("Method not found: {}", method) + } + }) + } + } +} + +fn get_client(overlay: Option) -> Result { + let config = Config::load()?; + let profile = config.resolve(overlay.as_deref())?; + Ok(ApiClient::new(&profile.address, &profile.token)) +} + +fn execute_tool(name: &str, args: Value) -> Result { + let overlay = args + .get("overlay") + .and_then(Value::as_str) + .map(String::from); + let client = get_client(overlay)?; + + match name { + "ping" => client.get("ping"), + "whoami" => client.get("whoami"), + "layouts_list" => client.get("layouts"), + "layouts_add" => { + let layout_name = args + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing name"))?; + let active = args.get("active").and_then(Value::as_bool).unwrap_or(false); + let mut body = json!({ "name": layout_name }); + if active { + body["active"] = json!(true); + } + client.post("layouts", body) + } + "layouts_duplicate" => { + let id = args + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("missing id"))?; + client.post(&format!("layouts/duplicate?id={id}"), json!({})) + } + "layouts_rename" => { + let id = args + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("missing id"))?; + let new_name = args + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing name"))?; + client.patch(&format!("layouts?id={id}"), json!({ "name": new_name })) + } + "layouts_delete" => { + let id = args + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("missing id"))?; + let preserve_elements = args + .get("preserve_elements") + .and_then(Value::as_bool) + .unwrap_or(false); + let preserve_layout_id = args.get("preserve_layout_id").and_then(Value::as_u64); + let query = qs(&[ + ("id", Some(id.to_string())), + ( + "preserveElements", + preserve_elements.then(|| "true".to_string()), + ), + ( + "preserveLayoutId", + preserve_layout_id.map(|v| v.to_string()), + ), + ]); + client.delete(&format!("layouts{query}")) + } + "layouts_set_active" => { + let id = args.get("id").and_then(Value::as_u64); + let layout_name = args.get("name").and_then(Value::as_str); + if id.is_none() && layout_name.is_none() { + bail!("provide either id or name"); + } + let query = qs(&[ + ("id", id.map(|v| v.to_string())), + ("name", layout_name.map(String::from)), + ]); + client.post(&format!("layout{query}"), json!({})) + } + "elements_list" => { + let id = args.get("id").and_then(Value::as_u64); + let layout = args.get("layout").and_then(Value::as_u64); + let query = qs(&[ + ("id", id.map(|v| v.to_string())), + ("layout", layout.map(|v| v.to_string())), + ]); + client.get(&format!("elements{query}")) + } + "elements_delete" => { + let id = args + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("missing id"))?; + client.delete(&format!("elements?id={id}")) + } + "elements_add_text" => { + let text = args + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing text"))?; + let size = args.get("size").and_then(Value::as_i64); + let color = args.get("color").and_then(Value::as_str).map(String::from); + let font = args.get("font").and_then(Value::as_str).map(String::from); + let css = args.get("css").and_then(Value::as_str).map(String::from); + + let mut text_group = Map::new(); + text_group.insert("text".into(), text.into()); + if let Some(s) = size { + text_group.insert("size".into(), s.into()); + } + if let Some(c) = color { + text_group.insert("color".into(), c.into()); + } + if let Some(f) = font { + text_group.insert("font".into(), f.into()); + } + if let Some(cs) = css { + text_group.insert("css".into(), cs.into()); + } + + let mut body = Map::new(); + body.insert("type".into(), "text".into()); + body.insert("text".into(), Value::Object(text_group)); + apply_common_mcp(&mut body, &args); + + client.post("elements", Value::Object(body)) + } + "elements_add_image" => { + let data_id = args.get("data_id").and_then(Value::as_u64); + let url = args.get("url").and_then(Value::as_str).map(String::from); + let width = args.get("width").and_then(Value::as_i64); + let height = args.get("height").and_then(Value::as_i64); + + if url.is_some() && (width.is_none() || height.is_none()) { + bail!("url requires width and height"); + } + if data_id.is_none() && url.is_none() { + bail!("provide either data_id or url"); + } + + let mut image_group = Map::new(); + if let Some(d) = data_id { + image_group.insert("elementDataId".into(), d.into()); + } + if let Some(u) = url { + image_group.insert("url".into(), u.into()); + } + if let Some(w) = width { + image_group.insert("width".into(), w.into()); + } + if let Some(h) = height { + image_group.insert("height".into(), h.into()); + } + + let mut body = Map::new(); + body.insert("type".into(), "image".into()); + body.insert("image".into(), Value::Object(image_group)); + apply_common_mcp(&mut body, &args); + + client.post("elements", Value::Object(body)) + } + "elements_add_widget" => { + let data_id = args.get("data_id").and_then(Value::as_u64); + let raw_data = args + .get("raw_data") + .and_then(Value::as_str) + .map(String::from); + let width = args + .get("width") + .and_then(Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("missing width"))?; + let height = args + .get("height") + .and_then(Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("missing height"))?; + + if data_id.is_none() && raw_data.is_none() { + bail!("provide either data_id or raw_data"); + } + + let mut widget_group = Map::new(); + if let Some(d) = data_id { + widget_group.insert("elementDataId".into(), d.into()); + } + if let Some(r) = raw_data { + widget_group.insert("rawData".into(), r.into()); + } + widget_group.insert("width".into(), width.into()); + widget_group.insert("height".into(), height.into()); + + let mut body = Map::new(); + body.insert("type".into(), "widget".into()); + body.insert("widget".into(), Value::Object(widget_group)); + apply_common_mcp(&mut body, &args); + + client.post("elements", Value::Object(body)) + } + "elements_add_media" => { + let source = args + .get("source") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing source"))?; + let volume = args.get("volume").and_then(Value::as_i64); + let width = args.get("width").and_then(Value::as_i64); + let height = args.get("height").and_then(Value::as_i64); + let autoplay = args + .get("autoplay") + .and_then(Value::as_bool) + .unwrap_or(false); + let r#loop = args.get("loop").and_then(Value::as_bool).unwrap_or(false); + let timestamp = args.get("timestamp").and_then(Value::as_i64); + + let mut media_group = Map::new(); + media_group.insert("source".into(), source.into()); + if let Some(v) = volume { + media_group.insert("volume".into(), v.into()); + } + if let Some(w) = width { + media_group.insert("width".into(), w.into()); + } + if let Some(h) = height { + media_group.insert("height".into(), h.into()); + } + if autoplay { + media_group.insert("autoplay".into(), true.into()); + } + if r#loop { + media_group.insert("loop".into(), true.into()); + } + if let Some(t) = timestamp { + media_group.insert("timestamp".into(), t.into()); + } + + let mut body = Map::new(); + body.insert("type".into(), "media".into()); + body.insert("media".into(), Value::Object(media_group)); + apply_common_mcp(&mut body, &args); + + client.post("elements", Value::Object(body)) + } + "elements_update" => { + let id = args + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("missing id"))?; + let mut body = Map::new(); + apply_common_mcp(&mut body, &args); + set_mcp(&mut body, "locked", args.get("locked").cloned()); + set_mcp( + &mut body, + "alwaysLoaded", + args.get("always_loaded").cloned(), + ); + set_mcp(&mut body, "indexLock", args.get("index_lock").cloned()); + + let mut text = Map::new(); + set_mcp(&mut text, "text", args.get("text").cloned()); + set_mcp(&mut text, "size", args.get("text_size").cloned()); + set_mcp(&mut text, "color", args.get("color").cloned()); + set_mcp(&mut text, "font", args.get("font").cloned()); + set_mcp(&mut text, "css", args.get("css").cloned()); + if !text.is_empty() { + body.insert("text".into(), Value::Object(text)); + } + + let mut image = Map::new(); + set_mcp( + &mut image, + "elementDataId", + args.get("image_data_id").cloned(), + ); + set_mcp(&mut image, "url", args.get("image_url").cloned()); + set_mcp(&mut image, "width", args.get("image_width").cloned()); + set_mcp(&mut image, "height", args.get("image_height").cloned()); + if !image.is_empty() { + body.insert("image".into(), Value::Object(image)); + } + + let mut widget = Map::new(); + set_mcp( + &mut widget, + "elementDataId", + args.get("widget_data_id").cloned(), + ); + set_mcp(&mut widget, "rawData", args.get("widget_raw_data").cloned()); + set_mcp(&mut widget, "width", args.get("widget_width").cloned()); + set_mcp(&mut widget, "height", args.get("widget_height").cloned()); + if !widget.is_empty() { + body.insert("widget".into(), Value::Object(widget)); + } + + let mut media = Map::new(); + set_mcp(&mut media, "source", args.get("media_source").cloned()); + set_mcp(&mut media, "volume", args.get("media_volume").cloned()); + set_mcp(&mut media, "playing", args.get("media_playing").cloned()); + set_mcp( + &mut media, + "timestamp", + args.get("media_timestamp").cloned(), + ); + set_mcp(&mut media, "autoplay", args.get("media_autoplay").cloned()); + set_mcp(&mut media, "loop", args.get("media_loop").cloned()); + set_mcp(&mut media, "width", args.get("media_width").cloned()); + set_mcp(&mut media, "height", args.get("media_height").cloned()); + if !media.is_empty() { + body.insert("media".into(), Value::Object(media)); + } + + if body.is_empty() { + bail!("provide at least one field to update"); + } + client.patch(&format!("elements?id={id}"), Value::Object(body)) + } + "elementdata_list" => { + let id = args.get("id").and_then(Value::as_u64); + let asset_name = args.get("name").and_then(Value::as_str); + let folder = args.get("folder").and_then(Value::as_u64); + let query = qs(&[ + ("id", id.map(|v| v.to_string())), + ("name", asset_name.map(String::from)), + ("folder", folder.map(|v| v.to_string())), + ]); + client.get(&format!("elementdata{query}")) + } + "elementdata_add" => { + let asset_name = args + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing name"))?; + let kind_str = args + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing type"))?; + let data = args + .get("data") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing data"))?; + let width = args + .get("width") + .and_then(Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("missing width"))?; + let height = args + .get("height") + .and_then(Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("missing height"))?; + let folder_id = args.get("folder_id").and_then(Value::as_u64); + + let mut body = Map::new(); + body.insert("name".into(), asset_name.into()); + body.insert("type".into(), kind_str.into()); + body.insert("data".into(), data.into()); + body.insert("width".into(), width.into()); + body.insert("height".into(), height.into()); + if let Some(fid) = folder_id { + body.insert("folderId".into(), fid.into()); + } + client.post("elementdata", Value::Object(body)) + } + "elementdata_update" => { + let id = args + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("missing id"))?; + let mut body = Map::new(); + set_mcp(&mut body, "name", args.get("name").cloned()); + set_mcp(&mut body, "data", args.get("data").cloned()); + set_mcp(&mut body, "width", args.get("width").cloned()); + set_mcp(&mut body, "height", args.get("height").cloned()); + set_mcp(&mut body, "folderId", args.get("folder_id").cloned()); + if body.is_empty() { + bail!("provide at least one field to update"); + } + client.patch(&format!("elementdata?id={id}"), Value::Object(body)) + } + "elementdata_delete" => { + let id = args.get("id").and_then(Value::as_u64); + let asset_name = args.get("name").and_then(Value::as_str); + if id.is_none() && asset_name.is_none() { + bail!("provide either id or name"); + } + let query = qs(&[ + ("id", id.map(|v| v.to_string())), + ("name", asset_name.map(String::from)), + ]); + client.delete(&format!("elementdata{query}")) + } + "folders_list" => client.get("folders"), + "folders_add" => { + let folder_name = args + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing name"))?; + let icon = args.get("icon").and_then(Value::as_str).map(String::from); + let mut body = Map::new(); + body.insert("name".into(), folder_name.into()); + if let Some(i) = icon { + body.insert("icon".into(), i.into()); + } + client.post("folders", Value::Object(body)) + } + "folders_update" => { + let id = args + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("missing id"))?; + let mut body = Map::new(); + set_mcp(&mut body, "name", args.get("name").cloned()); + set_mcp(&mut body, "icon", args.get("icon").cloned()); + if body.is_empty() { + bail!("provide name and/or icon"); + } + client.patch(&format!("folders?id={id}"), Value::Object(body)) + } + "folders_delete" => { + let id = args + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("missing id"))?; + let no_preserve_elements = args + .get("no_preserve_elements") + .and_then(Value::as_bool) + .unwrap_or(false); + let query = qs(&[ + ("id", Some(id.to_string())), + ( + "preserveElements", + no_preserve_elements.then(|| "false".to_string()), + ), + ]); + client.delete(&format!("folders{query}")) + } + _ => bail!("tool not found: {name}"), + } +} + +fn apply_common_mcp(body: &mut Map, args: &Value) { + set_mcp(body, "x", args.get("x").cloned()); + set_mcp(body, "y", args.get("y").cloned()); + set_mcp(body, "transform", args.get("transform").cloned()); + set_mcp(body, "transparency", args.get("transparency").cloned()); + set_mcp(body, "clip", args.get("clip").cloned()); + set_mcp(body, "layoutId", args.get("layout_id").cloned()); +} + +fn set_mcp(map: &mut Map, key: &str, value: Option) { + if let Some(v) = value { + if !v.is_null() { + map.insert(key.to_string(), v); + } + } +} + +fn get_tools_list() -> Value { + json!([ + { + "name": "ping", + "description": "Check that the overlay API is reachable", + "inputSchema": { + "type": "object", + "properties": { + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + } + } + }, + { + "name": "whoami", + "description": "Show the identity and permissions behind the configured token", + "inputSchema": { + "type": "object", + "properties": { + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + } + } + }, + { + "name": "layouts_list", + "description": "List layouts", + "inputSchema": { + "type": "object", + "properties": { + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + } + } + }, + { + "name": "layouts_add", + "description": "Create a layout", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the new layout" + }, + "active": { + "type": "boolean", + "description": "Make it the active layout immediately" + }, + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + }, + "required": ["name"] + } + }, + { + "name": "layouts_duplicate", + "description": "Duplicate a layout and its elements", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the layout to duplicate" + }, + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + }, + "required": ["id"] + } + }, + { + "name": "layouts_rename", + "description": "Rename a layout", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the layout to rename" + }, + "name": { + "type": "string", + "description": "New name for the layout" + }, + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + }, + "required": ["id", "name"] + } + }, + { + "name": "layouts_delete", + "description": "Delete a layout", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the layout to delete" + }, + "preserve_elements": { + "type": "boolean", + "description": "Move the layout's elements instead of deleting them" + }, + "preserve_layout_id": { + "type": "integer", + "description": "Layout ID to move preserved elements to (defaults to 1)" + }, + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + }, + "required": ["id"] + } + }, + { + "name": "layouts_set_active", + "description": "Set the active layout (scene switch). Provide either id or name.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the layout to make active" + }, + "name": { + "type": "string", + "description": "Name of the layout to make active" + }, + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + } + } + }, + { + "name": "elements_list", + "description": "List elements", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Fetch a single element by ID" + }, + "layout": { + "type": "integer", + "description": "Filter elements by layout ID" + }, + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + } + } + }, + { + "name": "elements_delete", + "description": "Delete an element", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the element to delete" + }, + "overlay": { + "type": "string", + "description": "Optional overlay profile nickname" + } + }, + "required": ["id"] + } + }, + { + "name": "elements_add_text", + "description": "Add a text element to a layout", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string", "description": "Text content" }, + "size": { "type": "integer", "description": "Font size (default 48)" }, + "color": { "type": "string", "description": "Text color in hex (default #ffffff)" }, + "font": { "type": "string", "description": "Font family name" }, + "css": { "type": "string", "description": "Extra CSS applied to the element" }, + "x": { "type": "integer", "description": "X coordinate" }, + "y": { "type": "integer", "description": "Y coordinate" }, + "transform": { "type": "string", "description": "CSS transform string (overrides x/y)" }, + "transparency": { "type": "integer", "description": "Opacity 0-100" }, + "clip": { "type": "string", "description": "CSS clip-path string" }, + "layout_id": { "type": "integer", "description": "Target layout ID (defaults to active layout)" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + }, + "required": ["text"] + } + }, + { + "name": "elements_add_image", + "description": "Add an image element from an asset or raw URL", + "inputSchema": { + "type": "object", + "properties": { + "data_id": { "type": "integer", "description": "Existing element data (asset) ID" }, + "url": { "type": "string", "description": "Raw image URL (requires width and height)" }, + "width": { "type": "integer", "description": "Width in pixels" }, + "height": { "type": "integer", "description": "Height in pixels" }, + "x": { "type": "integer", "description": "X coordinate" }, + "y": { "type": "integer", "description": "Y coordinate" }, + "transform": { "type": "string", "description": "CSS transform string (overrides x/y)" }, + "transparency": { "type": "integer", "description": "Opacity 0-100" }, + "clip": { "type": "string", "description": "CSS clip-path string" }, + "layout_id": { "type": "integer", "description": "Target layout ID (defaults to active layout)" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + } + } + }, + { + "name": "elements_add_widget", + "description": "Add a widget element", + "inputSchema": { + "type": "object", + "properties": { + "data_id": { "type": "integer", "description": "Existing element data (asset) ID" }, + "raw_data": { "type": "string", "description": "Inline widget definition" }, + "width": { "type": "integer", "description": "Width in pixels" }, + "height": { "type": "integer", "description": "Height in pixels" }, + "x": { "type": "integer", "description": "X coordinate" }, + "y": { "type": "integer", "description": "Y coordinate" }, + "transform": { "type": "string", "description": "CSS transform string (overrides x/y)" }, + "transparency": { "type": "integer", "description": "Opacity 0-100" }, + "clip": { "type": "string", "description": "CSS clip-path string" }, + "layout_id": { "type": "integer", "description": "Target layout ID (defaults to active layout)" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + }, + "required": ["width", "height"] + } + }, + { + "name": "elements_add_media", + "description": "Add a media (video/audio) element", + "inputSchema": { + "type": "object", + "properties": { + "source": { "type": "string", "description": "Media source URL (e.g. YouTube link)" }, + "volume": { "type": "integer", "description": "Volume 0-100 (default 100)" }, + "width": { "type": "integer", "description": "Width in pixels" }, + "height": { "type": "integer", "description": "Height in pixels" }, + "autoplay": { "type": "boolean", "description": "Start playing immediately" }, + "loop": { "type": "boolean", "description": "Loop playback" }, + "timestamp": { "type": "integer", "description": "Start position in seconds" }, + "x": { "type": "integer", "description": "X coordinate" }, + "y": { "type": "integer", "description": "Y coordinate" }, + "transform": { "type": "string", "description": "CSS transform string (overrides x/y)" }, + "transparency": { "type": "integer", "description": "Opacity 0-100" }, + "clip": { "type": "string", "description": "CSS clip-path string" }, + "layout_id": { "type": "integer", "description": "Target layout ID (defaults to active layout)" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + }, + "required": ["source"] + } + }, + { + "name": "elements_update", + "description": "Update fields on an element", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "ID of the element to update" }, + "x": { "type": "integer", "description": "X coordinate" }, + "y": { "type": "integer", "description": "Y coordinate" }, + "transform": { "type": "string", "description": "CSS transform string" }, + "transparency": { "type": "integer", "description": "Opacity 0-100" }, + "clip": { "type": "string", "description": "CSS clip-path string" }, + "layout_id": { "type": "integer", "description": "Move to layout ID" }, + "locked": { "type": "boolean", "description": "Lock or unlock element" }, + "always_loaded": { "type": "boolean", "description": "Keep loaded in background" }, + "index_lock": { "type": "boolean", "description": "Lock z-index layer" }, + "text": { "type": "string", "description": "New text content (text elements)" }, + "text_size": { "type": "integer", "description": "New font size" }, + "color": { "type": "string", "description": "New color" }, + "font": { "type": "string", "description": "New font" }, + "css": { "type": "string", "description": "New custom CSS" }, + "image_data_id": { "type": "integer", "description": "Swap image asset to data ID" }, + "image_url": { "type": "string", "description": "Swap to image URL" }, + "image_width": { "type": "integer", "description": "New image width" }, + "image_height": { "type": "integer", "description": "New image height" }, + "widget_data_id": { "type": "integer", "description": "Swap widget asset to data ID" }, + "widget_raw_data": { "type": "string", "description": "Replace widget inline definition" }, + "widget_width": { "type": "integer", "description": "New widget width" }, + "widget_height": { "type": "integer", "description": "New widget height" }, + "media_source": { "type": "string", "description": "New media source URL" }, + "media_volume": { "type": "integer", "description": "New media volume 0-100" }, + "media_playing": { "type": "boolean", "description": "Play or pause media" }, + "media_timestamp": { "type": "integer", "description": "Seek position in seconds" }, + "media_autoplay": { "type": "boolean", "description": "Autoplay media" }, + "media_loop": { "type": "boolean", "description": "Loop media" }, + "media_width": { "type": "integer", "description": "New media width" }, + "media_height": { "type": "integer", "description": "New media height" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + }, + "required": ["id"] + } + }, + { + "name": "elementdata_list", + "description": "List element data assets", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Fetch a single asset by ID" }, + "name": { "type": "string", "description": "Filter by exact name" }, + "folder": { "type": "integer", "description": "Filter by folder ID" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + } + } + }, + { + "name": "elementdata_add", + "description": "Create an element data asset", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Name of the asset" }, + "type": { "type": "string", "enum": ["image", "widget", "media", "text"], "description": "Asset type" }, + "data": { "type": "string", "description": "URL or asset data string" }, + "width": { "type": "integer", "description": "Asset width" }, + "height": { "type": "integer", "description": "Asset height" }, + "folder_id": { "type": "integer", "description": "Folder ID to place asset in" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + }, + "required": ["name", "type", "data", "width", "height"] + } + }, + { + "name": "elementdata_update", + "description": "Update fields on an element data asset", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "ID of the asset to update" }, + "name": { "type": "string", "description": "New name of the asset" }, + "data": { "type": "string", "description": "New URL or data string" }, + "width": { "type": "integer", "description": "New asset width" }, + "height": { "type": "integer", "description": "New asset height" }, + "folder_id": { "type": "integer", "description": "Move asset to folder ID" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + }, + "required": ["id"] + } + }, + { + "name": "elementdata_delete", + "description": "Delete an asset (also deletes elements that reference it). Provide either id or name.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "ID of the asset to delete" }, + "name": { "type": "string", "description": "Name of the asset to delete" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + } + } + }, + { + "name": "folders_list", + "description": "List folders", + "inputSchema": { + "type": "object", + "properties": { + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + } + } + }, + { + "name": "folders_add", + "description": "Create a folder", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Name of the folder" }, + "icon": { "type": "string", "description": "Emoji or icon identifier" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + }, + "required": ["name"] + } + }, + { + "name": "folders_update", + "description": "Update a folder's name and/or icon", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "ID of the folder to update" }, + "name": { "type": "string", "description": "New name of the folder" }, + "icon": { "type": "string", "description": "New icon" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + }, + "required": ["id"] + } + }, + { + "name": "folders_delete", + "description": "Delete a folder", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "ID of the folder to delete" }, + "no_preserve_elements": { "type": "boolean", "description": "Also delete all assets inside the folder" }, + "overlay": { "type": "string", "description": "Optional overlay profile nickname" } + }, + "required": ["id"] + } + } + ]) +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 47d79f5..6a1753d 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -8,6 +8,8 @@ mod elementdata; mod elements; mod folders; mod layouts; +mod mcp; +mod osc; mod overlay; mod ping; mod version; @@ -24,6 +26,8 @@ pub fn run(cli: Cli) -> Result<()> { Cmd::Layouts(c) => layouts::run(c.cmd, &global), Cmd::Folders(c) => folders::run(c.cmd, &global), Cmd::Version(c) => version::run(c), + Cmd::Mcp => mcp::run(&global), + Cmd::Osc(c) => osc::run(c, &global), } } diff --git a/crates/cli/src/commands/osc.rs b/crates/cli/src/commands/osc.rs new file mode 100644 index 0000000..9db0220 --- /dev/null +++ b/crates/cli/src/commands/osc.rs @@ -0,0 +1,724 @@ +use std::net::UdpSocket; +use std::time::{Instant, Duration}; +use anyhow::{bail, Result}; +use serde_json::json; +use serde_json::Value; + +use rosc::{OscPacket, OscType, OscMessage}; +use crate::api::client::{qs, ApiClient}; +use crate::cli::{GlobalArgs, OscCmd}; + +struct BucketedState { + run_ping: bool, + run_whoami: bool, + latest_layout_switch: Option, + deletes: std::collections::HashSet, + element_updates: std::collections::HashMap>, +} + +impl BucketedState { + fn new() -> Self { + Self { + run_ping: false, + run_whoami: false, + latest_layout_switch: None, + deletes: std::collections::HashSet::new(), + element_updates: std::collections::HashMap::new(), + } + } +} + +pub fn run(cmd: OscCmd, global: &GlobalArgs) -> Result<()> { + let host = &cmd.host; + let port = cmd.port; + let addr = format!("{host}:{port}"); + + let socket = UdpSocket::bind(&addr)?; + println!("OSC listener running on {addr}"); + println!("Throttling API updates to 30Hz (matching the native server-side update rate)."); + println!("Press Ctrl+C to stop."); + + let client = super::client_for(global)?; + + let mut last_execution = Instant::now() - Duration::from_millis(34); + let min_interval = Duration::from_millis(33); // 30Hz + + let mut last_sent_elements: std::collections::HashMap> = std::collections::HashMap::new(); + + let mut buf = [0u8; 65535]; + loop { + // Block waiting for the first packet + let (size, src) = match socket.recv_from(&mut buf) { + Ok(res) => res, + Err(e) => { + eprintln!("Socket receive error: {e}"); + continue; + } + }; + + // Decode the first packet + let mut pending_packets = Vec::new(); + match rosc::decoder::decode_udp(&buf[..size]) { + Ok((_, packet)) => pending_packets.push(packet), + Err(e) => eprintln!("Error decoding OSC packet from {src}: {e:#}"), + } + + // Set to non-blocking to drain the buffer of any queued packets + if let Err(e) = socket.set_nonblocking(true) { + eprintln!("Failed to set non-blocking: {e}"); + } + + // Drain all pending packets from the OS buffer + loop { + match socket.recv_from(&mut buf) { + Ok((s_size, s_src)) => { + match rosc::decoder::decode_udp(&buf[..s_size]) { + Ok((_, packet)) => pending_packets.push(packet), + Err(e) => eprintln!("Error decoding OSC packet from {s_src}: {e:#}"), + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + break; + } + Err(e) => { + eprintln!("Error draining socket: {e}"); + break; + } + } + } + + // Reset back to blocking + if let Err(e) = socket.set_nonblocking(false) { + eprintln!("Failed to set blocking: {e}"); + } + + // Process and bucket all packets into a single merged state + let mut bucketed_state = BucketedState::new(); + for packet in pending_packets { + process_packet_into_bucket(packet, &mut bucketed_state); + } + + // Enforce the 30Hz rate limit before executing + let elapsed = last_execution.elapsed(); + if elapsed < min_interval { + std::thread::sleep(min_interval - elapsed); + } + last_execution = Instant::now(); + + // Execute layout switch first + if let Some(msg) = bucketed_state.latest_layout_switch { + if let Err(e) = handle_layout_switch(msg, &client) { + eprintln!("Error switching layout: {e:#}"); + } + } + + // Execute pings and whoami + if bucketed_state.run_ping { + if let Err(e) = run_ping_action(&client) { + eprintln!("Error executing ping: {e:#}"); + } + } + if bucketed_state.run_whoami { + if let Err(e) = run_whoami_action(&client) { + eprintln!("Error executing whoami: {e:#}"); + } + } + + // Execute deletes + for id in bucketed_state.deletes { + if let Err(e) = client.delete(&format!("elements?id={id}")) { + eprintln!("Error deleting element {id}: {e:#}"); + } else { + println!("Deleted element {id}"); + last_sent_elements.remove(&id); + } + } + + // Execute element updates (merged states) + for (id, fields_map) in bucketed_state.element_updates { + if fields_map.is_empty() { + continue; + } + + // Get cached state for this element + let cached = last_sent_elements.entry(id).or_insert_with(serde_json::Map::new); + + // Filter out fields that do not exceed the deadband threshold + let mut filtered_map = serde_json::Map::new(); + for (key, val) in fields_map { + let cached_val = cached.get(&key).unwrap_or(&Value::Null); + if should_update_field(&key, &val, cached_val) { + filtered_map.insert(key.clone(), val.clone()); + } + } + + if filtered_map.is_empty() { + continue; + } + + // Update the cache with the new values + let mut cached_val = Value::Object(cached.clone()); + json_merge(&mut cached_val, Value::Object(filtered_map.clone())); + if let Value::Object(merged_cached) = cached_val { + *cached = merged_cached; + } + + let body = Value::Object(filtered_map); + if let Err(e) = client.patch(&format!("elements?id={id}"), body) { + eprintln!("Error updating element {id}: {e:#}"); + } else { + println!("Updated element {id} (merged state)"); + } + } + } +} + +fn process_packet_into_bucket(packet: OscPacket, state: &mut BucketedState) { + match packet { + OscPacket::Message(msg) => { + if let Err(e) = merge_message_into_bucket(msg, state) { + eprintln!("Error bucketing message: {e:#}"); + } + } + OscPacket::Bundle(bundle) => { + for inner in bundle.content { + process_packet_into_bucket(inner, state); + } + } + } +} + +fn merge_message_into_bucket(msg: OscMessage, state: &mut BucketedState) -> Result<()> { + let addr = msg.addr.as_str(); + + match addr { + "/pogly/ping" | "/ping" => { + state.run_ping = true; + } + "/pogly/whoami" | "/whoami" => { + state.run_whoami = true; + } + "/pogly/layouts/set-active" | "/pogly/layouts/set_active" | "/pogly/active_layout" => { + state.latest_layout_switch = Some(msg); + } + "/pogly/elements/delete" => { + if msg.args.is_empty() { + bail!("Expected 1 argument (element ID) for delete"); + } + let id = coerce_to_u32(&msg.args[0])?; + state.deletes.insert(id); + state.element_updates.remove(&id); + } + "/pogly/elements/update/position" => { + if msg.args.len() < 3 { + bail!("Expected 3 arguments (id, x, y) for position update"); + } + let id = coerce_to_u32(&msg.args[0])?; + if state.deletes.contains(&id) { + return Ok(()); + } + let x = coerce_to_i64(&msg.args[1])?; + let y = coerce_to_i64(&msg.args[2])?; + + let val = json!({ + "x": x, + "y": y + }); + merge_element_update(state, id, val); + } + "/pogly/elements/update/transparency" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, transparency) for transparency update"); + } + let id = coerce_to_u32(&msg.args[0])?; + if state.deletes.contains(&id) { + return Ok(()); + } + let transparency = coerce_to_i64(&msg.args[1])?; + + let val = json!({ + "transparency": transparency + }); + merge_element_update(state, id, val); + } + "/pogly/elements/update/text" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, text) for text update"); + } + let id = coerce_to_u32(&msg.args[0])?; + if state.deletes.contains(&id) { + return Ok(()); + } + let text = coerce_to_string(&msg.args[1])?; + + let val = json!({ + "text": { + "text": text + } + }); + merge_element_update(state, id, val); + } + "/pogly/elements/update/media/playing" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, playing) for media playing update"); + } + let id = coerce_to_u32(&msg.args[0])?; + if state.deletes.contains(&id) { + return Ok(()); + } + let playing = coerce_to_bool(&msg.args[1])?; + + let val = json!({ + "media": { + "playing": playing + } + }); + merge_element_update(state, id, val); + } + "/pogly/elements/update/media/volume" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, volume) for media volume update"); + } + let id = coerce_to_u32(&msg.args[0])?; + if state.deletes.contains(&id) { + return Ok(()); + } + let volume = coerce_to_i64(&msg.args[1])?; + + let val = json!({ + "media": { + "volume": volume + } + }); + merge_element_update(state, id, val); + } + "/pogly/elements/update/media/timestamp" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, timestamp) for media timestamp update"); + } + let id = coerce_to_u32(&msg.args[0])?; + if state.deletes.contains(&id) { + return Ok(()); + } + let timestamp = coerce_to_i64(&msg.args[1])?; + + let val = json!({ + "media": { + "timestamp": timestamp + } + }); + merge_element_update(state, id, val); + } + "/pogly/elements/update" => { + if msg.args.len() < 3 { + bail!("Expected 3 arguments (id, field_name, value) for generic update"); + } + let id = coerce_to_u32(&msg.args[0])?; + if state.deletes.contains(&id) { + return Ok(()); + } + let field_name = coerce_to_string(&msg.args[1])?; + let raw_value = &msg.args[2]; + + let val = build_generic_update_body(&field_name, raw_value)?; + merge_element_update(state, id, val); + } + _ => { + println!("Unknown OSC address: {addr}"); + } + } + + Ok(()) +} + +fn merge_element_update(state: &mut BucketedState, id: u32, val: Value) { + let entry = state.element_updates.entry(id).or_insert_with(serde_json::Map::new); + let mut entry_val = Value::Object(entry.clone()); + json_merge(&mut entry_val, val); + if let Value::Object(merged_map) = entry_val { + *entry = merged_map; + } +} + +fn json_merge(target: &mut Value, source: Value) { + match (target, source) { + (Value::Object(target_map), Value::Object(source_map)) => { + for (key, val) in source_map { + if !target_map.contains_key(&key) { + target_map.insert(key, val); + } else { + json_merge(target_map.get_mut(&key).unwrap(), val); + } + } + } + (target, source) => { + *target = source; + } + } +} + +fn handle_layout_switch(msg: OscMessage, client: &ApiClient) -> Result<()> { + if msg.args.is_empty() { + bail!("Expected 1 argument (layout name or ID)"); + } + let arg = &msg.args[0]; + let response = if let Ok(id) = coerce_to_u32(arg) { + let query = qs(&[("id", Some(id.to_string())), ("name", None)]); + client.post(&format!("layout{query}"), json!({}))? + } else { + let name = coerce_to_string(arg)?; + let query = qs(&[("id", None), ("name", Some(name))]); + client.post(&format!("layout{query}"), json!({}))? + }; + let layout = &response["activeLayout"]; + println!( + "Switched to layout: {} '{}'", + layout["id"], + layout["name"].as_str().unwrap_or("") + ); + Ok(()) +} + +fn run_ping_action(client: &ApiClient) -> Result<()> { + let response = client.get("ping")?; + println!("Ping response: {response}"); + Ok(()) +} + +fn run_whoami_action(client: &ApiClient) -> Result<()> { + let response = client.get("whoami")?; + println!("Whoami response: {response}"); + Ok(()) +} + +fn should_update_field(key: &str, new_val: &Value, cached_val: &Value) -> bool { + if new_val == cached_val { + return false; + } + + match key { + "x" | "y" | "transparency" => { + if let (Some(n), Some(c)) = (new_val.as_i64(), cached_val.as_i64()) { + return (n - c).abs() > 1; // Deadband: must change by at least 2 units + } + } + "media" => { + if let (Some(n_obj), Some(c_obj)) = (new_val.as_object(), cached_val.as_object()) { + for (sub_key, sub_val) in n_obj { + if let Some(c_val) = c_obj.get(sub_key) { + if sub_key == "volume" || sub_key == "timestamp" { + if let (Some(n), Some(c)) = (sub_val.as_i64(), c_val.as_i64()) { + if (n - c).abs() > 1 { + return true; + } + } else if sub_val != c_val { + return true; + } + } else if sub_val != c_val { + return true; + } + } else { + return true; + } + } + return false; + } + } + "text" => { + if let (Some(n_obj), Some(c_obj)) = (new_val.as_object(), cached_val.as_object()) { + if let (Some(n_val), Some(c_val)) = (n_obj.get("size"), c_obj.get("size")) { + if let (Some(n), Some(c)) = (n_val.as_i64(), c_val.as_i64()) { + if (n - c).abs() > 1 { + return true; + } + } + } + } + } + _ => {} + } + + new_val != cached_val +} + +fn coerce_to_string(arg: &OscType) -> Result { + match arg { + OscType::String(s) => Ok(s.clone()), + OscType::Int(i) => Ok(i.to_string()), + OscType::Long(l) => Ok(l.to_string()), + OscType::Float(f) => Ok(f.to_string()), + OscType::Double(d) => Ok(d.to_string()), + OscType::Bool(b) => Ok(b.to_string()), + _ => bail!("Cannot coerce OSC arg {:?} to string", arg), + } +} + +fn coerce_to_u32(arg: &OscType) -> Result { + match arg { + OscType::Int(i) => Ok(*i as u32), + OscType::Long(l) => Ok(*l as u32), + OscType::Float(f) => Ok(*f as u32), + OscType::Double(d) => Ok(*d as u32), + OscType::String(s) => s.parse::().map_err(|e| anyhow::anyhow!("Failed to parse string to u32: {e}")), + _ => bail!("Cannot coerce OSC arg {:?} to u32", arg), + } +} + +fn coerce_to_i64(arg: &OscType) -> Result { + match arg { + OscType::Int(i) => Ok(*i as i64), + OscType::Long(l) => Ok(*l), + OscType::Float(f) => Ok(*f as i64), + OscType::Double(d) => Ok(*d as i64), + OscType::String(s) => s.parse::().map_err(|e| anyhow::anyhow!("Failed to parse string to i64: {e}")), + _ => bail!("Cannot coerce OSC arg {:?} to i64", arg), + } +} + +fn coerce_to_bool(arg: &OscType) -> Result { + match arg { + OscType::Bool(b) => Ok(*b), + OscType::Int(i) => Ok(*i != 0), + OscType::Long(l) => Ok(*l != 0), + OscType::Float(f) => Ok(*f != 0.0), + OscType::Double(d) => Ok(*d != 0.0), + OscType::String(s) => { + let s_lower = s.to_lowercase(); + if s_lower == "true" || s_lower == "1" { + Ok(true) + } else if s_lower == "false" || s_lower == "0" { + Ok(false) + } else { + bail!("Invalid boolean string: {s}") + } + } + _ => bail!("Cannot coerce OSC arg {:?} to bool", arg), + } +} + +fn build_generic_update_body(field_name: &str, raw_value: &OscType) -> Result { + let mut body = serde_json::Map::new(); + + match field_name { + // Root fields + "x" | "y" | "transparency" | "layout_id" | "layoutId" => { + let val = coerce_to_i64(raw_value)?; + body.insert( + if field_name == "layout_id" { "layoutId".to_string() } else { field_name.to_string() }, + json!(val) + ); + } + "transform" | "clip" => { + let val = coerce_to_string(raw_value)?; + body.insert(field_name.to_string(), json!(val)); + } + "locked" | "always_loaded" | "alwaysLoaded" | "index_lock" | "indexLock" => { + let val = coerce_to_bool(raw_value)?; + let key = match field_name { + "always_loaded" => "alwaysLoaded", + "index_lock" => "indexLock", + other => other, + }; + body.insert(key.to_string(), json!(val)); + } + + // Text group fields + "text" => { + let val = coerce_to_string(raw_value)?; + body.insert("text".to_string(), json!({ "text": val })); + } + "text_size" | "textSize" | "size" => { + let val = coerce_to_i64(raw_value)?; + body.insert("text".to_string(), json!({ "size": val })); + } + "color" | "font" | "css" => { + let val = coerce_to_string(raw_value)?; + body.insert("text".to_string(), json!({ field_name: val })); + } + + // Image group fields + "image_data_id" | "imageDataId" | "image_element_data_id" => { + let val = coerce_to_u32(raw_value)?; + body.insert("image".to_string(), json!({ "elementDataId": val })); + } + "image_url" | "imageUrl" | "url" => { + let val = coerce_to_string(raw_value)?; + body.insert("image".to_string(), json!({ "url": val })); + } + "image_width" | "imageWidth" => { + let val = coerce_to_i64(raw_value)?; + body.insert("image".to_string(), json!({ "width": val })); + } + "image_height" | "imageHeight" => { + let val = coerce_to_i64(raw_value)?; + body.insert("image".to_string(), json!({ "height": val })); + } + + // Widget group fields + "widget_data_id" | "widgetDataId" | "widget_element_data_id" => { + let val = coerce_to_u32(raw_value)?; + body.insert("widget".to_string(), json!({ "elementDataId": val })); + } + "widget_raw_data" | "widgetRawData" | "raw_data" => { + let val = coerce_to_string(raw_value)?; + body.insert("widget".to_string(), json!({ "rawData": val })); + } + "widget_width" | "widgetWidth" => { + let val = coerce_to_i64(raw_value)?; + body.insert("widget".to_string(), json!({ "width": val })); + } + "widget_height" | "widgetHeight" => { + let val = coerce_to_i64(raw_value)?; + body.insert("widget".to_string(), json!({ "height": val })); + } + + // Media group fields + "media_source" | "mediaSource" | "source" => { + let val = coerce_to_string(raw_value)?; + body.insert("media".to_string(), json!({ "source": val })); + } + "media_volume" | "mediaVolume" | "volume" => { + let val = coerce_to_i64(raw_value)?; + body.insert("media".to_string(), json!({ "volume": val })); + } + "media_playing" | "mediaPlaying" | "playing" => { + let val = coerce_to_bool(raw_value)?; + body.insert("media".to_string(), json!({ "playing": val })); + } + "media_timestamp" | "mediaTimestamp" | "timestamp" => { + let val = coerce_to_i64(raw_value)?; + body.insert("media".to_string(), json!({ "timestamp": val })); + } + "media_autoplay" | "mediaAutoplay" | "autoplay" => { + let val = coerce_to_bool(raw_value)?; + body.insert("media".to_string(), json!({ "autoplay": val })); + } + "media_loop" | "mediaLoop" | "loop" => { + let val = coerce_to_bool(raw_value)?; + body.insert("media".to_string(), json!({ "loop": val })); + } + "media_width" | "mediaWidth" => { + let val = coerce_to_i64(raw_value)?; + body.insert("media".to_string(), json!({ "width": val })); + } + "media_height" | "mediaHeight" => { + let val = coerce_to_i64(raw_value)?; + body.insert("media".to_string(), json!({ "height": val })); + } + + _ => bail!("Unknown element update field: {field_name}"), + } + + Ok(Value::Object(body)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_coerce_to_string() { + assert_eq!(coerce_to_string(&OscType::String("test".into())).unwrap(), "test"); + assert_eq!(coerce_to_string(&OscType::Int(123)).unwrap(), "123"); + assert_eq!(coerce_to_string(&OscType::Long(456)).unwrap(), "456"); + assert_eq!(coerce_to_string(&OscType::Float(1.23)).unwrap(), "1.23"); + assert_eq!(coerce_to_string(&OscType::Double(4.56)).unwrap(), "4.56"); + assert_eq!(coerce_to_string(&OscType::Bool(true)).unwrap(), "true"); + } + + #[test] + fn test_coerce_to_u32() { + assert_eq!(coerce_to_u32(&OscType::Int(123)).unwrap(), 123); + assert_eq!(coerce_to_u32(&OscType::Long(456)).unwrap(), 456); + assert_eq!(coerce_to_u32(&OscType::Float(7.89)).unwrap(), 7); + assert_eq!(coerce_to_u32(&OscType::Double(9.87)).unwrap(), 9); + assert_eq!(coerce_to_u32(&OscType::String("654".into())).unwrap(), 654); + } + + #[test] + fn test_coerce_to_i64() { + assert_eq!(coerce_to_i64(&OscType::Int(123)).unwrap(), 123); + assert_eq!(coerce_to_i64(&OscType::Long(456)).unwrap(), 456); + assert_eq!(coerce_to_i64(&OscType::Float(7.89)).unwrap(), 7); + assert_eq!(coerce_to_i64(&OscType::Double(9.87)).unwrap(), 9); + assert_eq!(coerce_to_i64(&OscType::String("654".into())).unwrap(), 654); + } + + #[test] + fn test_coerce_to_bool() { + assert_eq!(coerce_to_bool(&OscType::Bool(true)).unwrap(), true); + assert_eq!(coerce_to_bool(&OscType::Int(1)).unwrap(), true); + assert_eq!(coerce_to_bool(&OscType::Int(0)).unwrap(), false); + assert_eq!(coerce_to_bool(&OscType::Float(0.0)).unwrap(), false); + assert_eq!(coerce_to_bool(&OscType::Float(1.0)).unwrap(), true); + assert_eq!(coerce_to_bool(&OscType::String("true".into())).unwrap(), true); + assert_eq!(coerce_to_bool(&OscType::String("0".into())).unwrap(), false); + } + + #[test] + fn test_build_generic_update_body() { + let body = build_generic_update_body("x", &OscType::Int(120)).unwrap(); + assert_eq!(body, json!({ "x": 120 })); + + let body = build_generic_update_body("text", &OscType::String("hello".into())).unwrap(); + assert_eq!(body, json!({ "text": { "text": "hello" } })); + + let body = build_generic_update_body("always_loaded", &OscType::Bool(true)).unwrap(); + assert_eq!(body, json!({ "alwaysLoaded": true })); + + let body = build_generic_update_body("media_playing", &OscType::Int(1)).unwrap(); + assert_eq!(body, json!({ "media": { "playing": true } })); + } + + #[test] + fn test_json_merge() { + let mut target = json!({ + "x": 10, + "media": { + "playing": true + } + }); + + let source = json!({ + "y": 20, + "media": { + "volume": 80 + } + }); + + json_merge(&mut target, source); + + assert_eq!(target, json!({ + "x": 10, + "y": 20, + "media": { + "playing": true, + "volume": 80 + } + })); + } + + #[test] + fn test_should_update_field() { + // Test identical values (should not update) + assert!(!should_update_field("x", &json!(10), &json!(10))); + + // Test minor changes within deadband (should not update) + assert!(!should_update_field("x", &json!(11), &json!(10))); + assert!(!should_update_field("x", &json!(9), &json!(10))); + + // Test larger changes exceeding deadband (should update) + assert!(should_update_field("x", &json!(12), &json!(10))); + assert!(should_update_field("x", &json!(8), &json!(10))); + + // Test non-numeric changes (should update) + assert!(should_update_field("transform", &json!("scale(1)"), &json!("scale(2)"))); + + // Test nested objects like media volume within deadband + let c_media = json!({"volume": 50}); + let n_media_small = json!({"volume": 51}); + let n_media_large = json!({"volume": 55}); + assert!(!should_update_field("media", &n_media_small, &c_media)); + assert!(should_update_field("media", &n_media_large, &c_media)); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 21f2ac8..3feab86 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "512"] + mod api; mod cli; mod commands; diff --git a/crates/cli/src/paths.rs b/crates/cli/src/paths.rs index 5f232d4..c9fc8af 100644 --- a/crates/cli/src/paths.rs +++ b/crates/cli/src/paths.rs @@ -1,19 +1,47 @@ use std::path::PathBuf; +#[cfg(windows)] fn env_dir(var: &str) -> PathBuf { std::env::var_os(var) .map(PathBuf::from) .unwrap_or_else(std::env::temp_dir) } +#[cfg(windows)] pub fn config_dir() -> PathBuf { env_dir("APPDATA").join("Pogly").join("cli") } +#[cfg(windows)] pub fn local_dir() -> PathBuf { env_dir("LOCALAPPDATA").join("Pogly").join("cli") } +#[cfg(not(windows))] +fn home_dir() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) +} + +#[cfg(not(windows))] +pub fn config_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { + xdg.join("Pogly").join("cli") + } else { + home_dir().join(".config").join("Pogly").join("cli") + } +} + +#[cfg(not(windows))] +pub fn local_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) { + xdg.join("Pogly").join("cli") + } else { + home_dir().join(".local").join("share").join("Pogly").join("cli") + } +} + pub fn config_file() -> PathBuf { config_dir().join("config.toml") } @@ -31,5 +59,9 @@ pub fn bin_dir() -> PathBuf { } pub fn launcher_path() -> PathBuf { - local_dir().join("pogly.exe") + if cfg!(windows) { + local_dir().join("pogly.exe") + } else { + local_dir().join("pogly") + } } diff --git a/crates/cli/src/versionstore.rs b/crates/cli/src/versionstore.rs index 22e8ad6..87a7436 100644 --- a/crates/cli/src/versionstore.rs +++ b/crates/cli/src/versionstore.rs @@ -70,11 +70,14 @@ fn download(url: &str) -> Result> { } pub fn install_version(version: &str, release: &Value) -> Result<()> { - let url = asset_url(release, "pogly-cli.exe").context("release has no pogly-cli.exe asset")?; + let binary_name = if cfg!(windows) { "pogly-cli.exe" } else { "pogly-cli" }; + let url = asset_url(release, binary_name) + .or_else(|| asset_url(release, "pogly-cli.exe")) + .context("release has no pogly-cli asset")?; let dir = paths::bin_dir().join(version); std::fs::create_dir_all(&dir)?; - let target = dir.join("pogly-cli.exe"); - let tmp = dir.join("pogly-cli.exe.tmp"); + let target = dir.join(binary_name); + let tmp = dir.join(format!("{binary_name}.tmp")); std::fs::write(&tmp, download(url)?)?; std::fs::rename(&tmp, &target) .with_context(|| format!("failed to install {}", target.display()))?; @@ -82,18 +85,20 @@ pub fn install_version(version: &str, release: &Value) -> Result<()> { } // The launcher stays a running parent process during upgrades, so it can't be -// overwritten — but Windows allows renaming a running image out of the way. +// overwritten — but Windows/Unix allows renaming a running image out of the way. // The launcher deletes the .old file on its next start. pub fn replace_launcher(release: &Value) -> Result<()> { let path = paths::launcher_path(); if !path.is_file() { return Ok(()); } - let Some(url) = asset_url(release, "pogly.exe") else { + let launcher_name = if cfg!(windows) { "pogly.exe" } else { "pogly" }; + let Some(url) = asset_url(release, launcher_name).or_else(|| asset_url(release, "pogly.exe")) else { return Ok(()); }; let bytes = download(url)?; - let old = paths::local_dir().join("pogly.exe.old"); + let launcher_old_name = if cfg!(windows) { "pogly.exe.old" } else { "pogly.old" }; + let old = paths::local_dir().join(launcher_old_name); let _ = std::fs::remove_file(&old); std::fs::rename(&path, &old).context("failed to move the current launcher aside")?; if let Err(e) = std::fs::write(&path, bytes) { diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs index 2871c38..64b93dd 100644 --- a/crates/launcher/src/main.rs +++ b/crates/launcher/src/main.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(windows)] fn local_dir() -> PathBuf { std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) @@ -9,6 +10,22 @@ fn local_dir() -> PathBuf { .join("cli") } +#[cfg(not(windows))] +fn home_dir() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) +} + +#[cfg(not(windows))] +fn local_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) { + xdg.join("Pogly").join("cli") + } else { + home_dir().join(".local").join("share").join("Pogly").join("cli") + } +} + fn selected_version(dir: &Path) -> Option { let pointer = std::fs::read_to_string(dir.join("version")) .ok() @@ -17,11 +34,14 @@ fn selected_version(dir: &Path) -> Option { if pointer.is_some() { return pointer; } + + let cli_name = if cfg!(windows) { "pogly-cli.exe" } else { "pogly-cli" }; + // No pointer file: fall back to the highest installed version. let mut versions: Vec = std::fs::read_dir(dir.join("bin")) .ok()? .flatten() - .filter(|e| e.path().join("pogly-cli.exe").is_file()) + .filter(|e| e.path().join(cli_name).is_file()) .filter_map(|e| e.file_name().into_string().ok()) .collect(); versions.sort(); @@ -31,12 +51,14 @@ fn selected_version(dir: &Path) -> Option { fn main() { let dir = local_dir(); + let launcher_old_name = if cfg!(windows) { "pogly.exe.old" } else { "pogly.old" }; + // Leftover from a launcher self-upgrade; it can't delete itself while running. if let Some(parent) = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|p| p.to_path_buf())) { - let _ = std::fs::remove_file(parent.join("pogly.exe.old")); + let _ = std::fs::remove_file(parent.join(launcher_old_name)); } let Some(version) = selected_version(&dir) else { @@ -45,7 +67,8 @@ fn main() { std::process::exit(1); }; - let exe = dir.join("bin").join(&version).join("pogly-cli.exe"); + let cli_name = if cfg!(windows) { "pogly-cli.exe" } else { "pogly-cli" }; + let exe = dir.join("bin").join(&version).join(cli_name); if !exe.is_file() { eprintln!("pogly-cli {version} is not installed at {}", exe.display()); eprintln!("Run `pogly version list` from an installed version, or reinstall from https://github.com/PoglyApp/pogly-cli/releases"); diff --git a/examples/README.md b/examples/README.md index 22928e2..f2075ef 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,9 +8,11 @@ Small scripts showing how to drive a Pogly overlay from the outside world. All o | [sync-layout.ps1](sync-layout.ps1) | Switches the active Pogly layout to match an OBS scene name; skips scenes without a matching layout | | [set-text.ps1](set-text.ps1) | Rewrites an existing text element — donation goals, death counters, "subs today" | | [rickroll.sh](rickroll.sh) | Spawns a looping rick roll and teleports it around the canvas for 10 seconds, then cleans up | +| [send-osc.py](send-osc.py) | Sends OSC (Open Sound Control) messages to control layouts and elements over UDP | ## Hooking into Streamer.bot + Any event Streamer.bot can see (Twitch, Kick, YouTube subs/raids/follows/channel points, OBS scene changes) can run these scripts: 1. Create an **Action** for your event trigger. @@ -39,6 +41,16 @@ pogly layouts set-active --name BRB for one-button scene switches, or point a button at any script above. +## Open Sound Control (OSC) + +Alternatively, you can run `pogly` as an OSC listener to receive instant layout and element updates over UDP (e.g. from TouchOSC, Stream Deck OSC plugins, or VRChat): + +``` +pogly osc --port 9000 +``` + +See [send-osc.py](send-osc.py) for an example sender script. + ## Writing your own Every command supports `--json`, so any language that can spawn a process and parse JSON can orchestrate the overlay: diff --git a/examples/send-osc.py b/examples/send-osc.py new file mode 100644 index 0000000..7b1c97c --- /dev/null +++ b/examples/send-osc.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Example script showing how to send OSC (Open Sound Control) messages +to control a Pogly overlay via the pogly-cli OSC listener. + +Requirements: + pip install python-osc + +Usage: + 1. Start the listener: + pogly osc --port 9000 + + 2. Run this script: + python send-osc.py +""" + +import time +import argparse +from pythonosc import udp_client + +def main(): + parser = argparse.ArgumentParser(description="Send OSC messages to pogly-cli") + parser.add_argument("--host", default="127.0.0.1", help="The host running pogly osc") + parser.add_argument("--port", type=int, default=9000, help="The port pogly osc is listening on") + args = parser.parse_args() + + client = udp_client.SimpleUDPClient(args.host, args.port) + + print(f"Sending OSC messages to {args.host}:{args.port}...") + + # 1. Ping the listener + print("Sending ping...") + client.send_message("/pogly/ping", []) + time.sleep(1) + + # 2. Switch to active layout by name (replace 'Main' with your layout name if different) + print("Switching layout to 'Main'...") + client.send_message("/pogly/layouts/set-active", "Main") + time.sleep(1) + + # 3. Update an element's position (replace 42 with your element ID) + element_id = 42 + print(f"Moving element {element_id} to (100, 200)...") + client.send_message("/pogly/elements/update/position", [element_id, 100, 200]) + time.sleep(1) + + # 4. Fade element opacity to 50% + print(f"Setting element {element_id} transparency to 50%...") + client.send_message("/pogly/elements/update/transparency", [element_id, 50]) + time.sleep(1) + + # 5. Generic field update: change text color to green (#00ff00) + print(f"Changing element {element_id} text color to green...") + client.send_message("/pogly/elements/update", [element_id, "color", "#00ff00"]) + + print("Done!") + +if __name__ == "__main__": + main()