From 8dd2f8e2b92a9f411c604abbffc95e6b6ed0e8f7 Mon Sep 17 00:00:00 2001 From: Sergey Borovkov Date: Mon, 4 May 2026 15:50:51 +0400 Subject: [PATCH 1/6] Add --entrypoint flag and JS injection workflow (WIP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make remote-entrypoint Edge Apps a first-class create option and lay the groundwork for a per-deploy JS injection workflow. - `screenly edge-app create --name X --entrypoint ` writes `screenly.yml` with `entrypoint.type: remote-global` + URI, drops a starter `screenly_inject.js`, and adds it to `.ignore`. No `index.html` on disk for remote apps. - `deploy` materializes a placeholder `index.html` for the duration of the upload (the backend rejects publishing versions with no signed assets) and removes it after. - Adds `is_first_deploy` short-circuit so the first deploy of a fresh app isn't blocked by the no-changes check. - New `sync_js_injection` step at the end of `deploy`: looks up the installation's stable asset and PATCHes `js_injection` from the local file. Wait/retry until the asset is provisioned. - Adds request URL/status logging to `commands::get` for easier diagnosis. - Docs (`docs/EdgeApps.md`) describe the new flow and the `screenly_settings` runtime hook for inject scripts. WIP: the final `js_injection` PATCH currently 400s on the live API ({"code":"22P02","error":"Invalid input value: nil"}). TODO marker in `set_asset_js_injection` — switch to the new revision-scoped js_injection endpoint when the backend ships it. --- data/screenly_inject.js | 16 ++ docs/EdgeApps.md | 67 +++++ src/api/asset.rs | 50 ++++ src/cli.rs | 24 +- src/commands/edge_app/app.rs | 461 ++++++++++++++++++++++++++++++--- src/commands/edge_app/utils.rs | 8 +- src/commands/mod.rs | 4 +- 7 files changed, 590 insertions(+), 40 deletions(-) create mode 100644 data/screenly_inject.js diff --git a/data/screenly_inject.js b/data/screenly_inject.js new file mode 100644 index 0000000..b7659e3 --- /dev/null +++ b/data/screenly_inject.js @@ -0,0 +1,16 @@ +// JavaScript injected into the remote entrypoint on every load. +// +// Edge App settings and secrets are available as `screenly_settings`. +// Use this to authenticate the remote page without baking credentials +// into the script. +// +// Example: attach a Bearer token from a setting/secret to every fetch. +// +// const token = screenly_settings.api_token; +// const originalFetch = window.fetch; +// window.fetch = (input, init = {}) => { +// init.headers = { ...(init.headers || {}), Authorization: `Bearer ${token}` }; +// return originalFetch(input, init); +// }; + +console.log('screenly_settings:', screenly_settings); diff --git a/docs/EdgeApps.md b/docs/EdgeApps.md index 237f805..e484d09 100644 --- a/docs/EdgeApps.md +++ b/docs/EdgeApps.md @@ -83,6 +83,73 @@ When you run the screenly edge-app create command, two files will be created in - `screenly.yml` - `index.html` +#### Remote entrypoint (`--entrypoint`) + +If your Edge App should load a remote URL instead of a bundled `index.html`, +pass the URL at create time: + +```shell +$ screenly edge-app create --name hello-remote --entrypoint https://example.com/app +``` + +This sets `entrypoint.type: remote-global` and `entrypoint.uri` in +`screenly.yml`, skips writing the `index.html` stub (it's not needed for +remote-entrypoint apps), and adds `screenly_inject.js` to `.ignore` so the +JS injection file (see below) is never bundled as an asset. + +The main use case for remote entrypoints is **JS injection** — a snippet +of JavaScript that the player executes against the loaded remote page on +every load. The most common reason to reach for this is **authenticating +to the remote page**: overriding `fetch`/`XHR` to attach `Authorization` +headers, setting cookies or `localStorage` tokens before the page boots, +auto-filling and submitting login forms, etc. (You can of course also use +it to hide chrome, inject branding, or otherwise tweak third-party pages +you don't control.) + +When `create --entrypoint` is used, a starter `screenly_inject.js` is +written into the project directory alongside `screenly.yml`. On every +`screenly edge-app deploy` its contents are pushed to the player as the +`js_injection` for this installation's asset. If the file is missing or +empty, `js_injection` is cleared on deploy so the deployed state always +mirrors the file. JS injection is applied per-installation, so you need +an `instance.yml` in the project directory (created via +`screenly edge-app instance create`) — without one, deploy skips the JS +injection step. + +To pass credentials securely into the injected script, define them as +Edge App **settings** or **secrets** (see [Settings](#settings) below) +and read them from `screenly_settings` inside `screenly_inject.js`. The +player wraps the injection in an IIFE that supplies `screenly_settings` +as an argument: + +```js +(function(screenly_settings) { + // your screenly_inject.js contents here +})({ /* this app's settings + secrets */ }); +``` + +So inside `screenly_inject.js` you can reference `screenly_settings` +directly — it's not a true global, but it's in scope for the whole +script. This keeps the values out of the page source and out of the +injection script you commit to source control. + +Example `screenly_inject.js` that adds a Bearer token to every outbound +request from the remote page: + +```js +const token = screenly_settings.api_token; +const originalFetch = window.fetch; +window.fetch = (input, init = {}) => { + init.headers = { ...(init.headers || {}), Authorization: `Bearer ${token}` }; + return originalFetch(input, init); +}; +``` + +Only `remote-global` (one URL shared across all instances) can be configured +via `--entrypoint`. For `remote-local` (per-instance URLs), set +`entrypoint.type: remote-local` in `screenly.yml` and `entrypoint_uri` per +instance in `instance.yml`. + `screenly.yml` contains the metadata. In this file, you can define settings, secrets, and various other metadata. In our "Hello World" example, we have a single setting called `greeting`, which is used in the Edge App. `index.html` is our entry point. It is what the client (i.e., the player) will load. This particular file is very simple and just includes some styling and various metadata examples. diff --git a/src/api/asset.rs b/src/api/asset.rs index 1588a92..5455b62 100644 --- a/src/api/asset.rs +++ b/src/api/asset.rs @@ -45,4 +45,54 @@ impl Api { response, )?) } + + pub fn set_asset_js_injection( + &self, + asset_id: &str, + js_code: &str, + ) -> Result<(), CommandError> { + // TODO: Switch to a dedicated revision-scoped js_injection endpoint + // when the backend ships it. Patching `js_injection` directly on the + // asset currently returns 400 ({"code":"22P02","error":"Invalid input + // value: nil"}) on the live API, so the deploy errors at this final + // step. Versioning + entrypoint setup + asset lookup are fine. + let endpoint = format!("api/v4.1/assets?id=eq.{asset_id}"); + commands::patch( + &self.authentication, + &endpoint, + &serde_json::json!({ "js_injection": js_code }), + )?; + Ok(()) + } + + pub fn get_installation_stable_asset( + &self, + installation_id: &str, + ) -> Result, CommandError> { + // Assets have `app_installation_id` as a direct column, so a flat + // filter is enough — no PostgREST embed needed. + let endpoint = format!( + "api/v4.1/assets?select=id,js_injection\ + &app_installation_id=eq.{installation_id}&app_channel=eq.stable" + ); + let response = commands::get(&self.authentication, &endpoint)?; + + let array = response.as_array().ok_or(CommandError::MissingField)?; + match array.first() { + Some(asset) => { + let id = asset + .get("id") + .and_then(|v| v.as_str()) + .ok_or(CommandError::MissingField)? + .to_owned(); + let js_injection = asset + .get("js_injection") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + Ok(Some((id, js_injection))) + } + None => Ok(None), + } + } } diff --git a/src/cli.rs b/src/cli.rs index 1839fec..368c5e3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -355,6 +355,11 @@ pub enum EdgeAppCommands { /// Use an existing Edge App directory with the manifest and index.html. #[arg(short, long, action = clap::ArgAction::SetTrue)] in_place: Option, + /// Remote entrypoint URL. When set, the created app uses entrypoint.type = + /// remote-global with this URL, and `screenly_inject.js` is added to `.ignore` + /// for use with the JS injection workflow on deploy. + #[arg(short, long)] + entrypoint: Option, }, /// Lists your Edge Apps. @@ -908,13 +913,8 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { name, path, in_place, + entrypoint, } => { - let create_func = if in_place.unwrap_or(false) { - commands::edge_app::EdgeAppCommand::create_in_place - } else { - commands::edge_app::EdgeAppCommand::create - }; - let manifest_path = match transform_edge_app_path_to_manifest(path) { Ok(path) => path, Err(e) => { @@ -923,7 +923,17 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { } }; - match create_func(&edge_app_command, name, manifest_path.as_path()) { + let result = if in_place.unwrap_or(false) { + if entrypoint.is_some() { + eprintln!("--entrypoint cannot be used with --in-place."); + std::process::exit(1); + } + edge_app_command.create_in_place(name, manifest_path.as_path()) + } else { + edge_app_command.create(name, manifest_path.as_path(), entrypoint.clone()) + }; + + match result { Ok(()) => { println!("Edge App successfully created."); } diff --git a/src/commands/edge_app/app.rs b/src/commands/edge_app/app.rs index baf96d0..59f7665 100644 --- a/src/commands/edge_app/app.rs +++ b/src/commands/edge_app/app.rs @@ -29,9 +29,42 @@ use crate::commands::edge_app::utils::{ use crate::commands::edge_app::EdgeAppCommand; use crate::commands::{CommandError, EdgeApps}; +pub const INJECT_JS_FILE_NAME: &str = "screenly_inject.js"; + +fn ensure_inject_js_in_ignore(parent_dir: &Path) -> Result<(), CommandError> { + let ignore_path = parent_dir.join(".ignore"); + let existing = match fs::read_to_string(&ignore_path) { + Ok(s) => s, + Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(CommandError::Io(e)), + }; + + if existing + .lines() + .any(|line| line.trim() == INJECT_JS_FILE_NAME) + { + return Ok(()); + } + + let mut new_content = existing; + if !new_content.is_empty() && !new_content.ends_with('\n') { + new_content.push('\n'); + } + new_content.push_str(INJECT_JS_FILE_NAME); + new_content.push('\n'); + + fs::write(&ignore_path, new_content)?; + Ok(()) +} + // Edge apps commands impl EdgeAppCommand { - pub fn create(&self, name: &str, path: &Path) -> Result<(), CommandError> { + pub fn create( + &self, + name: &str, + path: &Path, + entrypoint: Option, + ) -> Result<(), CommandError> { let parent_dir_path = path.parent().ok_or(CommandError::FileSystemError( "Cannot obtain Edge App root directory.".to_owned(), ))?; @@ -44,15 +77,33 @@ impl EdgeAppCommand { ))); } + let entrypoint_value = match entrypoint { + Some(url) => { + match reqwest::Url::parse(&url) { + Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => {} + _ => { + return Err(CommandError::InitializationError(format!( + "Invalid --entrypoint URL: {url}. Must be a valid http or https URL." + ))); + } + } + Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteGlobal, + uri: Some(url), + }) + } + None => Some(Entrypoint { + entrypoint_type: EntrypointType::File, + uri: None, + }), + }; + let app_id = self.api.create_app(name.to_string())?; let manifest = EdgeAppManifest { syntax: MANIFEST_VERSION.to_owned(), id: Some(app_id), - entrypoint: Some(Entrypoint { - entrypoint_type: EntrypointType::File, - uri: None, - }), + entrypoint: entrypoint_value, settings: vec![ Setting { name: "secret_word".to_string(), @@ -80,13 +131,72 @@ impl EdgeAppCommand { EdgeAppManifest::save_to_file(&manifest, path)?; - let index_html_template = - include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/index.html")); - let index_html_file = File::create(&index_html_path)?; - write!(&index_html_file, "{index_html_template}")?; + let is_remote = matches!( + manifest.entrypoint, + Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteGlobal, + .. + }) + ); + + if !is_remote { + let index_html_template = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/index.html")); + let index_html_file = File::create(&index_html_path)?; + write!(&index_html_file, "{index_html_template}")?; + } + + if is_remote { + ensure_inject_js_in_ignore(parent_dir_path)?; + + let inject_js_path = parent_dir_path.join(INJECT_JS_FILE_NAME); + if !inject_js_path.exists() { + let template = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/data/screenly_inject.js" + )); + fs::write(&inject_js_path, template)?; + } + } Ok(()) } +} + +/// RAII guard that materializes an empty `index.html` in the edge app directory +/// for the duration of a deploy of a remote-entrypoint app. The backend rejects +/// publishing a version with no asset signatures, so we ship a placeholder. +struct VirtualIndexHtml { + path: PathBuf, + created: bool, +} + +impl VirtualIndexHtml { + /// Minimal HTML so the asset has non-zero size (the backend rejects empty + /// asset bodies with a check-constraint violation). The contents are never + /// rendered — the player loads the remote entrypoint URL instead. + const PLACEHOLDER: &'static str = + "\n"; + + fn ensure(parent_dir: &Path) -> Result { + let path = parent_dir.join("index.html"); + let created = !path.exists(); + if created { + fs::write(&path, Self::PLACEHOLDER)?; + } + Ok(Self { path, created }) + } +} + +impl Drop for VirtualIndexHtml { + fn drop(&mut self) { + if self.created { + let _ = fs::remove_file(&self.path); + } + } +} + +impl EdgeAppCommand { pub fn create_in_place(&self, name: &str, path: &Path) -> Result<(), CommandError> { let parent_dir_path = path.parent().ok_or(CommandError::FileSystemError( @@ -141,13 +251,31 @@ impl EdgeAppCommand { let edge_app_dir = manifest_path.parent().ok_or(CommandError::MissingField)?; + // Remote-entrypoint apps don't need a real index.html, but the backend + // requires every published version to have at least one signed asset. + // Materialize an empty placeholder for the duration of this deploy. + let is_remote_entrypoint = matches!( + manifest.entrypoint, + Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteGlobal, + .. + }) | Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteLocal, + .. + }) + ); + let _virtual_index_html = if is_remote_entrypoint { + Some(VirtualIndexHtml::ensure(edge_app_dir)?) + } else { + None + }; + let local_files = collect_paths_for_upload(edge_app_dir)?; ensure_edge_app_has_all_necessary_files(&local_files)?; - let revision = match self.api.get_latest_revision(&actual_app_id)? { - Some(revision) => revision.revision, - None => 0, - }; + let prior_revision = self.api.get_latest_revision(&actual_app_id)?; + let is_first_deploy = prior_revision.is_none(); + let revision = prior_revision.map(|r| r.revision).unwrap_or(0); let remote_files = self .api @@ -166,7 +294,7 @@ impl EdgeAppCommand { changed_settings, )?; - self.update_entrypoint_value(path)?; + self.update_entrypoint_value(path.clone())?; let file_tree = generate_file_tree(&local_files, edge_app_dir); @@ -178,28 +306,84 @@ impl EdgeAppCommand { }; debug!("File tree changed: {file_tree_changed}"); - if !self.requires_upload(&changed_files) && !file_tree_changed && !version_metadata_changed - { - return Err(CommandError::NoChangesToUpload( - "No changes detected".to_owned(), - )); - } + let needs_new_version = is_first_deploy + || self.requires_upload(&changed_files) + || file_tree_changed + || version_metadata_changed; + + let final_revision = if needs_new_version { + let revision = + self.create_version(&manifest, generate_file_tree(&local_files, edge_app_dir))?; + + self.upload_changed_files(edge_app_dir, &actual_app_id, revision, &changed_files)?; + debug!("Files uploaded"); + + self.ensure_assets_processing_finished(&actual_app_id, revision)?; + // now we freeze it by publishing it + self.api.publish_version(&actual_app_id, revision)?; + debug!("Edge App published."); + + self.promote_version(&actual_app_id, revision, "stable")?; + revision + } else { + debug!("No version-creating changes; skipping version creation."); + revision + }; - // now that we know we have changes, we can create a new version - let revision = - self.create_version(&manifest, generate_file_tree(&local_files, edge_app_dir))?; + self.sync_js_injection(path)?; - self.upload_changed_files(edge_app_dir, &actual_app_id, revision, &changed_files)?; - debug!("Files uploaded"); + Ok(final_revision) + } - self.ensure_assets_processing_finished(&actual_app_id, revision)?; - // now we freeze it by publishing it - self.api.publish_version(&actual_app_id, revision)?; - debug!("Edge App published."); + fn sync_js_injection(&self, path: Option) -> Result<(), CommandError> { + let installation_id = match self.get_installation_id(path.clone()) { + Ok(id) => id, + Err(_) => { + debug!("No instance.yml present; skipping js_injection sync."); + return Ok(()); + } + }; - self.promote_version(&actual_app_id, revision, "stable")?; + let manifest_path = transform_edge_app_path_to_manifest(&path)?; + let edge_app_dir = manifest_path.parent().ok_or(CommandError::MissingField)?; + let inject_path = edge_app_dir.join(INJECT_JS_FILE_NAME); - Ok(revision) + let js_code = match fs::read_to_string(&inject_path) { + Ok(s) => s, + Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(CommandError::Io(e)), + }; + + let (asset_id, current_js_injection) = + self.wait_for_installation_stable_asset(&installation_id)?; + if current_js_injection == js_code { + debug!("js_injection already up to date for asset {asset_id}; skipping PATCH."); + return Ok(()); + } + self.api.set_asset_js_injection(&asset_id, &js_code)?; + debug!("js_injection synced for asset {asset_id}."); + Ok(()) + } + + fn wait_for_installation_stable_asset( + &self, + installation_id: &str, + ) -> Result<(String, String), CommandError> { + const SLEEP_SECS: u64 = 2; + const MAX_WAIT_SECS: u64 = 120; + let start = Instant::now(); + loop { + if let Some(pair) = self.api.get_installation_stable_asset(installation_id)? { + return Ok(pair); + } + if start.elapsed().as_secs() > MAX_WAIT_SECS { + return Err(CommandError::AssetProcessingTimeout); + } + debug!( + "Stable asset for installation {installation_id} not provisioned yet; retrying." + ); + thread::sleep(Duration::from_secs(SLEEP_SECS)); + } } fn promote_version( @@ -639,12 +823,14 @@ mod tests { let result = command.create( "Best app ever", tmp_dir.path().join("screenly.yml").as_path(), + None, ); post_mock.assert(); assert!(tmp_dir.path().join("screenly.yml").exists()); assert!(tmp_dir.path().join("index.html").exists()); + assert!(!tmp_dir.path().join(".ignore").exists()); let data = fs::read_to_string(tmp_dir.path().join("screenly.yml")).unwrap(); let manifest: EdgeAppManifest = serde_yaml::from_str(&data).unwrap(); @@ -699,6 +885,7 @@ mod tests { let result = command.create( "Best app ever", tmp_dir.path().join("screenly.yml").as_path(), + None, ); assert!(result.is_err()); @@ -714,6 +901,7 @@ mod tests { let result = command.create( "Best app ever", tmp_dir.path().join("screenly.yml").as_path(), + None, ); assert!(result.is_err()); @@ -826,6 +1014,217 @@ mod tests { ); } + #[test] + fn test_edge_app_create_with_remote_entrypoint_should_set_remote_global_and_write_ignore() { + let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(false, false); + + let post_mock = mock_server.mock(|when, then| { + when.method(POST) + .path("/v4/edge-apps") + .header("Authorization", "Token token") + .json_body(json!({ "name": "Remote app" })); + then.status(201) + .json_body(json!([{"id": "test-id", "name": "Remote app"}])); + }); + + let result = command.create( + "Remote app", + tmp_dir.path().join("screenly.yml").as_path(), + Some("https://example.com/app".to_string()), + ); + + post_mock.assert(); + assert!(result.is_ok()); + + let data = fs::read_to_string(tmp_dir.path().join("screenly.yml")).unwrap(); + let manifest: EdgeAppManifest = serde_yaml::from_str(&data).unwrap(); + assert_eq!( + manifest.entrypoint, + Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteGlobal, + uri: Some("https://example.com/app".to_string()), + }) + ); + + assert!(!tmp_dir.path().join("index.html").exists()); + + let ignore_path = tmp_dir.path().join(".ignore"); + assert!(ignore_path.exists()); + let ignore_content = fs::read_to_string(&ignore_path).unwrap(); + assert!(ignore_content.lines().any(|l| l.trim() == INJECT_JS_FILE_NAME)); + + let inject_path = tmp_dir.path().join(INJECT_JS_FILE_NAME); + assert!(inject_path.exists()); + let inject_content = fs::read_to_string(&inject_path).unwrap(); + assert!(inject_content.contains("screenly_settings")); + } + + #[test] + fn test_edge_app_create_with_invalid_entrypoint_url_should_fail() { + let (tmp_dir, command, _mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(false, false); + + let result = command.create( + "Bad app", + tmp_dir.path().join("screenly.yml").as_path(), + Some("not-a-url".to_string()), + ); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid --entrypoint URL")); + assert!(!tmp_dir.path().join("screenly.yml").exists()); + } + + #[test] + fn test_edge_app_create_with_remote_entrypoint_should_append_to_existing_ignore() { + let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(false, false); + + std::fs::write(tmp_dir.path().join(".ignore"), "*.log\n").unwrap(); + + let post_mock = mock_server.mock(|when, then| { + when.method(POST) + .path("/v4/edge-apps") + .header("Authorization", "Token token"); + then.status(201) + .json_body(json!([{"id": "test-id", "name": "Remote app"}])); + }); + + let result = command.create( + "Remote app", + tmp_dir.path().join("screenly.yml").as_path(), + Some("https://example.com/app".to_string()), + ); + + post_mock.assert(); + assert!(result.is_ok()); + + let ignore_content = fs::read_to_string(tmp_dir.path().join(".ignore")).unwrap(); + assert!(ignore_content.lines().any(|l| l.trim() == "*.log")); + assert!(ignore_content.lines().any(|l| l.trim() == INJECT_JS_FILE_NAME)); + } + + #[test] + fn test_sync_js_injection_with_no_instance_manifest_should_skip() { + let (tmp_dir, command, _mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(true, false); + + let result = + command.sync_js_injection(Some(tmp_dir.path().to_str().unwrap().to_string())); + assert!(result.is_ok()); + } + + #[test] + fn test_sync_js_injection_with_inject_file_should_patch_asset() { + let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(true, true); + + let installation_id = "01H2QZ6Z8WXWNDC0KQ198XCZEB"; + let asset_id = "asset-123"; + let js = "console.log('hello edge app');"; + + std::fs::write(tmp_dir.path().join(INJECT_JS_FILE_NAME), js).unwrap(); + + let lookup_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v4.1/assets") + .query_param("select", "id,js_injection") + .query_param("app_installation_id", format!("eq.{installation_id}")) + .query_param("app_channel", "eq.stable"); + then.status(200).json_body(json!([{ + "id": asset_id, + "js_injection": "" + }])); + }); + + let patch_mock = mock_server.mock(|when, then| { + when.method(PATCH) + .path("/api/v4.1/assets") + .query_param("id", format!("eq.{asset_id}")) + .json_body(json!({ "js_injection": js })); + then.status(200).json_body(json!([{}])); + }); + + let result = + command.sync_js_injection(Some(tmp_dir.path().to_str().unwrap().to_string())); + assert!(result.is_ok(), "{:?}", result); + + lookup_mock.assert(); + patch_mock.assert(); + } + + #[test] + fn test_sync_js_injection_when_remote_matches_local_should_skip_patch() { + let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(true, true); + + let installation_id = "01H2QZ6Z8WXWNDC0KQ198XCZEB"; + let asset_id = "asset-789"; + let js = "console.log('unchanged');"; + + std::fs::write(tmp_dir.path().join(INJECT_JS_FILE_NAME), js).unwrap(); + + let lookup_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v4.1/assets") + .query_param("app_installation_id", format!("eq.{installation_id}")); + then.status(200).json_body(json!([{ + "id": asset_id, + "js_injection": js + }])); + }); + + let patch_mock = mock_server.mock(|when, then| { + when.method(PATCH).path("/api/v4.1/assets"); + then.status(200); + }); + + let result = + command.sync_js_injection(Some(tmp_dir.path().to_str().unwrap().to_string())); + assert!(result.is_ok(), "{:?}", result); + + lookup_mock.assert(); + assert_eq!(patch_mock.hits(), 0); + } + + #[test] + fn test_sync_js_injection_with_missing_inject_file_should_clear_js_injection() { + let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(true, true); + + let installation_id = "01H2QZ6Z8WXWNDC0KQ198XCZEB"; + let asset_id = "asset-456"; + + let lookup_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/api/v4.1/assets") + .query_param("app_installation_id", format!("eq.{installation_id}")); + then.status(200).json_body(json!([{ + "id": asset_id, + "js_injection": "previous content" + }])); + }); + + let patch_mock = mock_server.mock(|when, then| { + when.method(PATCH) + .path("/api/v4.1/assets") + .query_param("id", format!("eq.{asset_id}")) + .json_body(json!({ "js_injection": "" })); + then.status(200).json_body(json!([{}])); + }); + + let result = + command.sync_js_injection(Some(tmp_dir.path().to_str().unwrap().to_string())); + assert!(result.is_ok(), "{:?}", result); + + lookup_mock.assert(); + patch_mock.assert(); + } + #[test] fn test_list_edge_apps_should_send_correct_request() { let (_tmp_dir, command, mock_server, _manifest, _instance_manifest) = diff --git a/src/commands/edge_app/utils.rs b/src/commands/edge_app/utils.rs index 5b78aa4..61a5b04 100644 --- a/src/commands/edge_app/utils.rs +++ b/src/commands/edge_app/utils.rs @@ -64,7 +64,13 @@ impl FileChanges { } fn is_included(entry: &DirEntry, ignore: &Ignorer) -> bool { - let exclusion_list = ["screenly.js", "screenly.yml", ".ignore", "instance.yml"]; + let exclusion_list = [ + "screenly.js", + "screenly.yml", + ".ignore", + "instance.yml", + "screenly_inject.js", + ]; if exclusion_list.contains(&entry.file_name().to_str().unwrap_or_default()) { return false; } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 33086c9..db4f605 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -137,16 +137,18 @@ pub fn get( endpoint: &str, ) -> Result { let url = format!("{}/{}", &authentication.config.url, endpoint); + debug!("GET {url}"); let mut headers = HeaderMap::new(); headers.insert("Prefer", "return=representation".parse()?); let response = authentication .build_client()? - .get(url) + .get(&url) .headers(headers) .send()?; let status = response.status(); + debug!("GET {url} -> {status}"); if status != StatusCode::OK { println!("Response: {:?}", &response.text()); From ef62669dfba02a71b89e98ce084ec62f40ae3e1b Mon Sep 17 00:00:00 2001 From: Sergey Borovkov Date: Mon, 4 May 2026 16:00:58 +0400 Subject: [PATCH 2/6] Ship setValue/setReactValue/setCookie/onPath helpers in inject template Lift the four recurring patterns from the Playground javascript-injectors examples (setValue, setReactValue for framework-controlled inputs, setCookie for SSO, onPath for path-guarded execution) into the bundled screenly_inject.js. Includes commented examples for form-fill login, cookie-based SSO, and a fetch-override Bearer-token attach so users porting from Playground get a familiar starting shape. --- data/screenly_inject.js | 58 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/data/screenly_inject.js b/data/screenly_inject.js index b7659e3..0124592 100644 --- a/data/screenly_inject.js +++ b/data/screenly_inject.js @@ -4,7 +4,61 @@ // Use this to authenticate the remote page without baking credentials // into the script. // -// Example: attach a Bearer token from a setting/secret to every fetch. +// ---- Helpers -------------------------------------------------------------- + +// Set an input's value and notify listeners. +function setValue(selector, value) { + const el = document.querySelector(selector); + if (!el) return false; + el.value = value; + el.dispatchEvent(new Event('change', { bubbles: true })); + return true; +} + +// Set an input's value through the native setter so frameworks like React +// pick it up. Use this when `setValue` doesn't take effect. +function setReactValue(selector, value) { + const el = document.querySelector(selector); + if (!el) return false; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value' + ).set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + return true; +} + +// Set a cookie scoped to `domain` and reload the page. No-ops if already set. +function setCookie(key, value, domain) { + if (document.cookie.split('; ').some(c => c.startsWith(key + '='))) return; + document.cookie = `${key}=${value}; path=/; domain=${domain}`; + location.reload(); +} + +// Run `fn` only when the current path matches `path`. +function onPath(path, fn) { + if (location.pathname === path) fn(); +} + +// ---- Examples (uncomment one) -------------------------------------------- + +// Form-fill login on /login: +// +// onPath('/login', () => { +// if (setValue('input[name="username"]', screenly_settings.username) && +// setValue('input[name="password"]', screenly_settings.password)) { +// document.querySelector('button[type="submit"]').click(); +// } else { +// setTimeout(arguments.callee, 1000); // retry until the form is rendered +// } +// }); + +// SSO via cookie: +// +// setCookie('session_id', screenly_settings.session_id, '.example.com'); + +// Override fetch to attach a Bearer token to every request the page makes: // // const token = screenly_settings.api_token; // const originalFetch = window.fetch; @@ -13,4 +67,6 @@ // return originalFetch(input, init); // }; +// ---- Default: log what's available --------------------------------------- + console.log('screenly_settings:', screenly_settings); From 2b1dc7fa3d23cb78630f7e73ce9403ad94eae879 Mon Sep 17 00:00:00 2001 From: Sergey Borovkov Date: Mon, 4 May 2026 19:19:47 +0400 Subject: [PATCH 3/6] Drop js_injection PATCH: ship screenly_inject.js as a bundled file The player will read screenly_inject.js straight from the edge app's file tree (octo-avenger change in a separate commit), so the CLI no longer needs to talk to the assets endpoint to set js_injection. This removes: - `sync_js_injection`, `wait_for_installation_stable_asset` and their call site at the end of `deploy` - `set_asset_js_injection` and `get_installation_stable_asset` on the Api struct - `screenly_inject.js` from the always-excluded list (so it's actually uploaded as a normal asset file) - the `.ignore` write in `create --entrypoint` (file is bundled now) Kept: the `--entrypoint` flag, the `screenly_inject.js` template write on `create`, the `VirtualIndexHtml` placeholder for the case where the user removes the inject file (the version still needs at least one signed asset to publish), the URL+status logging in `commands::get`. Docs updated to describe the file-based flow. --- docs/EdgeApps.md | 42 ++---- src/api/asset.rs | 49 ------- src/commands/edge_app/app.rs | 235 +-------------------------------- src/commands/edge_app/utils.rs | 8 +- 4 files changed, 16 insertions(+), 318 deletions(-) diff --git a/docs/EdgeApps.md b/docs/EdgeApps.md index e484d09..f64d9aa 100644 --- a/docs/EdgeApps.md +++ b/docs/EdgeApps.md @@ -93,45 +93,27 @@ $ screenly edge-app create --name hello-remote --entrypoint https://example.com/ ``` This sets `entrypoint.type: remote-global` and `entrypoint.uri` in -`screenly.yml`, skips writing the `index.html` stub (it's not needed for -remote-entrypoint apps), and adds `screenly_inject.js` to `.ignore` so the -JS injection file (see below) is never bundled as an asset. +`screenly.yml`, skips writing the `index.html` stub, and drops a starter +`screenly_inject.js` next to the manifest. The main use case for remote entrypoints is **JS injection** — a snippet of JavaScript that the player executes against the loaded remote page on every load. The most common reason to reach for this is **authenticating to the remote page**: overriding `fetch`/`XHR` to attach `Authorization` headers, setting cookies or `localStorage` tokens before the page boots, -auto-filling and submitting login forms, etc. (You can of course also use -it to hide chrome, inject branding, or otherwise tweak third-party pages -you don't control.) - -When `create --entrypoint` is used, a starter `screenly_inject.js` is -written into the project directory alongside `screenly.yml`. On every -`screenly edge-app deploy` its contents are pushed to the player as the -`js_injection` for this installation's asset. If the file is missing or -empty, `js_injection` is cleared on deploy so the deployed state always -mirrors the file. JS injection is applied per-installation, so you need -an `instance.yml` in the project directory (created via -`screenly edge-app instance create`) — without one, deploy skips the JS -injection step. +auto-filling and submitting login forms, etc. + +To use it, edit `screenly_inject.js` and run `screenly edge-app deploy`. +The file is bundled as part of the Edge App's revision; the player picks +it up automatically on remote-entrypoint Edge Apps. If you don't want JS +injection, delete the file before deploying. To pass credentials securely into the injected script, define them as Edge App **settings** or **secrets** (see [Settings](#settings) below) -and read them from `screenly_settings` inside `screenly_inject.js`. The -player wraps the injection in an IIFE that supplies `screenly_settings` -as an argument: - -```js -(function(screenly_settings) { - // your screenly_inject.js contents here -})({ /* this app's settings + secrets */ }); -``` - -So inside `screenly_inject.js` you can reference `screenly_settings` -directly — it's not a true global, but it's in scope for the whole -script. This keeps the values out of the page source and out of the -injection script you commit to source control. +and read them as `screenly_settings.` inside `screenly_inject.js`. +The player provides `screenly_settings` to the script at runtime, so +secret values don't appear in the page source or in the injection +script you commit to source control. Example `screenly_inject.js` that adds a Bearer token to every outbound request from the remote page: diff --git a/src/api/asset.rs b/src/api/asset.rs index 5455b62..b070a2c 100644 --- a/src/api/asset.rs +++ b/src/api/asset.rs @@ -46,53 +46,4 @@ impl Api { )?) } - pub fn set_asset_js_injection( - &self, - asset_id: &str, - js_code: &str, - ) -> Result<(), CommandError> { - // TODO: Switch to a dedicated revision-scoped js_injection endpoint - // when the backend ships it. Patching `js_injection` directly on the - // asset currently returns 400 ({"code":"22P02","error":"Invalid input - // value: nil"}) on the live API, so the deploy errors at this final - // step. Versioning + entrypoint setup + asset lookup are fine. - let endpoint = format!("api/v4.1/assets?id=eq.{asset_id}"); - commands::patch( - &self.authentication, - &endpoint, - &serde_json::json!({ "js_injection": js_code }), - )?; - Ok(()) - } - - pub fn get_installation_stable_asset( - &self, - installation_id: &str, - ) -> Result, CommandError> { - // Assets have `app_installation_id` as a direct column, so a flat - // filter is enough — no PostgREST embed needed. - let endpoint = format!( - "api/v4.1/assets?select=id,js_injection\ - &app_installation_id=eq.{installation_id}&app_channel=eq.stable" - ); - let response = commands::get(&self.authentication, &endpoint)?; - - let array = response.as_array().ok_or(CommandError::MissingField)?; - match array.first() { - Some(asset) => { - let id = asset - .get("id") - .and_then(|v| v.as_str()) - .ok_or(CommandError::MissingField)? - .to_owned(); - let js_injection = asset - .get("js_injection") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(); - Ok(Some((id, js_injection))) - } - None => Ok(None), - } - } } diff --git a/src/commands/edge_app/app.rs b/src/commands/edge_app/app.rs index 59f7665..48bf347 100644 --- a/src/commands/edge_app/app.rs +++ b/src/commands/edge_app/app.rs @@ -31,32 +31,6 @@ use crate::commands::{CommandError, EdgeApps}; pub const INJECT_JS_FILE_NAME: &str = "screenly_inject.js"; -fn ensure_inject_js_in_ignore(parent_dir: &Path) -> Result<(), CommandError> { - let ignore_path = parent_dir.join(".ignore"); - let existing = match fs::read_to_string(&ignore_path) { - Ok(s) => s, - Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), - Err(e) => return Err(CommandError::Io(e)), - }; - - if existing - .lines() - .any(|line| line.trim() == INJECT_JS_FILE_NAME) - { - return Ok(()); - } - - let mut new_content = existing; - if !new_content.is_empty() && !new_content.ends_with('\n') { - new_content.push('\n'); - } - new_content.push_str(INJECT_JS_FILE_NAME); - new_content.push('\n'); - - fs::write(&ignore_path, new_content)?; - Ok(()) -} - // Edge apps commands impl EdgeAppCommand { pub fn create( @@ -147,8 +121,6 @@ impl EdgeAppCommand { } if is_remote { - ensure_inject_js_in_ignore(parent_dir_path)?; - let inject_js_path = parent_dir_path.join(INJECT_JS_FILE_NAME); if !inject_js_path.exists() { let template = include_str!(concat!( @@ -330,62 +302,9 @@ impl EdgeAppCommand { revision }; - self.sync_js_injection(path)?; - Ok(final_revision) } - fn sync_js_injection(&self, path: Option) -> Result<(), CommandError> { - let installation_id = match self.get_installation_id(path.clone()) { - Ok(id) => id, - Err(_) => { - debug!("No instance.yml present; skipping js_injection sync."); - return Ok(()); - } - }; - - let manifest_path = transform_edge_app_path_to_manifest(&path)?; - let edge_app_dir = manifest_path.parent().ok_or(CommandError::MissingField)?; - let inject_path = edge_app_dir.join(INJECT_JS_FILE_NAME); - - let js_code = match fs::read_to_string(&inject_path) { - Ok(s) => s, - Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), - Err(e) => return Err(CommandError::Io(e)), - }; - - let (asset_id, current_js_injection) = - self.wait_for_installation_stable_asset(&installation_id)?; - if current_js_injection == js_code { - debug!("js_injection already up to date for asset {asset_id}; skipping PATCH."); - return Ok(()); - } - self.api.set_asset_js_injection(&asset_id, &js_code)?; - debug!("js_injection synced for asset {asset_id}."); - Ok(()) - } - - fn wait_for_installation_stable_asset( - &self, - installation_id: &str, - ) -> Result<(String, String), CommandError> { - const SLEEP_SECS: u64 = 2; - const MAX_WAIT_SECS: u64 = 120; - let start = Instant::now(); - loop { - if let Some(pair) = self.api.get_installation_stable_asset(installation_id)? { - return Ok(pair); - } - if start.elapsed().as_secs() > MAX_WAIT_SECS { - return Err(CommandError::AssetProcessingTimeout); - } - debug!( - "Stable asset for installation {installation_id} not provisioned yet; retrying." - ); - thread::sleep(Duration::from_secs(SLEEP_SECS)); - } - } - fn promote_version( &self, app_id: &str, @@ -1015,7 +934,8 @@ mod tests { } #[test] - fn test_edge_app_create_with_remote_entrypoint_should_set_remote_global_and_write_ignore() { + fn test_edge_app_create_with_remote_entrypoint_should_set_remote_global_and_write_inject_template( + ) { let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = prepare_edge_apps_test(false, false); @@ -1048,11 +968,7 @@ mod tests { ); assert!(!tmp_dir.path().join("index.html").exists()); - - let ignore_path = tmp_dir.path().join(".ignore"); - assert!(ignore_path.exists()); - let ignore_content = fs::read_to_string(&ignore_path).unwrap(); - assert!(ignore_content.lines().any(|l| l.trim() == INJECT_JS_FILE_NAME)); + assert!(!tmp_dir.path().join(".ignore").exists()); let inject_path = tmp_dir.path().join(INJECT_JS_FILE_NAME); assert!(inject_path.exists()); @@ -1079,151 +995,6 @@ mod tests { assert!(!tmp_dir.path().join("screenly.yml").exists()); } - #[test] - fn test_edge_app_create_with_remote_entrypoint_should_append_to_existing_ignore() { - let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(false, false); - - std::fs::write(tmp_dir.path().join(".ignore"), "*.log\n").unwrap(); - - let post_mock = mock_server.mock(|when, then| { - when.method(POST) - .path("/v4/edge-apps") - .header("Authorization", "Token token"); - then.status(201) - .json_body(json!([{"id": "test-id", "name": "Remote app"}])); - }); - - let result = command.create( - "Remote app", - tmp_dir.path().join("screenly.yml").as_path(), - Some("https://example.com/app".to_string()), - ); - - post_mock.assert(); - assert!(result.is_ok()); - - let ignore_content = fs::read_to_string(tmp_dir.path().join(".ignore")).unwrap(); - assert!(ignore_content.lines().any(|l| l.trim() == "*.log")); - assert!(ignore_content.lines().any(|l| l.trim() == INJECT_JS_FILE_NAME)); - } - - #[test] - fn test_sync_js_injection_with_no_instance_manifest_should_skip() { - let (tmp_dir, command, _mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(true, false); - - let result = - command.sync_js_injection(Some(tmp_dir.path().to_str().unwrap().to_string())); - assert!(result.is_ok()); - } - - #[test] - fn test_sync_js_injection_with_inject_file_should_patch_asset() { - let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(true, true); - - let installation_id = "01H2QZ6Z8WXWNDC0KQ198XCZEB"; - let asset_id = "asset-123"; - let js = "console.log('hello edge app');"; - - std::fs::write(tmp_dir.path().join(INJECT_JS_FILE_NAME), js).unwrap(); - - let lookup_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/api/v4.1/assets") - .query_param("select", "id,js_injection") - .query_param("app_installation_id", format!("eq.{installation_id}")) - .query_param("app_channel", "eq.stable"); - then.status(200).json_body(json!([{ - "id": asset_id, - "js_injection": "" - }])); - }); - - let patch_mock = mock_server.mock(|when, then| { - when.method(PATCH) - .path("/api/v4.1/assets") - .query_param("id", format!("eq.{asset_id}")) - .json_body(json!({ "js_injection": js })); - then.status(200).json_body(json!([{}])); - }); - - let result = - command.sync_js_injection(Some(tmp_dir.path().to_str().unwrap().to_string())); - assert!(result.is_ok(), "{:?}", result); - - lookup_mock.assert(); - patch_mock.assert(); - } - - #[test] - fn test_sync_js_injection_when_remote_matches_local_should_skip_patch() { - let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(true, true); - - let installation_id = "01H2QZ6Z8WXWNDC0KQ198XCZEB"; - let asset_id = "asset-789"; - let js = "console.log('unchanged');"; - - std::fs::write(tmp_dir.path().join(INJECT_JS_FILE_NAME), js).unwrap(); - - let lookup_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/api/v4.1/assets") - .query_param("app_installation_id", format!("eq.{installation_id}")); - then.status(200).json_body(json!([{ - "id": asset_id, - "js_injection": js - }])); - }); - - let patch_mock = mock_server.mock(|when, then| { - when.method(PATCH).path("/api/v4.1/assets"); - then.status(200); - }); - - let result = - command.sync_js_injection(Some(tmp_dir.path().to_str().unwrap().to_string())); - assert!(result.is_ok(), "{:?}", result); - - lookup_mock.assert(); - assert_eq!(patch_mock.hits(), 0); - } - - #[test] - fn test_sync_js_injection_with_missing_inject_file_should_clear_js_injection() { - let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(true, true); - - let installation_id = "01H2QZ6Z8WXWNDC0KQ198XCZEB"; - let asset_id = "asset-456"; - - let lookup_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/api/v4.1/assets") - .query_param("app_installation_id", format!("eq.{installation_id}")); - then.status(200).json_body(json!([{ - "id": asset_id, - "js_injection": "previous content" - }])); - }); - - let patch_mock = mock_server.mock(|when, then| { - when.method(PATCH) - .path("/api/v4.1/assets") - .query_param("id", format!("eq.{asset_id}")) - .json_body(json!({ "js_injection": "" })); - then.status(200).json_body(json!([{}])); - }); - - let result = - command.sync_js_injection(Some(tmp_dir.path().to_str().unwrap().to_string())); - assert!(result.is_ok(), "{:?}", result); - - lookup_mock.assert(); - patch_mock.assert(); - } #[test] fn test_list_edge_apps_should_send_correct_request() { diff --git a/src/commands/edge_app/utils.rs b/src/commands/edge_app/utils.rs index 61a5b04..5b78aa4 100644 --- a/src/commands/edge_app/utils.rs +++ b/src/commands/edge_app/utils.rs @@ -64,13 +64,7 @@ impl FileChanges { } fn is_included(entry: &DirEntry, ignore: &Ignorer) -> bool { - let exclusion_list = [ - "screenly.js", - "screenly.yml", - ".ignore", - "instance.yml", - "screenly_inject.js", - ]; + let exclusion_list = ["screenly.js", "screenly.yml", ".ignore", "instance.yml"]; if exclusion_list.contains(&entry.file_name().to_str().unwrap_or_default()) { return false; } From b4f664daf4f2e355b6668dda6987f2f4d7a4ed2d Mon Sep 17 00:00:00 2001 From: Sergey Borovkov Date: Mon, 4 May 2026 21:09:29 +0400 Subject: [PATCH 4/6] Note in inject template that DOMContentLoaded has already fired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The player runs the inject script after the page is fully loaded, so wrapping code in `document.addEventListener('DOMContentLoaded', ...)` silently fails — the listener gets registered after the event has already fired. Added a comment in the bundled template warning about this so users don't reach for the familiar pattern by reflex. --- data/screenly_inject.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/data/screenly_inject.js b/data/screenly_inject.js index 0124592..378a371 100644 --- a/data/screenly_inject.js +++ b/data/screenly_inject.js @@ -4,6 +4,11 @@ // Use this to authenticate the remote page without baking credentials // into the script. // +// This script runs AFTER the page has fully loaded — DOMContentLoaded / +// window.load have already fired, so manipulate the DOM directly. Don't +// wrap your code in `document.addEventListener('DOMContentLoaded', ...)` +// (the listener will be registered too late and never fire). +// // ---- Helpers -------------------------------------------------------------- // Set an input's value and notify listeners. From 63967b7cba68f591cf5950795130aea5d31b4821 Mon Sep 17 00:00:00 2001 From: Sergey Borovkov Date: Mon, 4 May 2026 21:24:24 +0400 Subject: [PATCH 5/6] Fix rustfmt + regenerate CommandLineHelp; clean up --entrypoint help text - `cargo fmt` (extra blank lines). - `--entrypoint` help string was still describing the old behavior of adding the inject file to `.ignore`; updated to describe the bundle-with-revision flow. - Regenerated `docs/CommandLineHelp.md` to match. --- docs/CommandLineHelp.md | 4 ++++ src/api/asset.rs | 1 - src/cli.rs | 8 +++++--- src/commands/edge_app/app.rs | 2 -- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/CommandLineHelp.md b/docs/CommandLineHelp.md index 825ec84..3e22bee 100644 --- a/docs/CommandLineHelp.md +++ b/docs/CommandLineHelp.md @@ -1,3 +1,6 @@ + Compiling screenly v1.1.1 (/home/sergey/work/cli) + Finished `release` profile [optimized] target(s) in 6.84s + Running `target/release/screenly print-help-markdown` # Command-Line Help for `screenly` This document contains the help content for the `screenly` command-line program. @@ -474,6 +477,7 @@ Creates an Edge App in the store * `-n`, `--name ` — Edge App name * `-p`, `--path ` — Path to the directory with the manifest. Defaults to the current working directory * `-i`, `--in-place` — Use an existing Edge App directory with the manifest and index.html +* `-e`, `--entrypoint ` — Remote entrypoint URL. When set, the created app uses entrypoint.type = remote-global with this URL and a starter `screenly_inject.js` is dropped next to `screenly.yml`. The inject file is shipped with each deploy and the player runs it on every load diff --git a/src/api/asset.rs b/src/api/asset.rs index b070a2c..1588a92 100644 --- a/src/api/asset.rs +++ b/src/api/asset.rs @@ -45,5 +45,4 @@ impl Api { response, )?) } - } diff --git a/src/cli.rs b/src/cli.rs index 368c5e3..d3ba88e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -355,9 +355,11 @@ pub enum EdgeAppCommands { /// Use an existing Edge App directory with the manifest and index.html. #[arg(short, long, action = clap::ArgAction::SetTrue)] in_place: Option, - /// Remote entrypoint URL. When set, the created app uses entrypoint.type = - /// remote-global with this URL, and `screenly_inject.js` is added to `.ignore` - /// for use with the JS injection workflow on deploy. + /// Remote entrypoint URL. When set, the created app uses + /// entrypoint.type = remote-global with this URL and a starter + /// `screenly_inject.js` is dropped next to `screenly.yml`. The + /// inject file is shipped with each deploy and the player runs it + /// on every load. #[arg(short, long)] entrypoint: Option, }, diff --git a/src/commands/edge_app/app.rs b/src/commands/edge_app/app.rs index 48bf347..d5ed98d 100644 --- a/src/commands/edge_app/app.rs +++ b/src/commands/edge_app/app.rs @@ -169,7 +169,6 @@ impl Drop for VirtualIndexHtml { } impl EdgeAppCommand { - pub fn create_in_place(&self, name: &str, path: &Path) -> Result<(), CommandError> { let parent_dir_path = path.parent().ok_or(CommandError::FileSystemError( "Cannot obtain Edge App root directory.".to_owned(), @@ -995,7 +994,6 @@ mod tests { assert!(!tmp_dir.path().join("screenly.yml").exists()); } - #[test] fn test_list_edge_apps_should_send_correct_request() { let (_tmp_dir, command, mock_server, _manifest, _instance_manifest) = From 47c9ca5a379dc78a84456302685bf4d5d8f5f291 Mon Sep 17 00:00:00 2001 From: Sergey Borovkov Date: Mon, 4 May 2026 21:42:45 +0400 Subject: [PATCH 6/6] Fix CommandLineHelp.md: regenerate without piped stderr --- docs/CommandLineHelp.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/CommandLineHelp.md b/docs/CommandLineHelp.md index 3e22bee..797429b 100644 --- a/docs/CommandLineHelp.md +++ b/docs/CommandLineHelp.md @@ -1,6 +1,3 @@ - Compiling screenly v1.1.1 (/home/sergey/work/cli) - Finished `release` profile [optimized] target(s) in 6.84s - Running `target/release/screenly print-help-markdown` # Command-Line Help for `screenly` This document contains the help content for the `screenly` command-line program.