isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs
This commit is contained in:
1 parent
95c80ec6b1
commit
564aeb75f6
47 files changed
+5978
-366
No files matched your search
@@ -1,8 +1,17 @@
|
||||
[package]
|
||||
name = "isokit-cli"
|
||||
description = "isokit: isometric scene engine - Studio GUI, MCP server, build and export"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "isokit"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
isokit-project = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
@@ -1 +1,209 @@
|
||||
fn main() {}
|
||||
//! `isokit`: the command line.
|
||||
//!
|
||||
//! isokit new <dir> start a project
|
||||
//! isokit studio [dir] the GUI editor in the browser (also serves MCP at /mcp)
|
||||
//! isokit mcp [dir] MCP server over stdio for AI agents
|
||||
//! isokit build [dir] self-contained HTML page with every view
|
||||
//! isokit render [dir] -o x.svg one view as SVG
|
||||
//! isokit check [dir] lint the project
|
||||
//! isokit prefabs [dir] list the object types
|
||||
|
||||
mod serve;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use isokit_project::{check, page, Project};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "isokit",
|
||||
version,
|
||||
about = "Isometric scene engine: Studio GUI, MCP server, HTML/SVG export"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Create a project folder with a starter scene
|
||||
New {
|
||||
dir: PathBuf,
|
||||
#[arg(long)]
|
||||
title: Option<String>,
|
||||
},
|
||||
/// Open the Studio (GUI editor) in the browser
|
||||
Studio {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
#[arg(long, default_value_t = 7878)]
|
||||
port: u16,
|
||||
/// Do not open a browser
|
||||
#[arg(long)]
|
||||
no_open: bool,
|
||||
},
|
||||
/// Serve the MCP protocol on stdio (for AI agents)
|
||||
Mcp {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Build the self-contained HTML page
|
||||
Build {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
#[arg(short, long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Render one view as a standalone SVG
|
||||
Render {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
#[arg(short, long)]
|
||||
out: PathBuf,
|
||||
#[arg(long)]
|
||||
level: Option<String>,
|
||||
#[arg(long)]
|
||||
rot: Option<u8>,
|
||||
/// Light (day) colours instead of dark
|
||||
#[arg(long)]
|
||||
light: bool,
|
||||
},
|
||||
/// Lint the project (exit code 1 on errors)
|
||||
Check {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// List the object types (prefabs) and their properties
|
||||
Prefabs {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
fn load(dir: &PathBuf) -> Result<Project> {
|
||||
let p = Project::load(dir).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
if let Err(e) = &p.runtime {
|
||||
bail!("{e}");
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::New { dir, title } => {
|
||||
let title = title.unwrap_or_else(|| {
|
||||
dir.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or("Scene".into())
|
||||
});
|
||||
isokit_project::scaffold(&dir, &title).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
println!(
|
||||
"Created {} - open it with: isokit studio {}",
|
||||
dir.display(),
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
Cmd::Studio { dir, port, no_open } => serve::studio(&dir, port, !no_open)?,
|
||||
Cmd::Mcp { dir } => mcp_stdio(&dir)?,
|
||||
Cmd::Build { dir, out } => {
|
||||
let p = load(&dir)?;
|
||||
let out = out.unwrap_or_else(|| {
|
||||
dir.join(
|
||||
p.manifest
|
||||
.build
|
||||
.out
|
||||
.clone()
|
||||
.unwrap_or_else(|| "dist/index.html".into()),
|
||||
)
|
||||
});
|
||||
let html = page::static_page(&p).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
if let Some(d) = out.parent() {
|
||||
std::fs::create_dir_all(d).ok();
|
||||
}
|
||||
std::fs::write(&out, &html).with_context(|| out.display().to_string())?;
|
||||
let views = p.manifest.levels().len() * 4;
|
||||
println!(
|
||||
"{} ({} views, {} kB)",
|
||||
out.display(),
|
||||
views,
|
||||
html.len() / 1024
|
||||
);
|
||||
}
|
||||
Cmd::Render {
|
||||
dir,
|
||||
out,
|
||||
level,
|
||||
rot,
|
||||
light,
|
||||
} => {
|
||||
let p = load(&dir)?;
|
||||
let level = level.unwrap_or_else(|| p.manifest.default_level());
|
||||
let rot = rot.unwrap_or(p.manifest.default_rotation) % 4;
|
||||
let svg = page::svg(&p, &level, rot, light).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
std::fs::write(&out, svg).with_context(|| out.display().to_string())?;
|
||||
println!("{} ({level}, rotation {rot})", out.display());
|
||||
}
|
||||
Cmd::Check { dir } => {
|
||||
let p = Project::load(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let f = check::check(&p);
|
||||
for x in &f {
|
||||
println!(
|
||||
"{:7} {}{}",
|
||||
x.level,
|
||||
x.object
|
||||
.as_ref()
|
||||
.map(|o| format!("[{o}] "))
|
||||
.unwrap_or_default(),
|
||||
x.message
|
||||
);
|
||||
}
|
||||
let errors = f.iter().filter(|x| x.level == "error").count();
|
||||
println!("{} finding(s), {errors} error(s)", f.len());
|
||||
if errors > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Cmd::Prefabs { dir } => {
|
||||
let p = load(&dir)?;
|
||||
for m in p.catalog() {
|
||||
println!("{:12} {:24} {} [{}]", m.kind, m.label, m.category, m.source);
|
||||
for d in &m.props {
|
||||
println!(
|
||||
" {:10} {:8} default {} {}",
|
||||
d.key, d.kind, d.default, d.label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// MCP over stdio: one JSON-RPC message per line in, one per line out.
|
||||
fn mcp_stdio(dir: &PathBuf) -> Result<()> {
|
||||
let mut ws =
|
||||
isokit_project::workspace::Workspace::open(dir).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let stdin = std::io::stdin();
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in stdin.lock().lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let resp = match serde_json::from_str::<serde_json::Value>(&line) {
|
||||
Ok(msg) => isokit_project::mcp::handle(&mut ws, &msg),
|
||||
Err(e) => Some(
|
||||
serde_json::json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
|
||||
),
|
||||
};
|
||||
if let Some(r) = resp {
|
||||
writeln!(out, "{r}")?;
|
||||
out.flush()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! `isokit studio`: the GUI editor and its JSON API on localhost, plus the
|
||||
//! MCP endpoint (`POST /mcp`, JSON responses) so agents edit the same scene
|
||||
//! live. Single-threaded: one request at a time against one Workspace.
|
||||
|
||||
use anyhow::Result;
|
||||
use isokit_project::ops::Op;
|
||||
use isokit_project::workspace::Workspace;
|
||||
use isokit_project::{assets, check, mcp, page};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use tiny_http::{Header, Method, Request, Response, Server};
|
||||
|
||||
const VIEW_PAGE: &str = r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"><title>isokit preview</title>
|
||||
<script>(function(){var t=null;try{t=localStorage.getItem("isokit-theme")}catch(e){}if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",t);})();</script>
|
||||
<link rel="stylesheet" href="/assets/viewer/isokit-view.css"><link rel="stylesheet" href="/assets/theme.css">
|
||||
<style>:root{color-scheme:dark}html[data-theme=light]{color-scheme:light}body{margin:0;background:#0a0f1a;font:14px system-ui,sans-serif;color:#d6deeb}
|
||||
html[data-theme=light] body{background:#f4f6fa;color:#1b2533}main{max-width:1320px;margin:0 auto;padding:16px}</style></head>
|
||||
<body><main><div id="view"></div></main><script src="/assets/viewer/isokit-view.js"></script><script>
|
||||
fetch("/api/meta").then(function(r){return r.json()}).then(function(m){
|
||||
var meta={title:m.title,note:m.note,levels:m.levels,defaultLevel:m.default_level,defaultRotation:m.default_rotation};
|
||||
document.title=m.title+" · preview";
|
||||
var view=IsoKit.mount(document.getElementById("view"),IsoKit.httpSource("",meta),{});
|
||||
window.__isokit={view:view};
|
||||
var v=null;(function poll(){fetch("/api/version").then(function(r){return r.json()}).then(function(r){if(v!==null&&r.version!==v)view.refresh();v=r.version;}).catch(function(){}).then(function(){setTimeout(poll,1000)});})();
|
||||
});
|
||||
</script></body></html>"#;
|
||||
|
||||
fn header(k: &str, v: &str) -> Header {
|
||||
Header::from_bytes(k.as_bytes(), v.as_bytes()).expect("valid header")
|
||||
}
|
||||
|
||||
fn send(req: Request, status: u16, mime: &str, body: String) {
|
||||
let r = Response::from_string(body)
|
||||
.with_status_code(status)
|
||||
.with_header(header("Content-Type", mime))
|
||||
.with_header(header("Cache-Control", "no-store"));
|
||||
let _ = req.respond(r);
|
||||
}
|
||||
|
||||
fn send_json(req: Request, v: Value) {
|
||||
let status = if v.get("error").is_some() && v.get("jsonrpc").is_none() {
|
||||
400
|
||||
} else {
|
||||
200
|
||||
};
|
||||
send(
|
||||
req,
|
||||
status,
|
||||
"application/json; charset=utf-8",
|
||||
v.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
fn query(url: &str) -> Map<String, Value> {
|
||||
let mut m = Map::new();
|
||||
if let Some((_, q)) = url.split_once('?') {
|
||||
for kv in q.split('&') {
|
||||
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
m.insert(decode(k), Value::String(decode(v)));
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'+' => out.push(b' '),
|
||||
b'%' if i + 2 < b.len() => {
|
||||
if let Ok(v) =
|
||||
u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16)
|
||||
{
|
||||
out.push(v);
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(b'%');
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
}
|
||||
|
||||
/// Requests from a web page on another origin (DNS rebinding, a malicious
|
||||
/// site) must not edit the scene.
|
||||
fn origin_ok(req: &Request) -> bool {
|
||||
match req.headers().iter().find(|h| h.field.equiv("Origin")) {
|
||||
None => true,
|
||||
Some(h) => {
|
||||
let o = h.value.as_str();
|
||||
o == "null" || o.starts_with("http://127.0.0.1") || o.starts_with("http://localhost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state(ws: &Workspace) -> Value {
|
||||
json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() })
|
||||
}
|
||||
|
||||
fn meta(ws: &Workspace) -> Value {
|
||||
let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default();
|
||||
v["dir"] = json!(ws.dir.display().to_string());
|
||||
v["load_error"] = json!(ws.load_error);
|
||||
v["script_error"] = json!(ws.project.runtime.as_ref().err());
|
||||
v
|
||||
}
|
||||
|
||||
pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
|
||||
let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
||||
// Windows canonical paths carry a \?\ prefix that other tools do not accept
|
||||
let dir = std::path::PathBuf::from(
|
||||
dir.to_string_lossy()
|
||||
.trim_start_matches(r"\\?\")
|
||||
.to_string(),
|
||||
);
|
||||
let mut ws = Workspace::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let (server, port) = (port..port.saturating_add(20))
|
||||
.find_map(|p| Server::http(("127.0.0.1", p)).ok().map(|s| (s, p)))
|
||||
.ok_or_else(|| anyhow::anyhow!("no free port from {port}"))?;
|
||||
let url = format!("http://127.0.0.1:{port}/");
|
||||
println!("isokit Studio: {url}");
|
||||
println!(" project {}", dir.display());
|
||||
println!(" preview {url}view");
|
||||
println!(" MCP {url}mcp (or: isokit mcp {})", dir.display());
|
||||
if let Err(e) = &ws.project.runtime {
|
||||
println!(" script error: {e}");
|
||||
}
|
||||
if open {
|
||||
open_browser(&url);
|
||||
}
|
||||
for mut req in server.incoming_requests() {
|
||||
let url = req.url().to_string();
|
||||
let path = url.split('?').next().unwrap_or("/").to_string();
|
||||
let method = req.method().clone();
|
||||
let mut body = String::new();
|
||||
if method == Method::Post {
|
||||
if !origin_ok(&req) {
|
||||
send(
|
||||
req,
|
||||
403,
|
||||
"text/plain",
|
||||
"cross-origin request refused".into(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let _ = req.as_reader().take(16 << 20).read_to_string(&mut body);
|
||||
}
|
||||
let bodyv: Value = if body.trim().is_empty() {
|
||||
json!({})
|
||||
} else {
|
||||
serde_json::from_str(&body).unwrap_or(Value::Null)
|
||||
};
|
||||
match (&method, path.as_str()) {
|
||||
(Method::Get, "/") => send(
|
||||
req,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
assets::get("studio/index.html")
|
||||
.unwrap_or_default()
|
||||
.into_owned(),
|
||||
),
|
||||
(Method::Get, "/view") => send(req, 200, "text/html; charset=utf-8", VIEW_PAGE.into()),
|
||||
(Method::Get, "/assets/theme.css") => {
|
||||
ws.refresh();
|
||||
send(req, 200, "text/css; charset=utf-8", ws.project.theme.css())
|
||||
}
|
||||
(Method::Get, p) if p.starts_with("/assets/") => {
|
||||
let name = &p["/assets/".len()..];
|
||||
let mime = if name.ends_with(".js") {
|
||||
"text/javascript; charset=utf-8"
|
||||
} else if name.ends_with(".css") {
|
||||
"text/css; charset=utf-8"
|
||||
} else {
|
||||
"text/html; charset=utf-8"
|
||||
};
|
||||
match assets::get(name) {
|
||||
Some(s) => send(req, 200, mime, s.into_owned()),
|
||||
None => send(req, 404, "text/plain", "not found".into()),
|
||||
}
|
||||
}
|
||||
(Method::Get, "/api/version") => {
|
||||
ws.refresh();
|
||||
send_json(req, json!({ "version": ws.version.to_string() }))
|
||||
}
|
||||
(Method::Get, "/api/meta") => {
|
||||
ws.refresh();
|
||||
send_json(req, meta(&ws))
|
||||
}
|
||||
(Method::Get, "/api/doc") => {
|
||||
ws.refresh();
|
||||
send_json(req, state(&ws))
|
||||
}
|
||||
(Method::Get, "/api/render") => {
|
||||
ws.refresh();
|
||||
let q = query(&url);
|
||||
let p = &ws.project;
|
||||
let level = q
|
||||
.get("level")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| p.manifest.default_level());
|
||||
let rot = q
|
||||
.get("rot")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
let cfg = q
|
||||
.get("cfg")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| serde_json::from_str::<Map<String, Value>>(s).ok());
|
||||
let dev = q.get("dev").is_some();
|
||||
match p.render(&level, rot, cfg.as_ref(), dev) {
|
||||
Ok(v) => send_json(req, serde_json::to_value(v).unwrap_or_default()),
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
(Method::Get, "/api/check") => {
|
||||
ws.refresh();
|
||||
send_json(req, json!({ "findings": check::check(&ws.project) }))
|
||||
}
|
||||
(Method::Post, "/api/op") => {
|
||||
let r = if let Some(list) = bodyv.get("ops").and_then(|v| v.as_array()) {
|
||||
let ops: Result<Vec<Op>, String> = list
|
||||
.iter()
|
||||
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
|
||||
.collect();
|
||||
ops.and_then(|l| ws.apply_all(l))
|
||||
.map(|o| o.last().and_then(|x| x.id.clone()))
|
||||
} else {
|
||||
serde_json::from_value::<Op>(bodyv.clone())
|
||||
.map_err(|e| format!("bad operation: {e}"))
|
||||
.and_then(|op| ws.apply(op))
|
||||
.map(|o| o.id)
|
||||
};
|
||||
match r {
|
||||
Ok(id) => {
|
||||
let mut s = state(&ws);
|
||||
s["ok"] = json!(true);
|
||||
s["id"] = json!(id);
|
||||
send_json(req, s)
|
||||
}
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
(Method::Post, "/api/undo") | (Method::Post, "/api/redo") => {
|
||||
let r = if path.ends_with("undo") {
|
||||
ws.undo()
|
||||
} else {
|
||||
ws.redo()
|
||||
};
|
||||
match r {
|
||||
Ok(_) => send_json(req, state(&ws)),
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
(Method::Post, "/api/build") => {
|
||||
ws.refresh();
|
||||
let p = &ws.project;
|
||||
let out = p.dir.join(
|
||||
p.manifest
|
||||
.build
|
||||
.out
|
||||
.clone()
|
||||
.unwrap_or_else(|| "dist/index.html".into()),
|
||||
);
|
||||
let r = page::static_page(p)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|html| {
|
||||
if let Some(d) = out.parent() {
|
||||
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&out, html).map_err(|e| e.to_string())
|
||||
});
|
||||
match r {
|
||||
Ok(_) => send_json(req, json!({ "path": out.display().to_string() })),
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
(Method::Post, "/mcp") => {
|
||||
// a batch (array) or a single message; notifications get 202
|
||||
let resp = match &bodyv {
|
||||
Value::Array(list) => {
|
||||
let out: Vec<Value> = list
|
||||
.iter()
|
||||
.filter_map(|m| mcp::handle(&mut ws, m))
|
||||
.collect();
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Array(out))
|
||||
}
|
||||
}
|
||||
Value::Null => Some(
|
||||
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": "parse error" } }),
|
||||
),
|
||||
m => mcp::handle(&mut ws, m),
|
||||
};
|
||||
match resp {
|
||||
Some(v) => send(req, 200, "application/json", v.to_string()),
|
||||
None => {
|
||||
let _ = req.respond(Response::empty(202));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Method::Get, "/mcp") => send(
|
||||
req,
|
||||
405,
|
||||
"text/plain",
|
||||
"MCP: POST JSON-RPC messages here".into(),
|
||||
),
|
||||
_ => send(req, 404, "text/plain", "not found".into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_browser(url: &str) {
|
||||
#[cfg(windows)]
|
||||
let r = std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn();
|
||||
#[cfg(target_os = "macos")]
|
||||
let r = std::process::Command::new("open").arg(url).spawn();
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let r = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
if r.is_err() {
|
||||
println!(" open {url} in a browser");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user