Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 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.
- `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/<version>` User-Agent so a server can detect the webview
context before any JavaScript runs.

## [0.1.0]

### Added

- 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`,
`--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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[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."
description = "A webview CLI: render HTML, get one JSON result back, exit."
authors = ["Justin Bennett <oss@just-be.dev>"]
license = "MIT"
repository = "https://github.com/just-be-dev/webview-cli"
Expand Down
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ On Linux you need the WebKitGTK headers to build (or to run the binary):
```bash
echo '<html>…</html>' | 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
Expand All @@ -73,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/<version> (+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 |
Expand Down Expand Up @@ -157,7 +172,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) |
Expand Down
2 changes: 1 addition & 1 deletion src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 11 additions & 6 deletions src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 6 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
///
Expand All @@ -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<std::path::PathBuf>,

/// Window title.
Expand Down
61 changes: 59 additions & 2 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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.
Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -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("<h1>piped</h1>".to_string())
});
assert_eq!(got, Ok(Load::Html("<h1>piped</h1>".to_string())));
}
}
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -68,6 +78,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.
Expand Down
Loading