///|
/// What `open` did with a URL.
pub(all) enum Outcome {
/// A browser opener was started; the browser takes it from there. The opener
/// is not waited on, so this does not prove a window appeared.
Launched
/// Deliberately not attempted, and why: `"over SSH"` (the browser would open
/// on the remote machine) or `"without a display"` (a Linux session with no
/// graphical display). Pass `force=true` to try anyway.
Skipped(String)
/// No opener could be started; the last error.
Failed(String)
} derive(Eq, Debug)
///|
/// The commands that would open `url` in the default browser on `platform`,
/// in the order to try them, or why not to try at all.
///
/// - `$BROWSER`, when set, wins: a list of commands separated by `:` (`;` on
/// Windows), tried in order. `%s` in a command is replaced by the URL;
/// otherwise the URL is appended as the last argument.
/// - Over SSH (`SSH_CONNECTION` or `SSH_TTY` set) nothing is tried, since the
/// browser would open on the remote machine; `force=true` overrides this.
/// - macOS: `open `. On Linux `open` can be a different program
/// (`openvt`), so it is never used there.
/// - Windows: `rundll32 url.dll,FileProtocolHandler `, which takes the URL
/// as one argument with no shell in between, so `?`, `&` and `#` are safe.
/// - Linux under WSL: `wslview `, then `explorer.exe `, both of which
/// open the Windows browser.
/// - Other Linux: `xdg-open ` when there is a display (`DISPLAY` or
/// `WAYLAND_DISPLAY`); without one nothing is tried unless `force=true`.
///
/// Blank environment values count as unset. This function only decides; it
/// starts nothing, which makes it easy to test or to show to a user.
pub fn commands(
platform : @types.Platform,
url : String,
env~ : Map[String, String],
force? : Bool = false,
) -> Result[Array[Array[String]], String] {
let set = (name : String) => env.get(name) is Some(value) && !value.is_blank()
if env.get("BROWSER") is Some(browser) && !browser.is_blank() {
let separator = match platform {
Windows => ";"
_ => ":"
}
let candidates = []
for entry in browser.split(separator) {
let words = entry
.split(" ")
.filter(word => !word.is_empty())
.map(word => word.to_owned())
.to_array()
if words.is_empty() {
continue
}
if words.any(word => word.contains("%s")) {
candidates.push(words.map(word => word.replace_all(old="%s", new=url)))
} else {
candidates.push([..words, url])
}
}
if !candidates.is_empty() {
return Ok(candidates)
}
}
if !force && (set("SSH_CONNECTION") || set("SSH_TTY")) {
return Err("over SSH")
}
match platform {
MacOS => Ok([["open", url]])
Windows => Ok([["rundll32", "url.dll,FileProtocolHandler", url]])
Linux =>
if set("WSL_DISTRO_NAME") || set("WSL_INTEROP") {
Ok([["wslview", url], ["explorer.exe", url]])
} else if force || set("DISPLAY") || set("WAYLAND_DISPLAY") {
Ok([["xdg-open", url]])
} else {
Err("without a display")
}
}
}
///|
/// Open `url` in the default browser, best effort, without blocking.
///
/// The commands from `commands` are tried in order until one starts. Each is
/// started detached and never waited on (some `xdg-open` setups run the
/// browser itself in the foreground), with its output sent to the null device
/// so nothing lands on a terminal the caller may own, such as a TUI. A missing
/// command moves on to the next; the result says what happened.
///
/// `env` defaults to the process environment; pass one to decide from another.
///
/// ```moonbit nocheck
/// match @open_in_browser.open("http://127.0.0.1:8080/") {
/// Launched => println("opened it in your browser")
/// Skipped(why) => println("not opening a browser \{why}")
/// Failed(error) => println("could not open a browser: \{error}")
/// }
/// ```
pub async fn open(
url : String,
force? : Bool = false,
env? : Map[String, String],
) -> Outcome {
let env = match env {
Some(env) => env
None => @env.get_env_vars()
}
match commands(@async.platform, url, env~, force~) {
Err(reason) => Skipped(reason)
Ok(candidates) => {
let mut last = "no command to run"
for command in candidates {
match launch(command) {
Ok(_) => return Launched
Err(error) => last = error
}
}
Failed(last)
}
}
}
///|
/// Start `command` detached with its output discarded.
async fn launch(command : Array[String]) -> Result[Unit, String] {
let null_device = match @async.platform {
Windows => "NUL"
_ => "/dev/null"
}
try {
let sink = @process.redirect_to_file(null_device)
let _ = @process.spawn_orphan(
command[0],
command[1:],
stdout=sink,
stderr=sink,
)
Ok(())
} catch {
@os_error.OSError(_) as error if error.is_ENOENT() =>
Err("`\{command[0]}` not found")
error => Err("\{error}")
}
}