From 4092315b7eb064991d7941353791ae216f26db49 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Thu, 4 Jun 2026 22:57:52 -0400 Subject: [PATCH 1/4] Add support for loading http(s) URLs The positional argument now accepts an http:// or https:// URL in addition to a file path. URLs are detected case-insensitively and loaded directly via the webview; anything else is still treated as a file. Piped stdin continues to take precedence over both. Updates help text, usage message, and README accordingly. --- README.md | 10 +++++---- src/cli.rs | 11 +++++----- src/input.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 2 +- src/run.rs | 1 + 5 files changed, 73 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d24c817..39b463a 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,14 @@ On Linux you need the WebKitGTK headers to build (or to run the binary): ```bash echo '…' | webview # HTML piped on stdin webview ./page.html # or a file path +webview https://example.com # or an http(s) URL webview ./page.html --title T --width 900 --height 700 --devtools --icon ./icon.png --timeout-ms 60000 ``` -Input precedence: non-empty piped stdin wins; otherwise the path argument; -otherwise it's a usage error. File pages are served over a custom origin so -relative CSS/JS/images and `fetch` resolve (and so they load at all under +Input precedence: non-empty piped stdin wins; otherwise the positional argument +(an `http://`/`https://` URL is loaded remotely, anything else is treated as a +file); otherwise it's a usage error. File pages are served over a custom origin +so relative CSS/JS/images and `fetch` resolve (and so they load at all under WKWebView). ### Flags @@ -157,7 +159,7 @@ Source layout: | ----------- | --------------------------------------------------------- | | `main.rs` | orchestration: parse args, resolve input, run | | `cli.rs` | clap arg struct + usage | -| `input.rs` | stdin-vs-path resolution → a `Load` enum | +| `input.rs` | stdin / URL / path resolution → a `Load` enum | | `bridge.rs` | the `BRIDGE` JS + `AppEvent` + message parsing | | `assets.rs` | custom-protocol file server (MIME, path-traversal safety) | | `icon.rs` | runtime Dock-icon swap for `--icon` (macOS; no-op else) | diff --git a/src/cli.rs b/src/cli.rs index 4139681..d63b34a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,8 +11,8 @@ use clap::Parser; const LONG_ABOUT: &str = "\ Render HTML in a native webview, print the single result the page sends back, then exit. -The HTML comes from a FILE argument, or from stdin when piped in. The page reports its -result over the JavaScript bridge that webview injects as `window.webview`: +The page comes from a FILE argument, an http(s) URL, or from stdin when piped in. The +page reports its result over the JavaScript bridge that webview injects as `window.webview`: window.webview.resolve(value) print `value` to stdout, exit 0 window.webview.reject(reason) print `reason` to stderr, exit 1 @@ -25,6 +25,7 @@ verbatim, so the caller decides how to interpret it."; const AFTER_LONG_HELP: &str = "\ Examples: webview page.html render a file and wait for a result + webview https://example.com render a remote URL cat page.html | webview render HTML piped on stdin webview page.html --timeout-ms 5000 give up after 5 seconds webview page.html --title Pick --width 480 --height 320 @@ -34,7 +35,7 @@ Exit codes: 1 the page called reject(reason) 2 the window was closed before the page settled 3 --timeout-ms elapsed before the page settled - 64 usage error (no HTML on stdin or as a file argument)"; + 64 usage error (no HTML on stdin, URL, or file argument)"; /// Parsed command-line arguments. /// @@ -50,8 +51,8 @@ Exit codes: after_long_help = AFTER_LONG_HELP, )] pub struct Cli { - /// HTML file to render. Omit to read HTML from stdin. - #[arg(value_name = "FILE")] + /// HTML file to render, or an http(s) URL to load. Omit to read HTML from stdin. + #[arg(value_name = "FILE|URL")] pub path: Option, /// Window title. diff --git a/src/input.rs b/src/input.rs index aa5a485..480e48e 100644 --- a/src/input.rs +++ b/src/input.rs @@ -2,7 +2,7 @@ //! //! Precedence: //! 1. stdin is not a TTY (something piped in) -> read to EOF as inline HTML. -//! 2. a positional path was given -> load that file. +//! 2. a positional argument was given -> an http(s) URL, else a file. //! 3. neither -> usage error (exit 64). use std::io::{self, IsTerminal, Read}; @@ -15,6 +15,15 @@ pub enum Load { Html(String), /// A file on disk (file:// origin, with read access to its parent dir). File(PathBuf), + /// A remote http(s) URL, loaded directly. + Url(String), +} + +/// Does this positional argument look like an http(s) URL we should load +/// remotely rather than treat as a file on disk? +fn looks_like_url(arg: &str) -> bool { + let lower = arg.to_ascii_lowercase(); + lower.starts_with("http://") || lower.starts_with("https://") } /// Why we couldn't resolve any input. @@ -48,8 +57,14 @@ where } } - // 2. Otherwise fall back to a file path. + // 2. Otherwise fall back to the positional argument: an http(s) URL is + // loaded remotely, anything else is treated as a file on disk. if let Some(p) = path { + if let Some(s) = p.to_str() { + if looks_like_url(s) { + return Ok(Load::Url(s.to_string())); + } + } return Ok(Load::File(p)); } @@ -120,4 +135,46 @@ mod tests { let got = resolve(false, None, || Err(io::Error::other("boom"))); assert!(matches!(got, Err(InputError::StdinRead(_)))); } + + #[test] + fn http_argument_is_loaded_as_a_url() { + let got = resolve(true, Some(PathBuf::from("http://example.com")), || { + panic!("stdin should not be read when it's a tty") + }); + assert_eq!(got, Ok(Load::Url("http://example.com".to_string()))); + } + + #[test] + fn https_argument_is_loaded_as_a_url() { + let got = resolve( + true, + Some(PathBuf::from("https://example.com/p?a=1")), + || panic!("stdin should not be read when it's a tty"), + ); + assert_eq!(got, Ok(Load::Url("https://example.com/p?a=1".to_string()))); + } + + #[test] + fn url_scheme_match_is_case_insensitive() { + let got = resolve(true, Some(PathBuf::from("HTTPS://Example.com")), || { + panic!("stdin should not be read when it's a tty") + }); + assert_eq!(got, Ok(Load::Url("HTTPS://Example.com".to_string()))); + } + + #[test] + fn non_url_argument_is_still_a_file() { + let got = resolve(true, Some(PathBuf::from("page.html")), || { + panic!("stdin should not be read when it's a tty") + }); + assert_eq!(got, Ok(Load::File(PathBuf::from("page.html")))); + } + + #[test] + fn piped_stdin_takes_precedence_over_url() { + let got = resolve(false, Some(PathBuf::from("https://example.com")), || { + Ok("

piped

".to_string()) + }); + assert_eq!(got, Ok(Load::Html("

piped

".to_string()))); + } } diff --git a/src/main.rs b/src/main.rs index 63b87f7..b64febd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ fn main() { Ok(load) => load, Err(InputError::NoInput) => { eprintln!( - "webview: no HTML to render. Pass a file path, or pipe HTML on stdin.\n\ + "webview: no HTML to render. Pass a file path or http(s) URL, or pipe HTML on stdin.\n\ Try 'webview --help' for usage." ); std::process::exit(exit::USAGE); diff --git a/src/run.rs b/src/run.rs index 023b7b8..3470fdb 100644 --- a/src/run.rs +++ b/src/run.rs @@ -68,6 +68,7 @@ pub fn run(cli: &Cli, load: Load) -> ! { builder = match &load { Load::Html(html) => builder.with_html(html), + Load::Url(url) => builder.with_url(url), Load::File(path) => { // Serve the page's directory over a custom scheme so it loads at // all (WKWebView) and gets a real origin for relative assets. From 89272098dbb7ce8a60982e285246c06f7dad01b6 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Thu, 4 Jun 2026 23:02:19 -0400 Subject: [PATCH 2/4] Bump version to 0.2.0 and add CHANGELOG Prepares for release of the http(s) URL input feature. --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bb4e764 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-06-04 + +### Added + +- Load remote pages by passing an `http://` or `https://` URL as the positional + argument, alongside the existing file-path and piped-stdin inputs. URLs are + detected case-insensitively; anything else is treated as a file. Piped stdin + continues to take precedence over both. + +## [0.1.0] + +### Added + +- Initial release: a one-shot webview CLI that renders HTML (from a file or + piped on stdin), gives the page a `window.webview.resolve` / `.reject` + bridge, prints the single result, and exits with a documented exit code. +- Window-shaping flags: `--title`, `--width`, `--height`, `--devtools`, + `--icon` (macOS Dock icon), and `--timeout-ms`. + +[0.2.0]: https://github.com/just-be-dev/webview-cli/releases/tag/v0.2.0 +[0.1.0]: https://github.com/just-be-dev/webview-cli/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 7a7dafc..9ff9004 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2351,7 +2351,7 @@ dependencies = [ [[package]] name = "webview-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "objc2", diff --git a/Cargo.toml b/Cargo.toml index 76182ce..9cc3f64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "webview-cli" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "A one-shot webview CLI: render HTML, get one JSON result back, exit." authors = ["Justin Bennett "] From 380cd920135d30f2376a1eadf3b4bb495d799298 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Thu, 4 Jun 2026 23:08:02 -0400 Subject: [PATCH 3/4] Drop "one-shot" framing from description and docs --- CHANGELOG.md | 2 +- Cargo.toml | 2 +- src/assets.rs | 2 +- src/main.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4e764..6c6de44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release: a one-shot webview CLI that renders HTML (from a file or +- Initial release: a webview CLI that renders HTML (from a file or piped on stdin), gives the page a `window.webview.resolve` / `.reject` bridge, prints the single result, and exits with a documented exit code. - Window-shaping flags: `--title`, `--width`, `--height`, `--devtools`, diff --git a/Cargo.toml b/Cargo.toml index 9cc3f64..1fc5866 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "webview-cli" version = "0.2.0" edition = "2021" -description = "A one-shot webview CLI: render HTML, get one JSON result back, exit." +description = "A webview CLI: render HTML, get one JSON result back, exit." authors = ["Justin Bennett "] license = "MIT" repository = "https://github.com/just-be-dev/webview-cli" diff --git a/src/assets.rs b/src/assets.rs index 6dd0eb8..e9731a7 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -13,7 +13,7 @@ use std::path::{Component, Path, PathBuf}; use wry::http::{header::CONTENT_TYPE, Request, Response}; /// Guess a content type from a file extension. A small, dependency-free table — -/// enough for the assets a one-shot page references. +/// enough for the assets a page references. pub fn content_type(path: &Path) -> &'static str { let ext = path .extension() diff --git a/src/main.rs b/src/main.rs index b64febd..3cabe84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -//! webview — a one-shot webview CLI for agents and humans. +//! webview — a webview CLI for agents and humans. //! //! Render the HTML the caller provides, give the page one channel to send a //! result back (`window.webview.resolve` / `.reject`), print that result, and From a97780dc2390526bd005e321c1686a51b4c38c51 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Thu, 4 Jun 2026 23:18:09 -0400 Subject: [PATCH 4/4] Add webview-context detection signals Expose window.webview.version to the page and set a webview-cli/ User-Agent so remote pages can detect they're rendered inside webview (in JS, or server-side before any JS runs). wry replaces the UA wholesale, so this token becomes the full User-Agent. --- CHANGELOG.md | 4 ++++ README.md | 13 +++++++++++++ src/bridge.rs | 17 +++++++++++------ src/run.rs | 10 ++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6de44..aa05746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 argument, alongside the existing file-path and piped-stdin inputs. URLs are detected case-insensitively; anything else is treated as a file. Piped stdin continues to take precedence over both. +- `window.webview.version` exposes the webview-cli version to the page, so a + remote page can detect this context and tell which build it's running in. +- Set a `webview-cli/` User-Agent so a server can detect the webview + context before any JavaScript runs. ## [0.1.0] diff --git a/README.md b/README.md index 39b463a..96a70c3 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,25 @@ Everything else lives in the HTML — there are no other flags by design. The page talks back through one injected object: ```js +window.webview.version; // the webview-cli version string, e.g. "0.2.0" window.webview.resolve(value); // any JSON-serializable value window.webview.reject(error); // string or Error ``` The first `resolve`/`reject` wins; the process exits immediately after. +### Detecting the webview + +A page — especially one loaded from a URL — can tell it's running inside +`webview` two ways: + +- **In JavaScript:** check for the injected object, e.g. + `if (window.webview) { … }`. `window.webview.version` disambiguates it from + any same-named global and tells you which build. +- **Server-side / before any JS:** the User-Agent is set to + `webview-cli/ (+https://github.com/just-be-dev/webview-cli)`, so a + server can detect the context and tailor the page on first byte. + ## Exit codes — this table _is_ the public API | Outcome | stdout | stderr | exit | diff --git a/src/bridge.rs b/src/bridge.rs index 6d2f131..52f9560 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -8,15 +8,20 @@ /// Injected before page load. Defines the one channel the page talks back on. /// +/// `version` carries the crate version so a remote page can both detect that +/// it's running inside webview (`window.webview` exists) and tell which build. /// `resolve` stringifies its argument (so the binary prints valid JSON, with /// `undefined`/no-arg becoming `null`); `reject` coerces to a string. The /// `"ok:"` / `"err:"` prefixes are the entire wire format. -pub const BRIDGE: &str = r#" - window.webview = { - resolve: (v) => window.ipc.postMessage("ok:" + JSON.stringify(v ?? null)), - reject: (e) => window.ipc.postMessage("err:" + String(e)), - }; -"#; +pub const BRIDGE: &str = concat!( + "\n window.webview = {\n", + " version: \"", + env!("CARGO_PKG_VERSION"), + "\",\n", + " resolve: (v) => window.ipc.postMessage(\"ok:\" + JSON.stringify(v ?? null)),\n", + " reject: (e) => window.ipc.postMessage(\"err:\" + String(e)),\n", + " };\n" +); /// What the event loop reacts to. `Resolve`/`Reject` carry the page's payload /// verbatim; `Timeout` and the window-close case carry nothing. diff --git a/src/run.rs b/src/run.rs index 3470fdb..da769ba 100644 --- a/src/run.rs +++ b/src/run.rs @@ -22,6 +22,15 @@ use crate::input::Load; /// The custom scheme file pages are served under (see `assets`). const FILE_SCHEME: &str = "wv"; +/// User-Agent we present to pages and remote servers. It lets a page detect +/// this context (server-side or before any JS runs) and tells which build. +/// wry replaces the UA wholesale — there's no append — so this *is* the UA. +const USER_AGENT: &str = concat!( + "webview-cli/", + env!("CARGO_PKG_VERSION"), + " (+https://github.com/just-be-dev/webview-cli)" +); + /// Exit codes — this table *is* the public API (see README). pub mod exit { /// page called `resolve(v)` — stdout carries the JSON. @@ -57,6 +66,7 @@ pub fn run(cli: &Cli, load: Load) -> ! { let ipc_proxy = proxy.clone(); let mut builder = WebViewBuilder::new() .with_initialization_script(BRIDGE) + .with_user_agent(USER_AGENT) .with_devtools(cli.devtools) .with_ipc_handler(move |req| { // `req.body()` is the verbatim string the page posted. We split the