use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Windows,
MacOs,
Systemd,
}
impl Kind {
pub fn here() -> Self {
if cfg!(windows) {
Self::Windows
} else if cfg!(target_os = "macos") {
Self::MacOs
} else {
Self::Systemd
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Plan {
pub files: Vec<(PathBuf, String)>,
pub commands: Vec<Vec<String>>,
pub remove: Vec<PathBuf>,
pub note: String,
}
const LABEL: &str = "com.qatlashub.ssh-browser";
fn startup_dir(home: &Path) -> PathBuf {
std::env::var_os("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData").join("Roaming"))
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs")
.join("Startup")
}
fn entry_path(kind: Kind, home: &Path, state: &Path) -> PathBuf {
let _ = state;
match kind {
Kind::Windows => startup_dir(home).join("ssh-browser.vbs"),
Kind::MacOs => home
.join("Library")
.join("LaunchAgents")
.join(format!("{LABEL}.plist")),
Kind::Systemd => home
.join(".config")
.join("systemd")
.join("user")
.join("ssh-browser.service"),
}
}
pub fn install_plan(kind: Kind, exe: &Path, home: &Path, state: &Path) -> Plan {
let entry = entry_path(kind, home, state);
let exe = exe.display().to_string();
match kind {
Kind::Windows => Plan {
files: vec![(
entry.clone(),
format!("CreateObject(\"WScript.Shell\").Run \"\"\"{exe}\"\" serve\", 0, False\n"),
)],
commands: Vec::new(),
remove: Vec::new(),
note: format!(
"ssh-browser will start when you log in.\n\
\x20 {}\n\n\
Undo with `ssh-browser autostart --off`, or delete that file.\n",
entry.display()
),
},
Kind::MacOs => Plan {
files: vec![(entry.clone(), launch_agent(&exe))],
commands: vec![vec![
"launchctl".into(),
"bootstrap".into(),
format!("gui/{}", users_uid()),
entry.display().to_string(),
]],
remove: Vec::new(),
note: format!(
"ssh-browser will start when you log in.\n\
\x20 agent {}\n\n\
Undo with `ssh-browser autostart --off`.\n",
entry.display()
),
},
Kind::Systemd => Plan {
files: vec![(entry.clone(), user_unit(&exe))],
commands: vec![
vec!["systemctl".into(), "--user".into(), "daemon-reload".into()],
vec![
"systemctl".into(),
"--user".into(),
"enable".into(),
"--now".into(),
"ssh-browser.service".into(),
],
],
remove: Vec::new(),
note: format!(
"ssh-browser will start when you log in.\n\
\x20 unit {}\n\n\
On a machine you reach over ssh rather than log into, a user unit stops when\n\
your last session ends. `loginctl enable-linger` is what keeps it running.\n\n\
Undo with `ssh-browser autostart --off`.\n",
entry.display()
),
},
}
}
pub fn remove_plan(kind: Kind, home: &Path, state: &Path) -> Plan {
let entry = entry_path(kind, home, state);
let note = "ssh-browser will no longer start when you log in. One running now keeps\n\
running; stop it however you started it.\n"
.to_string();
match kind {
Kind::Windows => Plan {
files: Vec::new(),
commands: Vec::new(),
remove: vec![entry],
note,
},
Kind::MacOs => Plan {
files: Vec::new(),
commands: vec![vec![
"launchctl".into(),
"bootout".into(),
format!("gui/{}/{LABEL}", users_uid()),
]],
remove: vec![entry],
note,
},
Kind::Systemd => Plan {
files: Vec::new(),
commands: vec![vec![
"systemctl".into(),
"--user".into(),
"disable".into(),
"--now".into(),
"ssh-browser.service".into(),
]],
remove: vec![entry],
note,
},
}
}
fn users_uid() -> String {
std::process::Command::new("id")
.arg("-u")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "501".to_string())
}
fn launch_agent(exe: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\x20 <key>Label</key>\n\
\x20 <string>{LABEL}</string>\n\
\x20 <key>ProgramArguments</key>\n\
\x20 <array>\n\
\x20 <string>{exe}</string>\n\
\x20 <string>serve</string>\n\
\x20 </array>\n\
\x20 <key>RunAtLoad</key>\n\
\x20 <true/>\n\
\x20 <key>KeepAlive</key>\n\
\x20 <true/>\n\
</dict>\n\
</plist>\n"
)
}
fn user_unit(exe: &str) -> String {
format!(
"[Unit]\n\
Description=ssh-browser, serving SSH hosts as browser origins\n\
\n\
[Service]\n\
ExecStart={exe} serve\n\
Restart=on-failure\n\
\n\
[Install]\n\
WantedBy=default.target\n"
)
}
pub fn apply(plan: &Plan) -> Result<()> {
for (path, body) in &plan.files {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("making {}", parent.display()))?;
}
std::fs::write(path, body).with_context(|| format!("writing {}", path.display()))?;
eprintln!(" wrote {}", path.display());
}
for command in &plan.commands {
let (program, args) = command.split_first().expect("a command has a program");
eprintln!(" {}", command.join(" "));
let out = std::process::Command::new(program)
.args(args)
.output()
.with_context(|| format!("running {program}"))?;
if !out.status.success() {
let said = [out.stdout, out.stderr]
.iter()
.map(|s| String::from_utf8_lossy(s).trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n");
bail!("{program} failed: {said}");
}
}
for path in &plan.remove {
match std::fs::remove_file(path) {
Ok(()) => eprintln!(" removed {}", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("removing {}", path.display())),
}
}
Ok(())
}
pub fn home() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.filter(|h| !h.is_empty())
.map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
fn dirs() -> (PathBuf, PathBuf) {
(PathBuf::from("/home/you"), PathBuf::from("/state"))
}
const EVERY: [Kind; 3] = [Kind::Windows, Kind::MacOs, Kind::Systemd];
#[test]
fn each_platform_writes_an_entry_naming_the_daemon() {
let (home, state) = dirs();
let exe = PathBuf::from("/bin/ssh-browser");
for kind in EVERY {
let plan = install_plan(kind, &exe, &home, &state);
assert_eq!(plan.files.len(), 1, "{kind:?}");
assert!(
plan.remove.is_empty(),
"{kind:?} installs, it does not delete"
);
let (_, body) = &plan.files[0];
assert!(body.contains("/bin/ssh-browser"), "{kind:?}: {body}");
assert!(body.contains("serve"), "{kind:?}: {body}");
assert!(plan.note.contains("autostart --off"), "{kind:?}");
}
}
#[test]
fn removing_touches_what_installing_wrote() {
let (home, state) = dirs();
let exe = PathBuf::from("/bin/ssh-browser");
for kind in EVERY {
let installed = install_plan(kind, &exe, &home, &state);
let removed = remove_plan(kind, &home, &state);
assert_eq!(
removed.remove,
vec![installed.files[0].0.clone()],
"{kind:?}"
);
assert!(removed.files.is_empty(), "{kind:?}");
}
}
#[test]
fn the_windows_launcher_asks_for_no_window() {
let (home, state) = dirs();
let plan = install_plan(
Kind::Windows,
&PathBuf::from("C:/bin/ssh-browser.exe"),
&home,
&state,
);
let (path, body) = &plan.files[0];
assert!(
path.ends_with("Start Menu/Programs/Startup/ssh-browser.vbs")
|| path.ends_with(r"Start Menu\Programs\Startup\ssh-browser.vbs"),
"{path:?}"
);
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
assert!(
body.contains(", 0, False"),
"the window style must be hidden: {body}"
);
assert!(body.contains("\"\"\"C:/bin/ssh-browser.exe\"\""), "{body}");
}
#[test]
fn the_launch_agent_runs_at_load() {
let (home, state) = dirs();
let plan = install_plan(
Kind::MacOs,
&PathBuf::from("/bin/ssh-browser"),
&home,
&state,
);
let (path, body) = &plan.files[0];
assert!(
path.starts_with("/home/you/Library/LaunchAgents"),
"{path:?}"
);
assert!(body.starts_with("<?xml"), "{body}");
assert!(body.contains("<key>RunAtLoad</key>\n <true/>"), "{body}");
assert!(body.contains(LABEL), "{body}");
}
#[test]
fn the_user_unit_is_wanted_by_default_target() {
let (home, state) = dirs();
let plan = install_plan(
Kind::Systemd,
&PathBuf::from("/bin/ssh-browser"),
&home,
&state,
);
let (path, body) = &plan.files[0];
assert!(
path.starts_with("/home/you/.config/systemd/user"),
"{path:?}"
);
assert!(body.contains("WantedBy=default.target"), "{body}");
assert!(body.contains("ExecStart=/bin/ssh-browser serve"), "{body}");
}
#[test]
fn installing_again_is_not_an_error() {
let (home, state) = dirs();
for kind in EVERY {
let plan = install_plan(kind, &PathBuf::from("/bin/x"), &home, &state);
for command in &plan.commands {
let line = command.join(" ");
let forgiving = line.contains("daemon-reload")
|| line.contains("enable")
|| line.contains("bootstrap");
assert!(
forgiving,
"{kind:?} runs something that may refuse twice: {line}"
);
}
}
}
}