use std::io::Read;
use std::net::TcpStream;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use crate::config::{Desktop, GUEST_USER, Gui};
use crate::ssh::quote;
pub const SWAY_PACKAGES: &[&str] = &["sway", "seatd", "wayvnc", "foot", "dejavu"];
pub fn sway_root_setup() -> String {
format!(
r#"sysrc seatd_enable=YES >/dev/null
if [ "$(sysrc -n seatd_env 2>/dev/null)" != "SEATD_VTBOUND=0" ]; then
sysrc seatd_env="SEATD_VTBOUND=0" >/dev/null
service seatd restart >/dev/null 2>&1 </dev/null
fi
service seatd status >/dev/null 2>&1 || service seatd start >/dev/null 2>&1 </dev/null
pw groupmod video -m {GUEST_USER}
"#
)
}
const RUNTIME_DIR: &str = "/tmp/bsdt-xdg";
pub fn start_script(gui: &Gui, dir: &str) -> String {
let mut script = format!("export XDG_RUNTIME_DIR={RUNTIME_DIR}\nmkdir -p -m 700 \"$XDG_RUNTIME_DIR\"\n");
match gui.desktop {
Desktop::Sway => {
script.push_str(&format!(
r#"cfg="$HOME/.config/sway/config"
if [ ! -e "$cfg" ] || head -n 1 "$cfg" | grep -q '^# Generated by bsdt'; then
mkdir -p "$HOME/.config/sway"
{{
echo '# Generated by bsdt from the stock sway config, with $mod set to {modifier}.'
echo '# Delete these two lines to keep your own edits; bsdt then leaves the file alone.'
sed 's/^set \$mod Mod4$/set $mod {modifier}/' /usr/local/etc/sway/config
}} > "$cfg"
fi
if ! pgrep -qxu {user} sway; then
rm -f "$XDG_RUNTIME_DIR"/*
env WLR_BACKENDS=headless,libinput WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 \
LIBSEAT_BACKEND=seatd daemon -f -o /tmp/bsdt-sway.log sway
fi
i=0
until SWAYSOCK=$(ls "$XDG_RUNTIME_DIR"/sway-ipc.*.sock 2>/dev/null | head -n 1) &&
[ -n "$SWAYSOCK" ] && swaymsg -s "$SWAYSOCK" -t get_version >/dev/null 2>&1; do
i=$((i + 1))
if [ $i -ge 100 ]; then
echo "sway did not start; /tmp/bsdt-sway.log says:" >&2
tail -n 20 /tmp/bsdt-sway.log >&2
exit 1
fi
sleep 0.2
done
export SWAYSOCK
export WAYLAND_DISPLAY=$(cd "$XDG_RUNTIME_DIR" && ls wayland-* | grep -v '\.lock$' | head -n 1)
swaymsg -q {mode}
pgrep -qxu {user} wayvnc || daemon -f -o /tmp/bsdt-wayvnc.log wayvnc 0.0.0.0 {vnc}
"#,
user = GUEST_USER,
modifier = gui.modifier.sway(),
mode = quote(&format!("output * mode {}", gui.resolution)),
vnc = gui.vnc,
));
if let Some(start) = gui.start() {
script.push_str(&format!("swaymsg -q exec {}\n", quote(&format!("cd {} && {start}", quote(dir)))));
}
}
Desktop::None => {
if let Some(start) = gui.start() {
script.push_str(&format!(
"cd {} && daemon -f -o /tmp/bsdt-gui.log sh -c {}\n",
quote(dir),
quote(start)
));
}
}
}
script
}
pub fn wait_vnc(port: u16, timeout: Duration) -> Result<()> {
let deadline = Instant::now() + timeout;
loop {
if let Ok(mut stream) = TcpStream::connect(("127.0.0.1", port)) {
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let mut greeting = [0; 4];
if stream.read_exact(&mut greeting).is_ok() && &greeting == b"RFB " {
return Ok(());
}
}
if Instant::now() > deadline {
bail!("no VNC server answered on 127.0.0.1:{port} within {}s", timeout.as_secs());
}
std::thread::sleep(Duration::from_millis(500));
}
}
pub fn open_viewer(port: u16, fullscreen: bool) -> Result<Option<String>> {
let url = format!("vnc://127.0.0.1:{port}");
let mut tiger = vec!["-FullscreenSystemKeys".to_string()];
if fullscreen {
tiger.push("-FullScreen".into());
}
tiger.push(format!("127.0.0.1::{port}"));
if cfg!(target_os = "macos") {
return match macos_app("TigerVNC") {
Some(app) => {
spawn(Command::new("open").arg("-a").arg(&app).arg("--args").args(&tiger))?;
Ok(Some(app.display().to_string()))
}
None => Ok(None),
};
}
let viewers: [(&str, Vec<String>); 4] = [
("vncviewer", tiger.clone()),
("xtigervncviewer", tiger),
("remmina", vec!["-c".into(), url.clone()]),
("krdc", vec![url.clone()]),
];
for (binary, args) in viewers {
if which(binary) {
spawn(Command::new(binary).args(args))?;
return Ok(Some(binary.into()));
}
}
if which("xdg-open") {
let status = Command::new("xdg-open").arg(&url).stdout(Stdio::null()).stderr(Stdio::null()).status()?;
if status.success() {
return Ok(Some("xdg-open".into()));
}
}
Ok(None)
}
fn spawn(cmd: &mut Command) -> Result<()> {
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn()?;
Ok(())
}
fn which(binary: &str) -> bool {
std::env::var_os("PATH")
.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(binary).is_file()))
}
fn macos_app(prefix: &str) -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Applications"));
[Some(PathBuf::from("/Applications")), home].into_iter().flatten().find_map(|dir| {
std::fs::read_dir(dir)
.ok()?
.filter_map(|e| e.ok())
.find(|e| e.file_name().to_string_lossy().starts_with(prefix))
.map(|e| e.path())
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn gui(extra: &str) -> Gui {
let config: Config =
toml::from_str(&format!("[vm]\nos = \"freebsd\"\nversion = \"15.1\"\ngui = true\n{extra}")).unwrap();
config.gui
}
#[test]
fn sway_script_starts_desktop_vnc_and_terminal() {
let script = start_script(&gui(""), "/home/bsdt/app");
assert!(script.contains("WLR_BACKENDS=headless,libinput"));
assert!(script.contains("swaymsg -q 'output * mode 1280x800'"));
assert!(script.contains("wayvnc 0.0.0.0 5900"));
assert!(script.contains("s/^set \\$mod Mod4$/set $mod Mod1/"));
assert!(script.contains(r#"swaymsg -q exec 'cd '\''/home/bsdt/app'\'' && foot'"#));
}
#[test]
fn custom_desktop_only_runs_start() {
let script = start_script(&gui("[gui]\ndesktop = \"none\"\nstart = \"Xvnc :0\""), "/w");
assert!(!script.contains("sway"));
assert!(script.contains("daemon -f -o /tmp/bsdt-gui.log sh -c 'Xvnc :0'"));
}
}