use std::process::{Command, Stdio};
pub fn exe(name: &str) -> String {
if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_string()
}
}
pub fn find_on_path(name: &str) -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH")?;
let names: Vec<String> = if cfg!(windows) {
let ext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into());
let mut v = vec![name.to_string()];
v.extend(
ext.split(';')
.filter(|e| !e.is_empty())
.map(|e| format!("{name}{}", e.to_ascii_lowercase())),
);
v
} else {
vec![name.to_string()]
};
std::env::split_paths(&path)
.filter(|d| !d.as_os_str().is_empty())
.flat_map(|d| names.iter().map(move |n| d.join(n)))
.find(|p| p.is_file())
}
pub fn has_display() -> bool {
if cfg!(not(target_os = "linux")) {
return true;
}
std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some()
}
pub fn open_url(url: &str) -> bool {
#[cfg(target_os = "windows")]
{
let safe: String = url
.chars()
.filter(|c| *c != '"' && *c != '\n' && *c != '\r')
.collect();
return Command::new("cmd")
.arg("/C")
.raw_arg(format!("start \"\" \"{safe}\""))
.creation_flags(CREATE_NO_WINDOW)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok();
}
#[cfg(not(target_os = "windows"))]
{
for opener in ["xdg-open", "open"] {
if Command::new(opener)
.arg(url)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
{
return true;
}
}
false
}
}
pub fn app_mode_browsers() -> Vec<String> {
if cfg!(target_os = "windows") {
let mut out = vec![];
for base in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] {
let Some(dir) = std::env::var_os(base) else {
continue;
};
let dir = std::path::PathBuf::from(dir);
for rel in [
r"Microsoft\Edge\Application\msedge.exe",
r"Google\Chrome\Application\chrome.exe",
r"BraveSoftware\Brave-Browser\Application\brave.exe",
] {
let p = dir.join(rel);
if p.is_file() {
out.push(p.to_string_lossy().into_owned());
}
}
}
out
} else if cfg!(target_os = "macos") {
let mut out = vec![];
for (bundle, exe) in [
("Google Chrome.app", "Google Chrome"),
("Chromium.app", "Chromium"),
("Brave Browser.app", "Brave Browser"),
("Microsoft Edge.app", "Microsoft Edge"),
] {
for app in app_bundles(bundle) {
let p = app.join("Contents/MacOS").join(exe);
if p.is_file() {
out.push(p.to_string_lossy().into_owned());
}
}
}
out
} else {
[
"chromium",
"chromium-browser",
"google-chrome",
"brave-browser",
"microsoft-edge",
]
.iter()
.map(|s| s.to_string())
.collect()
}
}
pub fn app_bundles(name: &str) -> Vec<std::path::PathBuf> {
if cfg!(not(target_os = "macos")) {
return vec![];
}
let mut out = vec![std::path::PathBuf::from("/Applications").join(name)];
if let Some(home) = dirs::home_dir() {
out.push(home.join("Applications").join(name));
}
out.into_iter().filter(|p| p.is_dir()).collect()
}
pub fn open_terminal(dir: &std::path::Path) -> bool {
if !has_display() {
return false;
}
for args in terminals(dir) {
let Some((program, rest)) = args.split_first() else {
continue;
};
let mut cmd = Command::new(program);
cmd.args(rest)
.current_dir(dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
if cmd.spawn().is_ok() {
return true;
}
}
false
}
fn terminals(dir: &std::path::Path) -> Vec<Vec<String>> {
let d = dir.to_string_lossy().into_owned();
let mut out: Vec<Vec<String>> = vec![];
let _ = &d;
#[cfg(target_os = "windows")]
{
out.push(vec!["wt.exe".into(), "-d".into(), d.clone()]);
out.push(vec![
"cmd.exe".into(),
"/C".into(),
"start".into(),
String::new(),
"cmd.exe".into(),
]);
}
#[cfg(target_os = "macos")]
out.push(vec![
"open".into(),
"-a".into(),
"Terminal".into(),
d.clone(),
]);
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
if let Some(t) = std::env::var_os("TERMINAL") {
let t = t.to_string_lossy().into_owned();
if !t.trim().is_empty() {
out.push(vec![t]);
}
}
for (name, flag) in [
("gnome-terminal", Some("--working-directory")),
("konsole", Some("--workdir")),
("xfce4-terminal", Some("--working-directory")),
("alacritty", Some("--working-directory")),
("kitty", Some("--directory")),
("foot", Some("--working-directory")),
("ptyxis", Some("--working-directory")),
("wezterm", None),
("x-terminal-emulator", None),
] {
let mut argv = vec![name.to_string()];
if name == "wezterm" {
argv.extend(["start".to_string(), "--cwd".to_string(), d.clone()]);
} else if let Some(f) = flag {
argv.extend([f.to_string(), d.clone()]);
}
out.push(argv);
}
}
out
}
pub fn shim(name: &str) -> Command {
#[cfg(target_os = "windows")]
{
let mut cmd = Command::new("cmd");
cmd.arg("/C").arg(name);
cmd
}
#[cfg(not(target_os = "windows"))]
Command::new(name)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Sound {
Asked,
Declined,
Unsaid,
}
const BURST: std::time::Duration = std::time::Duration::from_secs(2);
fn sound() -> Sound {
static LAST: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
let want = match std::env::var("SNYVI_SOUND").as_deref() {
Ok("1") | Ok("true") | Ok("yes") => Some(true),
Ok("0") | Ok("false") | Ok("no") | Ok("") => Some(false),
_ => None,
};
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
sound_for(want, &mut last, std::time::Instant::now())
}
fn sound_for(
want: Option<bool>,
last: &mut Option<std::time::Instant>,
now: std::time::Instant,
) -> Sound {
match want {
None => Sound::Unsaid,
Some(false) => Sound::Declined,
Some(true) => {
if last.is_some_and(|t| now.duration_since(t) < BURST) {
Sound::Declined
} else {
*last = Some(now);
Sound::Asked
}
}
}
}
#[allow(unused_variables)]
fn notify_with(title: &str, body: &str, sound: Sound) {
#[cfg(target_os = "windows")]
{
let script = format!(
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null;\
$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(5);\
$t = $x.GetElementsByTagName('text');\
$t.Item(0).AppendChild($x.CreateTextNode('{}')) > $null;\
$t.Item(1).AppendChild($x.CreateTextNode('{}')) > $null;{}\
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}}\\WindowsPowerShell\\v1.0\\powershell.exe').Show([Windows.UI.Notifications.ToastNotification]::new($x))",
ps_quote(title),
ps_quote(body),
if sound == Sound::Declined {
"$a = $x.CreateElement('audio'); $a.SetAttribute('silent', 'true'); $x.DocumentElement.AppendChild($a) > $null;"
} else {
""
},
);
let _ = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.creation_flags(CREATE_NO_WINDOW)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
#[cfg(target_os = "macos")]
{
let script = format!(
"display notification \"{}\" with title \"{}\"{}",
body.replace('\\', "\\\\").replace('"', "\\\""),
title.replace('\\', "\\\\").replace('"', "\\\""),
if sound == Sound::Asked {
" sound name \"Glass\""
} else {
""
},
);
let _ = Command::new("osascript")
.args(["-e", &script])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
let _ = Command::new("notify-send")
.args(["-a", "snyvi", "-i", "snyvi"])
.args(sound_hint(sound))
.args([title, body])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
pub fn notify_open(title: &str, body: &str, open: impl FnOnce() + Send + 'static) {
let sound = sound();
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
if notify_send_takes_actions() {
let mut child = match Command::new("notify-send")
.args([
"-a",
"snyvi",
"-i",
"snyvi",
"-A",
"default=Open",
"-t",
"20000",
])
.args(sound_hint(sound))
.args([title, body])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(_) => {
notify_with(title, body, sound);
return;
}
};
let out = child.stdout.take();
let id = child.id();
if let Some(mut old) = replace_clickable(Some(child)) {
let _ = old.kill();
let _ = old.wait();
}
std::thread::spawn(move || {
use std::io::Read;
let mut said = String::new();
if let Some(mut out) = out {
let _ = out.read_to_string(&mut said);
}
if let Some(mut mine) = take_clickable(id) {
let _ = mine.wait();
}
if said.trim() == "default" {
open();
}
});
return;
}
}
let _ = &open;
notify_with(title, body, sound);
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
fn sound_hint(sound: Sound) -> &'static [&'static str] {
if sound == Sound::Asked {
&["-h", "string:sound-name:message-new-instant"]
} else {
&[]
}
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
static CLICKABLE: std::sync::Mutex<Option<std::process::Child>> = std::sync::Mutex::new(None);
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
fn replace_clickable(next: Option<std::process::Child>) -> Option<std::process::Child> {
let mut slot = CLICKABLE.lock().unwrap_or_else(|e| e.into_inner());
std::mem::replace(&mut slot, next)
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
fn take_clickable(id: u32) -> Option<std::process::Child> {
let mut slot = CLICKABLE.lock().unwrap_or_else(|e| e.into_inner());
if slot.as_ref().map(std::process::Child::id) == Some(id) {
slot.take()
} else {
None
}
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
fn notify_send_takes_actions() -> bool {
static OK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OK.get_or_init(|| {
Command::new("notify-send")
.arg("--help")
.output()
.map(|o| {
let text = String::from_utf8_lossy(&o.stdout).into_owned()
+ &String::from_utf8_lossy(&o.stderr);
text.contains("--action")
})
.unwrap_or(false)
})
}
#[cfg(target_os = "windows")]
fn ps_quote(s: &str) -> String {
s.replace('\'', "''").replace(['\n', '\r'], " ")
}
pub fn terminate(pid: u32, force: bool) {
#[cfg(target_os = "windows")]
{
let mut cmd = Command::new("taskkill");
cmd.arg("/PID").arg(pid.to_string());
if force {
cmd.arg("/F");
}
let _ = cmd
.creation_flags(CREATE_NO_WINDOW)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(not(target_os = "windows"))]
{
let _ = Command::new("kill")
.arg(if force { "-KILL" } else { "-TERM" })
.arg(pid.to_string())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
pub fn spawn_daemon(exe: &std::path::Path) -> std::io::Result<()> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
CreateProcessW, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, DETACHED_PROCESS,
PROCESS_INFORMATION, STARTUPINFOW,
};
let mut line: Vec<u16> = vec![b'"' as u16];
line.extend(exe.as_os_str().encode_wide().filter(|c| *c != b'"' as u16));
line.push(b'"' as u16);
line.extend(" serve".encode_utf16());
line.push(0);
let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() };
si.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
let started = unsafe {
CreateProcessW(
std::ptr::null(),
line.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
0, DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW,
std::ptr::null(),
std::ptr::null(),
&si,
&mut pi,
)
};
if started == 0 {
return Err(std::io::Error::last_os_error());
}
unsafe {
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
return Ok(());
}
#[cfg(not(windows))]
{
let mut cmd = Command::new(exe);
cmd.arg("serve")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd.spawn()?;
Ok(())
}
}
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
#[cfg(test)]
mod tests {
use super::{sound_for, terminals, Sound};
use std::path::Path;
#[test]
fn a_terminal_is_asked_for_a_directory_and_never_for_a_command() {
let dir = Path::new("/tmp/a folder");
let candidates = terminals(dir);
assert!(!candidates.is_empty(), "no terminal is ever tried");
for argv in &candidates {
let (_program, rest) = argv.split_first().expect("a candidate with no program");
for arg in rest {
let allowed = arg.starts_with('-') || arg.starts_with('/') || arg.is_empty() || arg == "start" || arg == "cmd.exe" || arg == "Terminal" || *arg == dir.to_string_lossy();
assert!(
allowed,
"{argv:?} passes {arg:?}, which is neither a flag nor the directory"
);
}
}
}
#[test]
fn a_burst_sounds_once_and_only_when_asked() {
use std::time::{Duration, Instant};
let t0 = Instant::now();
let mut last = None;
assert_eq!(sound_for(None, &mut last, t0), Sound::Unsaid);
assert_eq!(sound_for(Some(false), &mut last, t0), Sound::Declined);
assert!(
last.is_none(),
"declining or saying nothing spent the burst"
);
assert_eq!(sound_for(Some(true), &mut last, t0), Sound::Asked);
for ms in [1, 500, 1999] {
assert_eq!(
sound_for(Some(true), &mut last, t0 + Duration::from_millis(ms)),
Sound::Declined,
"a second sound {ms} ms into the burst"
);
}
assert_eq!(
sound_for(Some(true), &mut last, t0 + Duration::from_millis(2000)),
Sound::Asked
);
}
#[test]
fn a_flag_never_arrives_without_the_directory_it_is_for() {
let dir = Path::new("/tmp/a folder");
for argv in terminals(dir) {
let takes_dir = argv.iter().any(|a| *a == dir.to_string_lossy());
let has_flag = argv[1..].iter().any(|a| a.starts_with('-'));
assert!(
!has_flag || takes_dir,
"{argv:?} passes a flag but never the directory"
);
}
}
}