From 48648fc0b7b46df320a40855f6a5c155ba0f61f5 Mon Sep 17 00:00:00 2001 From: Dingo <7553117+DingosGotMyBaby@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:32:56 +1000 Subject: [PATCH 1/4] Create new MCP component for pogly-cli I used gemini to make this like the lazy cunt I am. Adds a lightweight, stdio-based MCP server subcommand to the pogly-cli. This enables AI assistants (like Claude Desktop or Antigravity) to view, build, and manage overlay layouts and elements directly. Key changes: - Created a custom JSON-RPC 2.0 stdio message handler in commands/mcp.rs with zero external async dependencies (tokio, rmcp, etc.). - Registered the new `mcp` subcommand in cli.rs and commands/mod.rs. - Exposed 23 MCP tools mapping to elements, elementdata, layouts, folders, and status commands (each accepting an optional 'overlay' nickname flag). - Handled automatic overlay resolution and API client forwarding. - Added Claude Desktop and source-based configuration instructions to the README. --- README.md | 46 ++ crates/cli/src/cli.rs | 2 + crates/cli/src/commands/mcp.rs | 1046 ++++++++++++++++++++++++++++++++ crates/cli/src/commands/mod.rs | 2 + crates/cli/src/main.rs | 2 + 5 files changed, 1098 insertions(+) create mode 100644 crates/cli/src/commands/mcp.rs diff --git a/README.md b/README.md index 8edba49..7701238 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,52 @@ 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" + ] + } + } +} +``` + ## Building from source ``` diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index ac56522..4b468f4 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -38,6 +38,8 @@ pub enum Cmd { Folders(FoldersCmd), /// Show or manage the installed CLI version Version(VersionCmd), + /// Run the MCP (Model Context Protocol) server + Mcp, } #[derive(Args)] 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..ed7bac2 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -8,6 +8,7 @@ mod elementdata; mod elements; mod folders; mod layouts; +mod mcp; mod overlay; mod ping; mod version; @@ -24,6 +25,7 @@ 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), } } 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; From 47756b67f9c077061c27abaf5089992898608ef8 Mon Sep 17 00:00:00 2001 From: Dingo <7553117+DingosGotMyBaby@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:33:18 +1000 Subject: [PATCH 2/4] Adds OSC support why? why not. the rest is large loser model generated of course - Integrate the `rosc` crate for OSC message and bundle decoding - Define `pogly osc` subcommand with configurable `--host` and `--port` (defaulting to 127.0.0.1:9000) - Implement a UDP listener server that routes OSC address patterns to Pogly HTTP API endpoints: - Active layout selection (/pogly/layouts/set-active) - Element deletion (/pogly/elements/delete) - Element property updates (/pogly/elements/update/...) - Generic element update routing (/pogly/elements/update) for any key-value property - Support robust type coercion (coercing float, int, string, and bool inputs into correct API types) - Add comprehensive unit tests verifying coercion rules and dynamic JSON builders - Provide `send-osc.py` example and document OSC commands in READMEs --- Cargo.lock | 33 +++ README.md | 29 +++ crates/cli/Cargo.toml | 2 + crates/cli/src/cli.rs | 13 + crates/cli/src/commands/mod.rs | 2 + crates/cli/src/commands/osc.rs | 434 +++++++++++++++++++++++++++++++++ examples/README.md | 12 + examples/send-osc.py | 59 +++++ 8 files changed, 584 insertions(+) create mode 100644 crates/cli/src/commands/osc.rs create mode 100644 examples/send-osc.py 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 7701238..87136e4 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. @@ -131,6 +132,34 @@ Or for development / running from source: } ``` +## 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 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 4b468f4..bfc8d98 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -40,6 +40,8 @@ pub enum Cmd { Version(VersionCmd), /// Run the MCP (Model Context Protocol) server Mcp, + /// Run the OSC (Open Sound Control) listener server + Osc(OscCmd), } #[derive(Args)] @@ -445,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/mod.rs b/crates/cli/src/commands/mod.rs index ed7bac2..6a1753d 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -9,6 +9,7 @@ mod elements; mod folders; mod layouts; mod mcp; +mod osc; mod overlay; mod ping; mod version; @@ -26,6 +27,7 @@ pub fn run(cli: Cli) -> Result<()> { 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..d46dfe9 --- /dev/null +++ b/crates/cli/src/commands/osc.rs @@ -0,0 +1,434 @@ +use std::net::UdpSocket; +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}; + +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!("Press Ctrl+C to stop."); + + let client = super::client_for(global)?; + + let mut buf = [0u8; 65535]; + loop { + match socket.recv_from(&mut buf) { + Ok((size, src)) => { + match rosc::decoder::decode_udp(&buf[..size]) { + Ok((_, packet)) => { + if let Err(e) = handle_packet(packet, &client) { + eprintln!("Error handling packet from {src}: {e:#}"); + } + } + Err(e) => { + eprintln!("Error decoding OSC packet from {src}: {e:#}"); + } + } + } + Err(e) => { + eprintln!("Socket receive error: {e}"); + } + } + } +} + +fn handle_packet(packet: OscPacket, client: &ApiClient) -> Result<()> { + match packet { + OscPacket::Message(msg) => handle_message(msg, client), + OscPacket::Bundle(bundle) => { + for inner in bundle.content { + if let Err(e) = handle_packet(inner, client) { + eprintln!("Error handling message in bundle: {e:#}"); + } + } + Ok(()) + } + } +} + +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 handle_message(msg: OscMessage, client: &ApiClient) -> Result<()> { + let addr = msg.addr.as_str(); + println!("Received: {} ({} args)", addr, msg.args.len()); + + match addr { + "/pogly/ping" | "/ping" => { + let response = client.get("ping")?; + println!("Ping response: {response}"); + } + "/pogly/whoami" | "/whoami" => { + let response = client.get("whoami")?; + println!("Whoami response: {response}"); + } + "/pogly/layouts/set-active" | "/pogly/layouts/set_active" | "/pogly/active_layout" => { + 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("") + ); + } + "/pogly/elements/delete" => { + if msg.args.is_empty() { + bail!("Expected 1 argument (element ID)"); + } + let id = coerce_to_u32(&msg.args[0])?; + client.delete(&format!("elements?id={id}"))?; + println!("Deleted element {id}"); + } + "/pogly/elements/update/position" => { + if msg.args.len() < 3 { + bail!("Expected 3 arguments (id, x, y)"); + } + let id = coerce_to_u32(&msg.args[0])?; + let x = coerce_to_i64(&msg.args[1])?; + let y = coerce_to_i64(&msg.args[2])?; + + let body = json!({ + "x": x, + "y": y + }); + client.patch(&format!("elements?id={id}"), body)?; + println!("Updated element {id} position to ({x}, {y})"); + } + "/pogly/elements/update/transparency" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, transparency)"); + } + let id = coerce_to_u32(&msg.args[0])?; + let transparency = coerce_to_i64(&msg.args[1])?; + + let body = json!({ + "transparency": transparency + }); + client.patch(&format!("elements?id={id}"), body)?; + println!("Updated element {id} transparency to {transparency}%"); + } + "/pogly/elements/update/text" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, text)"); + } + let id = coerce_to_u32(&msg.args[0])?; + let text = coerce_to_string(&msg.args[1])?; + + let body = json!({ + "text": { + "text": text + } + }); + client.patch(&format!("elements?id={id}"), body)?; + println!("Updated element {id} text"); + } + "/pogly/elements/update/media/playing" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, playing)"); + } + let id = coerce_to_u32(&msg.args[0])?; + let playing = coerce_to_bool(&msg.args[1])?; + + let body = json!({ + "media": { + "playing": playing + } + }); + client.patch(&format!("elements?id={id}"), body)?; + println!("Updated element {id} playing to {playing}"); + } + "/pogly/elements/update/media/volume" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, volume)"); + } + let id = coerce_to_u32(&msg.args[0])?; + let volume = coerce_to_i64(&msg.args[1])?; + + let body = json!({ + "media": { + "volume": volume + } + }); + client.patch(&format!("elements?id={id}"), body)?; + println!("Updated element {id} volume to {volume}%"); + } + "/pogly/elements/update/media/timestamp" => { + if msg.args.len() < 2 { + bail!("Expected 2 arguments (id, timestamp)"); + } + let id = coerce_to_u32(&msg.args[0])?; + let timestamp = coerce_to_i64(&msg.args[1])?; + + let body = json!({ + "media": { + "timestamp": timestamp + } + }); + client.patch(&format!("elements?id={id}"), body)?; + println!("Updated element {id} timestamp to {timestamp}s"); + } + "/pogly/elements/update" => { + if msg.args.len() < 3 { + bail!("Expected 3 arguments (id, field_name, value)"); + } + let id = coerce_to_u32(&msg.args[0])?; + let field_name = coerce_to_string(&msg.args[1])?; + let raw_value = &msg.args[2]; + + let body = build_generic_update_body(&field_name, raw_value)?; + client.patch(&format!("elements?id={id}"), body)?; + println!("Updated element {id} field '{field_name}'"); + } + _ => { + println!("Unknown OSC address: {addr}"); + } + } + + Ok(()) +} + +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 } })); + } +} 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() From 306cc8f979cdb8012ce85bfa43ed803776c87aa4 Mon Sep 17 00:00:00 2001 From: Dingo <7553117+DingosGotMyBaby@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:53:06 +1000 Subject: [PATCH 3/4] Limits updates from OSC to 30Hz Pogly runs at 30hz on the server side with clients interpolating to their native refresh rate. So therefore we only need to send an event when required by the server, not every time an OSC event arrives --- README.md | 2 + crates/cli/src/commands/osc.rs | 540 +++++++++++++++++++++++++-------- 2 files changed, 417 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 87136e4..b3bbf4d 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ Or for development / running from source: `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: ``` diff --git a/crates/cli/src/commands/osc.rs b/crates/cli/src/commands/osc.rs index d46dfe9..9db0220 100644 --- a/crates/cli/src/commands/osc.rs +++ b/crates/cli/src/commands/osc.rs @@ -1,4 +1,5 @@ use std::net::UdpSocket; +use std::time::{Instant, Duration}; use anyhow::{bail, Result}; use serde_json::json; use serde_json::Value; @@ -7,6 +8,26 @@ 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; @@ -14,241 +35,293 @@ pub fn run(cmd: OscCmd, global: &GlobalArgs) -> Result<()> { 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 { - match socket.recv_from(&mut buf) { - Ok((size, src)) => { - match rosc::decoder::decode_udp(&buf[..size]) { - Ok((_, packet)) => { - if let Err(e) = handle_packet(packet, &client) { - eprintln!("Error handling packet from {src}: {e:#}"); - } - } - Err(e) => { - eprintln!("Error decoding OSC packet from {src}: {e:#}"); - } - } - } + // 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:#}"), } - } -} -fn handle_packet(packet: OscPacket, client: &ApiClient) -> Result<()> { - match packet { - OscPacket::Message(msg) => handle_message(msg, client), - OscPacket::Bundle(bundle) => { - for inner in bundle.content { - if let Err(e) = handle_packet(inner, client) { - eprintln!("Error handling message in bundle: {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; } } - Ok(()) } - } -} -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), - } -} + // Reset back to blocking + if let Err(e) = socket.set_nonblocking(false) { + eprintln!("Failed to set blocking: {e}"); + } -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), - } -} + // 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); + } -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), + // 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 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}") +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); } } - _ => bail!("Cannot coerce OSC arg {:?} to bool", arg), } } -fn handle_message(msg: OscMessage, client: &ApiClient) -> Result<()> { +fn merge_message_into_bucket(msg: OscMessage, state: &mut BucketedState) -> Result<()> { let addr = msg.addr.as_str(); - println!("Received: {} ({} args)", addr, msg.args.len()); match addr { "/pogly/ping" | "/ping" => { - let response = client.get("ping")?; - println!("Ping response: {response}"); + state.run_ping = true; } "/pogly/whoami" | "/whoami" => { - let response = client.get("whoami")?; - println!("Whoami response: {response}"); + state.run_whoami = true; } "/pogly/layouts/set-active" | "/pogly/layouts/set_active" | "/pogly/active_layout" => { - 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("") - ); + state.latest_layout_switch = Some(msg); } "/pogly/elements/delete" => { if msg.args.is_empty() { - bail!("Expected 1 argument (element ID)"); + bail!("Expected 1 argument (element ID) for delete"); } let id = coerce_to_u32(&msg.args[0])?; - client.delete(&format!("elements?id={id}"))?; - println!("Deleted element {id}"); + 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)"); + 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 body = json!({ + let val = json!({ "x": x, "y": y }); - client.patch(&format!("elements?id={id}"), body)?; - println!("Updated element {id} position to ({x}, {y})"); + merge_element_update(state, id, val); } "/pogly/elements/update/transparency" => { if msg.args.len() < 2 { - bail!("Expected 2 arguments (id, transparency)"); + 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 body = json!({ + let val = json!({ "transparency": transparency }); - client.patch(&format!("elements?id={id}"), body)?; - println!("Updated element {id} transparency to {transparency}%"); + merge_element_update(state, id, val); } "/pogly/elements/update/text" => { if msg.args.len() < 2 { - bail!("Expected 2 arguments (id, text)"); + 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 body = json!({ + let val = json!({ "text": { "text": text } }); - client.patch(&format!("elements?id={id}"), body)?; - println!("Updated element {id} text"); + merge_element_update(state, id, val); } "/pogly/elements/update/media/playing" => { if msg.args.len() < 2 { - bail!("Expected 2 arguments (id, playing)"); + 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 body = json!({ + let val = json!({ "media": { "playing": playing } }); - client.patch(&format!("elements?id={id}"), body)?; - println!("Updated element {id} playing to {playing}"); + merge_element_update(state, id, val); } "/pogly/elements/update/media/volume" => { if msg.args.len() < 2 { - bail!("Expected 2 arguments (id, volume)"); + 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 body = json!({ + let val = json!({ "media": { "volume": volume } }); - client.patch(&format!("elements?id={id}"), body)?; - println!("Updated element {id} volume to {volume}%"); + merge_element_update(state, id, val); } "/pogly/elements/update/media/timestamp" => { if msg.args.len() < 2 { - bail!("Expected 2 arguments (id, timestamp)"); + 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 body = json!({ + let val = json!({ "media": { "timestamp": timestamp } }); - client.patch(&format!("elements?id={id}"), body)?; - println!("Updated element {id} timestamp to {timestamp}s"); + merge_element_update(state, id, val); } "/pogly/elements/update" => { if msg.args.len() < 3 { - bail!("Expected 3 arguments (id, field_name, value)"); + 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 body = build_generic_update_body(&field_name, raw_value)?; - client.patch(&format!("elements?id={id}"), body)?; - println!("Updated element {id} field '{field_name}'"); + let val = build_generic_update_body(&field_name, raw_value)?; + merge_element_update(state, id, val); } _ => { println!("Unknown OSC address: {addr}"); @@ -258,6 +331,171 @@ fn handle_message(msg: OscMessage, client: &ApiClient) -> Result<()> { 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(); @@ -431,4 +669,56 @@ mod tests { 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)); + } } From d42b54d32bd90790dae6933b803ea5a3387c5daf Mon Sep 17 00:00:00 2001 From: Dingo <7553117+DingosGotMyBaby@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:45:17 +1000 Subject: [PATCH 4/4] Adds multi-platform support Since APPDATA and LOCALAPPDATA don't exist on Linux and Mac, I have implemented a native way to handle it. This will allow people to run this on servers and stuff without the api keys being put into the system tmp folder --- crates/cli/src/paths.rs | 34 +++++++++++++++++++++++++++++++++- crates/cli/src/versionstore.rs | 17 +++++++++++------ crates/launcher/src/main.rs | 29 ++++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 10 deletions(-) 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");