use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
static WEBVIEW_BIN: OnceLock<PathBuf> = OnceLock::new();
const WEBVIEW_BIN_ENV: &str = "A3S_WEBVIEW_BIN";
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ViewSpec {
pub url: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub embeddable: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OpenedWith {
Webview,
Browser,
}
pub(crate) fn find_view_url(output: &str, origin: Option<&str>) -> Option<ViewSpec> {
serde_json::Deserializer::from_str(output)
.into_iter::<serde_json::Value>()
.flatten()
.filter_map(|v| find_in(&v, origin))
.last()
}
fn absolutize(url: &str, origin: Option<&str>) -> Option<String> {
if url.starts_with("http://") || url.starts_with("https://") {
Some(url.to_string())
} else if url.starts_with('/') {
origin.map(|o| format!("{}{}", o.trim_end_matches('/'), url))
} else {
None
}
}
fn find_in(value: &serde_json::Value, origin: Option<&str>) -> Option<ViewSpec> {
match value {
serde_json::Value::Object(obj) => {
if let Some(spec) = obj.get("view").and_then(|v| parse_view_object(v, origin)) {
return Some(spec);
}
if let Some(spec) = parse_legacy_view_url(obj, origin) {
return Some(spec);
}
obj.values().find_map(|v| find_in(v, origin))
}
serde_json::Value::Array(arr) => arr.iter().find_map(|v| find_in(v, origin)),
_ => None,
}
}
fn px(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<u32> {
obj.get(key)
.and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f.round() as u64)))
.map(|n| n as u32)
}
fn parse_view_object(v: &serde_json::Value, origin: Option<&str>) -> Option<ViewSpec> {
let obj = v.as_object()?;
let url = obj.get("url").and_then(|u| u.as_str())?;
Some(ViewSpec {
url: absolutize(url, origin)?,
width: px(obj, "width"),
height: px(obj, "height"),
embeddable: true,
})
}
fn parse_legacy_view_url(
obj: &serde_json::Map<String, serde_json::Value>,
origin: Option<&str>,
) -> Option<ViewSpec> {
let url = obj.get("viewUrl").and_then(|u| u.as_str())?;
let url = absolutize(url, origin)?;
let size = obj.get("viewSize").and_then(|s| s.as_object());
let width = size.and_then(|s| px(s, "width"));
let height = size.and_then(|s| px(s, "height"));
let embeddable = obj
.get("embeddable")
.and_then(|e| e.as_bool())
.unwrap_or(false)
|| width.is_some();
Some(ViewSpec {
url,
width,
height,
embeddable,
})
}
fn webview_binary_name() -> &'static str {
if cfg!(windows) {
"a3s-webview.exe"
} else {
"a3s-webview"
}
}
fn executable_path(path: &Path) -> Option<PathBuf> {
if path.is_file() {
Some(path.to_path_buf())
} else {
None
}
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.map(|dir| dir.join(name))
.find_map(|path| executable_path(&path))
}
fn env_webview_override() -> Option<PathBuf> {
let raw = std::env::var_os(WEBVIEW_BIN_ENV)?;
if raw.is_empty() {
None
} else {
Some(PathBuf::from(raw))
}
}
fn dev_webview_candidates(manifest_dir: &Path, name: &str) -> Vec<PathBuf> {
vec![
manifest_dir.join("target/debug").join(name),
manifest_dir.join("target/release").join(name),
manifest_dir.join("../webview/target/debug").join(name),
manifest_dir.join("../webview/target/release").join(name),
manifest_dir.join("../../target/debug").join(name),
manifest_dir.join("../../target/release").join(name),
]
}
fn find_dev_webview(name: &str) -> Option<PathBuf> {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
dev_webview_candidates(manifest_dir, name)
.into_iter()
.find_map(|path| executable_path(&path))
}
fn find_existing_webview() -> Option<PathBuf> {
let name = webview_binary_name();
if let Some(path) = env_webview_override() {
return Some(path);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(sibling) = exe.parent().map(|d| d.join(name)) {
if let Some(path) = executable_path(&sibling) {
return Some(path);
}
}
}
if let Some(path) = find_dev_webview(name) {
return Some(path);
}
find_on_path(name)
}
pub(crate) fn webview_helper_path() -> Option<PathBuf> {
find_existing_webview().filter(|path| executable_path(path).is_some())
}
fn resolve_webview_bin() -> PathBuf {
find_existing_webview().unwrap_or_else(|| PathBuf::from(webview_binary_name()))
}
fn webview_bin() -> &'static PathBuf {
WEBVIEW_BIN.get_or_init(resolve_webview_bin)
}
pub(crate) fn prime_webview_lookup() {
let _ = webview_bin();
}
fn webview_args(spec: &ViewSpec) -> Vec<String> {
let mut args = vec![
"--url".to_string(),
spec.url.clone(),
"--title".to_string(),
"A3S RemoteUI".to_string(),
];
if let Some(w) = spec.width {
args.push("--width".to_string());
args.push(w.to_string());
}
if let Some(h) = spec.height {
args.push("--height".to_string());
args.push(h.to_string());
}
args
}
pub(crate) fn open_window(spec: &ViewSpec) -> std::io::Result<OpenedWith> {
Command::new(webview_bin())
.args(webview_args(spec))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_child| OpenedWith::Webview)
.or_else(|webview_error| {
open_in_browser(&spec.url)
.map(|()| OpenedWith::Browser)
.map_err(|browser_error| {
std::io::Error::new(
browser_error.kind(),
format!(
"a3s-webview failed: {webview_error}; browser fallback failed: {browser_error}"
),
)
})
})
}
fn browser_open_command(url: &str) -> (&'static str, Vec<String>) {
if cfg!(target_os = "macos") {
("open", vec![url.to_string()])
} else if cfg!(windows) {
(
"cmd",
vec![
"/C".to_string(),
"start".to_string(),
String::new(),
url.to_string(),
],
)
} else {
("xdg-open", vec![url.to_string()])
}
}
fn open_in_browser(url: &str) -> std::io::Result<()> {
let (program, args) = browser_open_command(url);
Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_child| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_top_level_view_url() {
let out = r#"{"success":true,"viewUrl":"https://os.x/p","data":{"items":[]}}"#;
let s = find_view_url(out, None).unwrap();
assert_eq!(s.url, "https://os.x/p");
assert!(!s.embeddable); }
#[test]
fn finds_nested_view_url_with_size_marks_embeddable() {
let out =
r#"{"data":{"viewUrl":"https://os.x/embed","viewSize":{"width":720,"height":520}}}"#;
let s = find_view_url(out, None).unwrap();
assert_eq!((s.width, s.height), (Some(720), Some(520)));
assert!(s.embeddable); }
#[test]
fn embeddable_flag_without_size() {
let out = r#"{"viewUrl":"https://os.x/p","embeddable":true}"#;
assert!(find_view_url(out, None).unwrap().embeddable);
}
#[test]
fn finds_view_object_marks_embeddable() {
let out = r#"{"success":true,"view":{"url":"https://os.x/p?embed=1","width":720,"height":520},"modules":[]}"#;
let s = find_view_url(out, None).unwrap();
assert_eq!(s.url, "https://os.x/p?embed=1");
assert_eq!((s.width, s.height), (Some(720), Some(520)));
assert!(s.embeddable); }
#[test]
fn finds_nested_view_object() {
let out = r#"{"data":{"view":{"url":"https://os.x/embed","width":400,"height":300}}}"#;
assert_eq!(find_view_url(out, None).unwrap().width, Some(400));
}
#[test]
fn view_object_takes_precedence_over_legacy_url() {
let out = r#"{"viewUrl":"https://old/x","view":{"url":"https://new/y","width":300,"height":200}}"#;
assert_eq!(find_view_url(out, None).unwrap().url, "https://new/y");
}
#[test]
fn relative_view_url_is_absolutized_against_origin() {
let out = r#"{"success":true,"view":{"url":"/admin/kernel/assets?embed=1","width":1440,"height":900}}"#;
let s = find_view_url(out, Some("https://os.example.com/")).unwrap();
assert_eq!(s.url, "https://os.example.com/admin/kernel/assets?embed=1"); assert!(s.embeddable);
}
#[test]
fn last_view_wins_across_concatenated_json_docs() {
let out = r#"{"success":true,"modules":[]}
{"success":true,"view":{"url":"/admin/assets/a1?embed=1","width":1024,"height":768}}"#;
let s = find_view_url(out, Some("https://os.x")).unwrap();
assert_eq!(s.url, "https://os.x/admin/assets/a1?embed=1");
}
#[test]
fn relative_view_url_without_origin_is_dropped() {
let out = r#"{"view":{"url":"/admin/kernel/assets?embed=1","width":10,"height":10}}"#;
assert!(find_view_url(out, None).is_none());
}
#[test]
fn ignores_non_http_and_absent() {
assert!(find_view_url(r#"{"viewUrl":"file:///x"}"#, None).is_none());
assert!(find_view_url(
r#"{"view":{"url":"file:///x","width":10,"height":10}}"#,
None
)
.is_none());
assert!(find_view_url(r#"{"data":{"items":[1,2]}}"#, None).is_none());
assert!(find_view_url("not json", None).is_none());
}
#[test]
fn webview_args_pass_url_and_size() {
let spec = ViewSpec {
url: "https://os.x/p?embed=1".into(),
width: Some(720),
height: Some(520),
embeddable: true,
};
assert_eq!(
webview_args(&spec),
vec![
"--url",
"https://os.x/p?embed=1",
"--title",
"A3S RemoteUI",
"--width",
"720",
"--height",
"520"
]
);
let no_size = ViewSpec {
url: "https://os.x/p".into(),
width: None,
height: None,
embeddable: false,
};
assert_eq!(
webview_args(&no_size),
vec!["--url", "https://os.x/p", "--title", "A3S RemoteUI"]
);
}
#[test]
fn browser_fallback_command_tracks_platform() {
let (program, args) = browser_open_command("https://os.x/p?embed=1");
if cfg!(target_os = "macos") {
assert_eq!(program, "open");
assert_eq!(args, vec!["https://os.x/p?embed=1"]);
} else if cfg!(windows) {
assert_eq!(program, "cmd");
assert_eq!(args, vec!["/C", "start", "", "https://os.x/p?embed=1"]);
} else {
assert_eq!(program, "xdg-open");
assert_eq!(args, vec!["https://os.x/p?embed=1"]);
}
}
#[test]
fn dev_webview_candidates_include_cli_and_sibling_webview_targets() {
let root = Path::new("/repo/crates/cli");
let candidates = dev_webview_candidates(root, "a3s-webview");
assert!(candidates.contains(&PathBuf::from("/repo/crates/cli/target/debug/a3s-webview")));
assert!(candidates.contains(&PathBuf::from(
"/repo/crates/cli/../webview/target/debug/a3s-webview"
)));
assert!(candidates.contains(&PathBuf::from(
"/repo/crates/cli/../../target/release/a3s-webview"
)));
}
#[test]
fn webview_binary_name_tracks_platform() {
if cfg!(windows) {
assert_eq!(webview_binary_name(), "a3s-webview.exe");
} else {
assert_eq!(webview_binary_name(), "a3s-webview");
}
}
#[test]
fn webview_lookup_can_be_primed_and_reused() {
prime_webview_lookup();
let first = webview_bin().clone();
prime_webview_lookup();
assert_eq!(webview_bin(), &first);
assert!(!first.as_os_str().to_string_lossy().is_empty());
}
#[test]
fn progressive_api_view_flows_to_webview_args() {
let resp = r#"{"success":true,
"view":{"url":"/admin/kernel/assets?embed=1","width":900,"height":680},
"data":{"items":[]}}"#;
let spec =
find_view_url(resp, Some("https://os.example.com")).expect("view object should parse");
assert!(spec.embeddable); let args = webview_args(&spec);
assert_eq!(args[0], "--url");
assert_eq!(
args[1],
"https://os.example.com/admin/kernel/assets?embed=1"
);
assert!(args.contains(&"900".to_string()) && args.contains(&"680".to_string()));
}
}