diff --git a/.gitignore b/.gitignore index 61b20296..5dc6c0e4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ result* # Local MCP Bundle builds (release artifacts) *.mcpb +/mcpb-build/ diff --git a/Cargo.toml b/Cargo.toml index 1f3d21f6..1efaba61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,10 +56,10 @@ warp = "0.3" # MCP server dependencies rmcp = { version = "1.4", default-features = false, features = ["macros", "server", "schemars", "transport-io"] } schemars = "1" +tempfile = "3.8" [dev-dependencies] envtestkit = "1.1.2" httpmock = "0.8" swc_common = { version = "18", default-features = false, features = [] } swc_ecma_parser = { version = "32", default-features = false, features = ["typescript"] } -tempfile = "3.8" diff --git a/README.md b/README.md index c100baca..d6e8d876 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ The server communicates over stdio and exposes the full Screenly API as tools. | **Playlist Items** | `playlist_item_list`, `playlist_item_create`, `playlist_item_update`, `playlist_item_delete` | | **Labels** | `label_list`, `label_create`, `label_update`, `label_delete`, `label_link_screen`, `label_unlink_screen`, `label_link_playlist`, `label_unlink_playlist` | | **Shared Playlists** | `shared_playlist_list`, `shared_playlist_create`, `shared_playlist_delete` | -| **Edge Apps** | `edge_app_list`, `edge_app_list_settings`, `edge_app_list_instances` | +| **Edge Apps** | `edge_app_list`, `edge_app_list_settings`, `edge_app_list_instances`, `edge_app_publish_from_html` | Every tool is annotated with behaviour hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`), so MCP clients can tell read-only tools apart from ones that modify or delete data and prompt for confirmation before destructive actions. diff --git a/mcpb/README.md b/mcpb/README.md index d4b45e36..3494ecdc 100644 --- a/mcpb/README.md +++ b/mcpb/README.md @@ -49,10 +49,11 @@ Once installed, you can ask Claude to: - Organise content with asset groups and labels - Share a playlist with another team - Inspect Edge Apps, their settings, and their instances +- Publish a Claude Artifact or HTML page as a Screenly app (Edge App), install an instance in Content, remember app/instance ids by name for later updates, and push HTML updates as new revisions ## Capabilities -The bundle exposes 33 tools. Every tool is annotated so Claude knows whether it only reads +The bundle exposes 34 tools. Every tool is annotated so Claude knows whether it only reads data or modifies your account, which means Claude will ask for confirmation before doing anything destructive. @@ -65,10 +66,11 @@ anything destructive. | Playlist Items | `playlist_item_list`, `playlist_item_create`, `playlist_item_update`, `playlist_item_delete` | | Labels | `label_list`, `label_create`, `label_update`, `label_delete`, `label_link_screen`, `label_unlink_screen`, `label_link_playlist`, `label_unlink_playlist` | | Shared Playlists | `shared_playlist_list`, `shared_playlist_create`, `shared_playlist_delete` | -| Edge Apps | `edge_app_list`, `edge_app_list_settings`, `edge_app_list_instances` | +| Edge Apps | `edge_app_list`, `edge_app_list_settings`, `edge_app_list_instances`, `edge_app_publish_from_html` | -Twelve of these tools are read-only. Thirteen are marked destructive (deletes, unlinks, -and updates that overwrite existing fields) so clients can prompt before running them. +Twelve of these tools are read-only. Fourteen are marked destructive (deletes, unlinks, +updates that overwrite existing fields, and Edge App publishes) so clients can prompt +before running them. ## Authentication diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 8d17caab..8c08a70e 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -188,6 +188,10 @@ { "name": "edge_app_list_instances", "description": "List instances of an Edge App." + }, + { + "name": "edge_app_publish_from_html", + "description": "Publish HTML as a Screenly app (Edge App). Use when the user says upload this as a Screenly app or Edge App. Wraps the page for unattended digital signage (no mouse/keyboard; auto-rotates tab panels and carousel slides), creates an instance so it appears in Content, and deploys. Remembers app_id/instance_id by name on this machine. Omit app_id to create or to update a remembered name; pass app_id to target a specific app." } ], "compatibility": { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 4613ed31..545ca008 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -184,6 +184,22 @@ pub struct AppUuidParam { pub app_uuid: String, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct EdgeAppPublishFromHtmlParam { + #[schemars(description = "Name of the Edge App")] + pub name: String, + #[schemars( + description = "Full HTML source of the page or Claude Artifact. Use this when uploading HTML as a Screenly app. Fragments are wrapped into a complete document." + )] + pub html: String, + #[schemars( + description = "Existing Edge App UUID. Prefer this when known. If omitted, the tool reuses the app_id saved locally for this exact name from a previous publish on this machine, or creates a new app." + )] + pub app_id: Option, + #[schemars(description = "Optional description stored on the Edge App version")] + pub description: Option, +} + // ============ SERVER STRUCT ============ /// MCP Server for Screenly API @@ -853,6 +869,43 @@ impl ScreenlyMcpServer { Err(e) => json!({"error": e}).to_string(), } } + + #[tool( + description = "Publish HTML as a Screenly app (Edge App). Use when the user says upload this as a Screenly app or Edge App. Wraps the page for unattended digital signage (no mouse/keyboard; auto-rotates tab panels and carousel slides), creates an instance so it appears in Content, and deploys. Remembers app_id/instance_id by name on this machine. Omit app_id to create or to update a remembered name; pass app_id to target a specific app.", + annotations( + title = "Publish Screenly App from HTML", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn edge_app_publish_from_html( + &self, + Parameters(EdgeAppPublishFromHtmlParam { + name, + html, + app_id, + description, + }): Parameters, + ) -> String { + let auth = Arc::clone(&self.auth); + match tokio::task::spawn_blocking(move || { + EdgeAppTools::publish_from_html( + &auth, + &name, + &html, + app_id.as_deref(), + description.as_deref(), + ) + }) + .await + { + Ok(Ok(result)) => result, + Ok(Err(e)) => json!({"error": e}).to_string(), + Err(e) => json!({"error": format!("Publish task failed: {}", e)}).to_string(), + } + } } // ============ SERVER HANDLER ============ @@ -870,7 +923,15 @@ impl rmcp::ServerHandler for ScreenlyMcpServer { Examples: 'TRUE' (always show), '$WEEKDAY IN {1,2,3,4,5}' (weekdays only), \ '$TIME BETWEEN {32400000, 61200000}' (9AM-5PM), \ '$TIME >= 32400000 AND $TIME <= 61200000 AND NOT $WEEKDAY IN {0, 6}' (business hours). \ - Time reference: 32400000=9AM, 43200000=12PM, 61200000=5PM, 72000000=8PM.", + Time reference: 32400000=9AM, 43200000=12PM, 61200000=5PM, 72000000=8PM.\n\n\ + SCREENLY APPS FROM HTML: If the user asks to upload HTML or a Claude Artifact as a \ + Screenly app, app, or Edge App, call edge_app_publish_from_html with the full HTML \ + source (not asset_create, which needs a public URL). This creates the app, deploys it, \ + and creates an instance so it appears in Content and can be scheduled on a screen. \ + The tool saves app_id and instance_id locally by name (~/.screenly.d/mcp-edge-apps.json). \ + To update later, call again with the same name and revised HTML; pass app_id when known, \ + otherwise the remembered name is enough. Keep the returned app_id and instance_id in mind \ + for the rest of the conversation.", ) } } diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index 35614e32..7b5b8cd0 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -636,7 +636,25 @@ fn test_edge_app_list_instances() { assert!(result.is_ok()); } -/// Guard against the 33-tool catalog drifting between the MCPB manifest and +#[test] +fn test_edge_app_publish_from_html_rejects_empty_name() { + let mock_server = MockServer::start(); + let auth = setup_auth(&mock_server); + let result = EdgeAppTools::publish_from_html(&auth, " ", "

Hi

", None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name is required")); +} + +#[test] +fn test_edge_app_publish_from_html_rejects_empty_html() { + let mock_server = MockServer::start(); + let auth = setup_auth(&mock_server); + let result = EdgeAppTools::publish_from_html(&auth, "Lobby Board", " ", None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("html is empty")); +} + +/// Guard against the tool catalog drifting between the MCPB manifest and /// the `#[tool]` definitions in `server.rs` (names + descriptions). #[test] fn test_mcpb_manifest_tools_match_server() { @@ -676,9 +694,10 @@ fn test_mcpb_manifest_tools_match_server() { let server_src = fs::read_to_string(manifest_dir.join("src/mcp/server.rs")) .expect("src/mcp/server.rs should exist"); - let tool_re = - Regex::new(r#"(?s)#\[tool\(\s*description\s*=\s*"([^"]+)"[\s\S]*?\)\]\s*fn\s+(\w+)"#) - .unwrap(); + let tool_re = Regex::new( + r#"(?s)#\[tool\(\s*description\s*=\s*"([^"]+)"[\s\S]*?\)\]\s*(?:async\s+)?fn\s+(\w+)"#, + ) + .unwrap(); let mut server_tools = BTreeMap::new(); for caps in tool_re.captures_iter(&server_src) { @@ -692,8 +711,8 @@ fn test_mcpb_manifest_tools_match_server() { assert_eq!( server_tools.len(), - 33, - "expected 33 #[tool] handlers in server.rs, found {}", + 34, + "expected 34 #[tool] handlers in server.rs, found {}", server_tools.len() ); assert_eq!( diff --git a/src/mcp/tools/edge_app.rs b/src/mcp/tools/edge_app.rs index 57253da1..7858d329 100644 --- a/src/mcp/tools/edge_app.rs +++ b/src/mcp/tools/edge_app.rs @@ -1,7 +1,170 @@ //! Edge App MCP tools. +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::{env, fs}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + use crate::authentication::Authentication; use crate::commands; +use crate::commands::edge_app::manifest::{ + EdgeAppManifest, Entrypoint, EntrypointType, MANIFEST_VERSION, +}; +use crate::commands::edge_app::utils::{ + transform_edge_app_path_to_manifest, transform_instance_path_to_instance_manifest, +}; +use crate::commands::edge_app::EdgeAppCommand; + +/// Override path for the local name → app/instance cache (used in tests). +const MCP_EDGE_APPS_PATH_ENV: &str = "SCREENLY_MCP_EDGE_APPS_PATH"; + +/// Directory next to the `~/.screenly` *token file* — that path is a regular +/// file (`authentication.rs`), so we cannot store JSON under `~/.screenly/`. +const MCP_EDGE_APPS_DIR_NAME: &str = ".screenly.d"; +const MCP_EDGE_APPS_FILE_NAME: &str = "mcp-edge-apps.json"; + +/// Default Edge App icon for Claude Artifact publishes (screenly.yml `icon`). +const DEFAULT_CLAUDE_APP_ICON: &str = + "https://playground.srly.io/edge-apps/icons/claude-app-default.svg"; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +struct McpEdgeAppRecord { + app_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + instance_id: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +struct McpEdgeAppRegistry { + /// `{api_url}|{token fingerprint}` → display name → last published ids. + #[serde(default)] + scopes: BTreeMap>, +} + +/// Marker attribute so wrap is idempotent when HTML is published again. +const THEME_BOOTSTRAP_MARKER: &str = "data-screenly-mcp-theme"; +const SIGNAGE_STYLE_MARKER: &str = "data-screenly-mcp-signage"; + +const SCREENLY_JS_SRC: &str = "screenly.js?version=1"; + +const SIGNAGE_STYLE: &str = r#""#; + +/// Theme CSS variables, then rotate only explicit slideshow markers +/// (`[role="tabpanel"]`, `.carousel-item`, `[data-slide]`, `[data-screenly-page]`). +const THEME_BOOTSTRAP_SCRIPT: &str = r#""#; /// Edge App tools for the MCP server. pub struct EdgeAppTools; @@ -41,4 +204,787 @@ impl EdgeAppTools { serde_json::to_string_pretty(&result) .map_err(|e| format!("Failed to serialize response: {}", e)) } + + /// Create or update an Edge App from HTML (Claude Artifact / webpage). + /// + /// Omitting `app_id` creates a new app unless the same `name` was published + /// before on this machine (`~/.screenly.d/mcp-edge-apps.json`). Passing + /// `app_id` always deploys a new revision for that app. + pub fn publish_from_html( + auth: &Authentication, + name: &str, + html: &str, + app_id: Option<&str>, + description: Option<&str>, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("name is required".to_string()); + } + + let wrapped = wrap_html_for_edge_app(html)?; + let ready_signal = ready_signal_for_html(&wrapped); + let dir = tempfile::tempdir() + .map_err(|e| format!("Failed to create temporary Edge App directory: {}", e))?; + let dir_path = dir.path(); + let (manifest_path, instance_manifest_path, path) = publish_dir_paths(dir_path)?; + + fs::write(dir_path.join("index.html"), &wrapped) + .map_err(|e| format!("Failed to write index.html: {}", e))?; + + let explicit_id = app_id + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned); + let remembered = lookup_remembered_app(auth, name); + let from_memory = explicit_id.is_none() && remembered.is_some(); + let existing_id = explicit_id + .clone() + .or_else(|| remembered.as_ref().map(|r| r.app_id.clone())); + let preferred_instance_id = remembered.as_ref().and_then(|r| { + if existing_id.as_deref() == Some(r.app_id.as_str()) { + r.instance_id.clone() + } else { + None + } + }); + + let created = existing_id.is_none(); + let manifest = EdgeAppManifest { + syntax: MANIFEST_VERSION.to_owned(), + id: existing_id, + description: description + .map(str::trim) + .filter(|d| !d.is_empty()) + .map(ToOwned::to_owned), + icon: Some(DEFAULT_CLAUDE_APP_ICON.to_owned()), + ready_signal: Some(ready_signal), + entrypoint: Some(Entrypoint { + entrypoint_type: EntrypointType::File, + uri: None, + }), + ..Default::default() + }; + + EdgeAppManifest::save_to_file(&manifest, &manifest_path) + .map_err(|e| format!("Failed to write {}: {}", manifest_path.display(), e))?; + + let command = edge_app_command(auth); + if created { + command + .create_in_place(name, &manifest_path) + .map_err(|e| format!("Failed to create Edge App: {}", e))?; + } + + let app_id = EdgeAppManifest::new(&manifest_path) + .map_err(|e| format!("Failed to read Edge App id: {}", e))? + .id + .ok_or_else(|| "Edge App id missing after create".to_string())?; + + let revision = command + .deploy(Some(path), Some(false)) + .map_err(|e| format!("Failed to deploy Edge App: {}", e))?; + + // Create/deploy already happened. Instance + local memory must not hide app_id. + let mut warnings: Vec = Vec::new(); + let (instance_id, instance_created) = match ensure_instance( + auth, + &app_id, + name, + preferred_instance_id.as_deref(), + &instance_manifest_path, + ) { + Ok(pair) => pair, + Err(e) => { + warnings.push(e); + (None, false) + } + }; + + let saved_to_memory = + match remember_published_app(auth, name, &app_id, instance_id.as_deref()) { + Ok(()) => true, + Err(e) => { + warnings.push(format!("Failed to save app_id locally: {}", e)); + false + } + }; + + serialize_publish_success(PublishFromHtmlResponse { + app_id, + instance_id, + instance_created, + name: name.to_string(), + revision, + created, + resolved_from_memory: from_memory, + saved_to_memory, + warnings, + message: publish_follow_up_message(saved_to_memory).to_string(), + }) + } +} + +#[derive(Serialize)] +struct PublishFromHtmlResponse { + app_id: String, + instance_id: Option, + instance_created: bool, + name: String, + revision: u32, + created: bool, + resolved_from_memory: bool, + saved_to_memory: bool, + warnings: Vec, + message: String, +} + +fn publish_follow_up_message(saved_to_memory: bool) -> &'static str { + if saved_to_memory { + "IDs are saved locally under ~/.screenly.d/mcp-edge-apps.json for this name. Later, call this tool again with the same name (and updated HTML) to deploy a new revision; app_id is optional when the name is remembered." + } else { + "Keep this app_id. Local memory could not be updated; pass app_id on the next publish to update the same app." + } +} + +fn serialize_publish_success(response: PublishFromHtmlResponse) -> Result { + serde_json::to_string_pretty(&response) + .map_err(|e| format!("Failed to serialize response: {}", e)) +} + +fn publish_dir_paths(dir_path: &Path) -> Result<(PathBuf, PathBuf, String), String> { + let path = dir_path + .to_str() + .ok_or_else(|| "Edge App path is not valid UTF-8".to_string())? + .to_string(); + let manifest_path = transform_edge_app_path_to_manifest(&Some(path.clone())) + .map_err(|e| format!("Failed to resolve Edge App manifest path: {}", e))?; + let instance_path = transform_instance_path_to_instance_manifest(&Some(path.clone())) + .map_err(|e| format!("Failed to resolve Edge App instance path: {}", e))?; + Ok((manifest_path, instance_path, path)) +} + +fn registry_path() -> Result { + if let Ok(path) = env::var(MCP_EDGE_APPS_PATH_ENV) { + let path = path.trim(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + + let home = dirs::home_dir().ok_or_else(|| "Home directory not found".to_string())?; + Ok(home + .join(MCP_EDGE_APPS_DIR_NAME) + .join(MCP_EDGE_APPS_FILE_NAME)) +} + +fn load_registry() -> Result { + let path = registry_path()?; + if !path.exists() { + return Ok(McpEdgeAppRegistry::default()); + } + + let data = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + if data.trim().is_empty() { + return Ok(McpEdgeAppRegistry::default()); + } + + serde_json::from_str(&data).map_err(|e| format!("Failed to parse {}: {}", path.display(), e)) +} + +fn save_registry(registry: &McpEdgeAppRegistry) -> Result<(), String> { + let path = registry_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?; + } + + let data = serde_json::to_string_pretty(registry) + .map_err(|e| format!("Failed to serialize Edge App memory: {}", e))?; + fs::write(&path, format!("{data}\n")) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e)) +} + +fn registry_scope(auth: &Authentication) -> String { + let url = auth.config.url.trim().trim_end_matches('/'); + format!("{url}|{}", token_fingerprint(&auth.token)) +} + +fn token_fingerprint(token: &str) -> String { + hex::encode(Sha256::digest(token.trim().as_bytes())) +} + +fn lookup_in_registry(auth: &Authentication, name: &str) -> Option { + load_registry() + .ok()? + .scopes + .get(®istry_scope(auth))? + .get(name) + .cloned() +} + +/// Returns the remembered ids for this API host + token, or `None` if the app +/// is gone (entry is then dropped). Network errors keep the cached id. +fn lookup_remembered_app(auth: &Authentication, name: &str) -> Option { + let record = lookup_in_registry(auth, name)?; + match app_exists_in_account(auth, &record.app_id) { + Ok(true) => Some(record), + Ok(false) => { + let _ = forget_published_app(auth, name); + None + } + Err(_) => Some(record), + } +} + +fn remember_published_app( + auth: &Authentication, + name: &str, + app_id: &str, + instance_id: Option<&str>, +) -> Result<(), String> { + let mut registry = load_registry()?; + registry + .scopes + .entry(registry_scope(auth)) + .or_default() + .insert( + name.to_string(), + McpEdgeAppRecord { + app_id: app_id.to_string(), + instance_id: instance_id.map(ToOwned::to_owned), + }, + ); + save_registry(®istry) +} + +fn forget_published_app(auth: &Authentication, name: &str) -> Result<(), String> { + let mut registry = load_registry()?; + let scope = registry_scope(auth); + let empty = if let Some(apps) = registry.scopes.get_mut(&scope) { + apps.remove(name); + apps.is_empty() + } else { + false + }; + if empty { + registry.scopes.remove(&scope); + } + save_registry(®istry) +} + +fn app_exists_in_account(auth: &Authentication, app_id: &str) -> Result { + let endpoint = format!("v4/edge-apps?select=id&id=eq.{app_id}&deleted=eq.false"); + let result = commands::get(auth, &endpoint) + .map_err(|e| format!("Failed to look up Edge App {}: {}", app_id, e))?; + Ok(result + .as_array() + .map(|rows| !rows.is_empty()) + .unwrap_or(false)) +} + +fn ensure_instance( + auth: &Authentication, + app_id: &str, + name: &str, + preferred_instance_id: Option<&str>, + instance_manifest_path: &Path, +) -> Result<(Option, bool), String> { + let command = edge_app_command(auth); + let listed = command + .list_instances(app_id) + .map_err(|e| format!("Failed to list Edge App instances: {}", e))?; + + let rows = listed.value.as_array().cloned().unwrap_or_default(); + if let Some(id) = pick_existing_instance(&rows, preferred_instance_id, name) { + return Ok((Some(id), false)); + } + + let instance_id = command + .create_instance(instance_manifest_path, app_id, name) + .map_err(|e| format!("Failed to create Edge App instance: {}", e))?; + Ok((Some(instance_id), true)) +} + +fn pick_existing_instance( + rows: &[serde_json::Value], + preferred_instance_id: Option<&str>, + name: &str, +) -> Option { + if let Some(preferred) = preferred_instance_id.filter(|id| !id.is_empty()) { + if rows + .iter() + .any(|row| row.get("id").and_then(|v| v.as_str()) == Some(preferred)) + { + return Some(preferred.to_string()); + } + } + + if let Some(id) = rows.iter().find_map(|row| { + if row.get("name").and_then(|v| v.as_str()) == Some(name) { + row.get("id") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned) + } else { + None + } + }) { + return Some(id); + } + + if rows.len() == 1 { + return rows[0] + .get("id") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned); + } + + None +} + +fn edge_app_command(auth: &Authentication) -> EdgeAppCommand { + EdgeAppCommand::new(Authentication { + config: crate::authentication::Config { + url: auth.config.url.clone(), + }, + token: auth.token.clone(), + }) +} + +/// Turn a Claude Artifact / HTML fragment into a player-ready Edge App document. +pub(crate) fn wrap_html_for_edge_app(html: &str) -> Result { + let html = html.trim(); + if html.is_empty() { + return Err("html is empty".to_string()); + } + + let screenly_script = format!(r#""#); + let lower = html.to_ascii_lowercase(); + let has_html_shell = lower.contains(""); + + if !has_html_shell { + return Ok(format!( + "\n\ + \n\ + \n\ + \n\ + \n\ + {screenly_script}\n\ + {SIGNAGE_STYLE}\n\ + \n\ + \n\ + {html}\n\ + {THEME_BOOTSTRAP_SCRIPT}\n\ + \n\ + \n" + )); + } + + let mut out = html.to_string(); + + if !has_screenly_js_script(&out) { + out = inject_before_tag(&out, "", &format!("{screenly_script}\n")) + .or_else(|| inject_after_tag(&out, "", &format!("\n{screenly_script}\n"))) + .ok_or_else(|| { + "HTML document is missing a element to inject screenly.js".to_string() + })?; + } + + if !out.contains(SIGNAGE_STYLE_MARKER) { + out = inject_before_tag(&out, "", &format!("{SIGNAGE_STYLE}\n")) + .or_else(|| inject_after_tag(&out, "", &format!("\n{SIGNAGE_STYLE}\n"))) + .ok_or_else(|| { + "HTML document is missing a element to inject signage styles".to_string() + })?; + } + + if !out.contains(THEME_BOOTSTRAP_MARKER) { + out = inject_before_tag(&out, "", &format!("{THEME_BOOTSTRAP_SCRIPT}\n")) + .or_else(|| inject_before_tag(&out, "", &format!("{THEME_BOOTSTRAP_SCRIPT}\n"))) + .ok_or_else(|| { + "HTML document is missing a or tag to inject theme bootstrap" + .to_string() + })?; + } + + if !out.to_ascii_lowercase().contains("\n{out}"); + } + + Ok(out) +} + +/// True when an opening ` +Board + +

News

+"#; + let wrapped = wrap_html_for_edge_app(html).unwrap(); + assert_eq!(wrapped.matches("screenly.js").count(), 1); + assert!(has_screenly_js_script(&wrapped)); + } + + #[test] + fn has_screenly_js_script_ignores_inline_script_text() { + let html = r#""#; + assert!(!has_screenly_js_script(html)); + } + + #[test] + fn wrap_injects_when_screenly_js_script_is_only_inside_a_comment() { + let html = r#" + + + +Board + +

News

+"#; + assert!(!has_screenly_js_script(html)); + let wrapped = wrap_html_for_edge_app(html).unwrap(); + assert_eq!(wrapped.matches(SCREENLY_JS_SRC).count(), 1); + assert!(has_screenly_js_script(&wrapped)); + assert!(ready_signal_for_html(&wrapped)); + } + + #[test] + fn ready_signal_is_off_when_document_has_a_base_tag() { + let html = r#" + + + +Board + +

News

+"#; + let wrapped = wrap_html_for_edge_app(html).unwrap(); + assert!(has_screenly_js_script(&wrapped)); + assert!(html_has_base_tag(&wrapped)); + assert!(!ready_signal_for_html(&wrapped)); + } +} + +#[cfg(test)] +mod registry_tests { + use httpmock::Method::GET; + use httpmock::MockServer; + use serde_json::json; + use tempfile::tempdir; + + use super::*; + use crate::authentication::Config; + + fn test_auth(url: &str, token: &str) -> Authentication { + Authentication::new_with_config(Config::new(url.to_string()), token) + } + + fn with_registry(f: impl FnOnce() -> R) -> R { + let dir = tempdir().unwrap(); + let path = dir.path().join("mcp-edge-apps.json"); + let path_str = path.to_str().unwrap().to_string(); + temp_env::with_var(MCP_EDGE_APPS_PATH_ENV, Some(path_str.as_str()), f) + } + + #[test] + fn remember_and_lookup_round_trip() { + with_registry(|| { + let auth = test_auth("https://api.example.com", "token-a"); + assert!(lookup_in_registry(&auth, "Lobby Board").is_none()); + remember_published_app(&auth, "Lobby Board", "app-1", Some("inst-1")).unwrap(); + + let remembered = lookup_in_registry(&auth, "Lobby Board").unwrap(); + assert_eq!(remembered.app_id, "app-1"); + assert_eq!(remembered.instance_id.as_deref(), Some("inst-1")); + + remember_published_app(&auth, "Lobby Board", "app-1", Some("inst-2")).unwrap(); + let updated = lookup_in_registry(&auth, "Lobby Board").unwrap(); + assert_eq!(updated.instance_id.as_deref(), Some("inst-2")); + }); + } + + #[test] + fn registry_is_scoped_by_api_url_and_token() { + with_registry(|| { + let prod_a = test_auth("https://api.example.com", "token-a"); + let prod_b = test_auth("https://api.example.com", "token-b"); + let staging_a = test_auth("https://staging.example.com", "token-a"); + + remember_published_app(&prod_a, "Lobby Board", "app-prod-a", None).unwrap(); + + assert!(lookup_in_registry(&prod_b, "Lobby Board").is_none()); + assert!(lookup_in_registry(&staging_a, "Lobby Board").is_none()); + assert_eq!( + lookup_in_registry(&prod_a, "Lobby Board").unwrap().app_id, + "app-prod-a" + ); + }); + } + + #[test] + fn stale_remembered_id_is_forgotten() { + with_registry(|| { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v4/edge-apps"); + then.status(200).json_body(json!([])); + }); + let auth = test_auth(&server.base_url(), "token-a"); + remember_published_app(&auth, "Lobby Board", "gone-app", None).unwrap(); + assert!(lookup_in_registry(&auth, "Lobby Board").is_some()); + assert!(lookup_remembered_app(&auth, "Lobby Board").is_none()); + assert!(lookup_in_registry(&auth, "Lobby Board").is_none()); + }); + } + + #[test] + fn default_registry_path_is_not_the_token_file() { + temp_env::with_var_unset(MCP_EDGE_APPS_PATH_ENV, || { + let path = registry_path().unwrap(); + assert_eq!( + path.file_name().and_then(|n| n.to_str()), + Some(MCP_EDGE_APPS_FILE_NAME) + ); + assert_eq!( + path.parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()), + Some(MCP_EDGE_APPS_DIR_NAME) + ); + }); + } + + #[test] + fn success_json_keeps_app_id_when_bookkeeping_fails() { + let raw = serialize_publish_success(PublishFromHtmlResponse { + app_id: "app-1".to_string(), + instance_id: None, + instance_created: false, + name: "Lobby Board".to_string(), + revision: 3, + created: true, + resolved_from_memory: false, + saved_to_memory: false, + warnings: vec!["Failed to save app_id locally: File exists".to_string()], + message: publish_follow_up_message(false).to_string(), + }) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(value["app_id"], "app-1"); + assert_eq!(value["saved_to_memory"], false); + assert_eq!(value["warnings"].as_array().unwrap().len(), 1); + assert!(value["message"] + .as_str() + .unwrap() + .contains("Keep this app_id")); + } + + #[test] + fn pick_existing_instance_prefers_remembered_id_then_name() { + let rows = vec![ + json!({"id": "inst-a", "name": "Other"}), + json!({"id": "inst-b", "name": "Lobby Board"}), + ]; + assert_eq!( + pick_existing_instance(&rows, Some("inst-a"), "Lobby Board").as_deref(), + Some("inst-a") + ); + assert_eq!( + pick_existing_instance(&rows, Some("gone"), "Lobby Board").as_deref(), + Some("inst-b") + ); + assert_eq!( + pick_existing_instance(&rows, None, "Missing").as_deref(), + None + ); + assert_eq!( + pick_existing_instance(&[json!({"id": "only", "name": "X"})], None, "Y").as_deref(), + Some("only") + ); + } + + #[test] + fn publish_dir_paths_honours_manifest_and_instance_env() { + let dir = tempdir().unwrap(); + temp_env::with_vars( + [ + ("MANIFEST_FILE_NAME", Some("custom.yml")), + ("INSTANCE_FILE_NAME", Some("inst.yml")), + ], + || { + let (manifest, instance, _) = publish_dir_paths(dir.path()).unwrap(); + assert_eq!( + manifest.file_name().and_then(|n| n.to_str()), + Some("custom.yml") + ); + assert_eq!( + instance.file_name().and_then(|n| n.to_str()), + Some("inst.yml") + ); + }, + ); + } }