use std::ffi::OsStr;
use std::fs;
use std::mem::size_of;
use std::os::windows::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Com::{
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx,
CoUninitialize, IPersistFile,
};
use windows::Win32::System::Threading::{
GetProcessId, OpenProcess, PROCESS_TERMINATE, TerminateProcess,
};
use windows::Win32::UI::Shell::{
IShellLinkW, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW, ShellLink,
};
use windows::Win32::UI::WindowsAndMessaging::SW_HIDE;
use windows::core::{Interface, PCWSTR};
use crate::config::dirs::config_dir;
const FLOW_LNK: &str = "flow.lnk";
pub fn startup_dir() -> Result<PathBuf, String> {
let appdata = std::env::var("APPDATA")
.map_err(|_| "%APPDATA% is not set; cannot resolve shell:startup".to_string())?;
Ok(PathBuf::from(appdata)
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs")
.join("Startup"))
}
#[derive(Debug, Clone)]
pub struct AutostartReport {
pub shortcut: PathBuf,
}
pub fn enable_autostart(ahk: bool) -> Result<AutostartReport, String> {
if ahk {
let script = flow_ahk_path();
if !script.exists() {
return Err(format!(
"flow.ahk not found at {}; run `flow config init --ahk` to create it first",
script.display()
));
}
}
let exe = flow_exe_path()?;
let working_dir = exe.parent().map(Path::to_path_buf);
let args = if ahk { "start --ahk" } else { "start" };
let link = shortcut_path()?;
create_shortcut(&link, &exe, Some(args), working_dir.as_deref())?;
Ok(AutostartReport { shortcut: link })
}
pub fn disable_autostart() -> Result<AutostartReport, String> {
let link = shortcut_path()?;
match fs::remove_file(&link) {
Ok(()) => Ok(AutostartReport { shortcut: link }),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(AutostartReport { shortcut: link })
}
Err(e) => Err(format!("failed to remove {}: {e}", link.display())),
}
}
pub fn spawn_ahk_script() -> Result<u32, String> {
let script = flow_ahk_path();
if !script.exists() {
return Err(format!(
"flow.ahk not found at {}; run `flow config init --ahk` to create it",
script.display()
));
}
let script_w = Wide::from_os(script.as_os_str());
let verb_w = Wide::from_str("open");
let mut info = SHELLEXECUTEINFOW {
cbSize: size_of::<SHELLEXECUTEINFOW>() as u32,
fMask: SEE_MASK_NOCLOSEPROCESS,
lpVerb: verb_w.pcwstr(),
lpFile: script_w.pcwstr(),
nShow: SW_HIDE.0,
..Default::default()
};
unsafe {
ShellExecuteExW(&mut info).map_err(|e| format!("ShellExecuteExW failed: {e}"))?;
}
if info.hProcess.is_invalid() {
return Err(
"ShellExecuteExW returned no process handle (is AutoHotkey installed?)".to_string(),
);
}
let pid = unsafe { GetProcessId(info.hProcess) };
let _ = unsafe { CloseHandle(info.hProcess) };
if pid == 0 {
return Err("GetProcessId returned 0 after launching flow.ahk".to_string());
}
let pidfile = ahk_pidfile_path();
if let Err(e) = fs::write(&pidfile, pid.to_string()) {
log::warn!("failed to write AHK pidfile {}: {e}", pidfile.display());
}
Ok(pid)
}
pub fn stop_ahk_script() -> Result<bool, String> {
let pidfile = ahk_pidfile_path();
let pid_str = match fs::read_to_string(&pidfile) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(format!("failed to read {}: {e}", pidfile.display())),
};
let result = match pid_str.trim().parse::<u32>() {
Ok(pid) => kill_process(pid),
Err(_) => Ok(false), };
if let Err(e) = fs::remove_file(&pidfile)
&& e.kind() != std::io::ErrorKind::NotFound
{
log::warn!("failed to remove AHK pidfile {}: {e}", pidfile.display());
}
result
}
fn shortcut_path() -> Result<PathBuf, String> {
Ok(startup_dir()?.join(FLOW_LNK))
}
fn flow_exe_path() -> Result<PathBuf, String> {
std::env::current_exe().map_err(|e| format!("cannot determine current executable: {e}"))
}
fn flow_ahk_path() -> PathBuf {
config_dir().join("flow.ahk")
}
fn ahk_pidfile_path() -> PathBuf {
config_dir().join("flow.ahk.pid")
}
fn kill_process(pid: u32) -> Result<bool, String> {
let handle = match unsafe { OpenProcess(PROCESS_TERMINATE, false, pid) } {
Ok(h) => h,
Err(_) => return Ok(false), };
let result = unsafe { TerminateProcess(handle, 1) }
.map(|()| true)
.map_err(|e| format!("TerminateProcess failed: {e}"));
let _ = unsafe { CloseHandle(handle) };
result
}
struct Wide {
buf: Vec<u16>,
}
impl Wide {
fn from_os(s: &OsStr) -> Self {
Self {
buf: s.encode_wide().chain(std::iter::once(0)).collect(),
}
}
fn from_str(s: &str) -> Self {
Self::from_os(OsStr::new(s))
}
fn pcwstr(&self) -> PCWSTR {
PCWSTR::from_raw(self.buf.as_ptr())
}
}
fn create_shortcut(
link_path: &Path,
target: &Path,
args: Option<&str>,
working_dir: Option<&Path>,
) -> Result<(), String> {
let target_w = Wide::from_os(target.as_os_str());
let args_w = args.map(Wide::from_str);
let work_w = working_dir.map(|p| Wide::from_os(p.as_os_str()));
let link_w = Wide::from_os(link_path.as_os_str());
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
if hr.is_err() {
return Err(format!("CoInitializeEx failed: {hr}"));
}
let result = (|| -> windows::core::Result<()> {
let link: IShellLinkW =
unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)? };
unsafe { link.SetPath(target_w.pcwstr())? };
if let Some(a) = &args_w {
unsafe { link.SetArguments(a.pcwstr())? };
}
if let Some(w) = &work_w {
unsafe { link.SetWorkingDirectory(w.pcwstr())? };
}
unsafe { link.SetShowCmd(SW_HIDE)? };
let persist: IPersistFile = link.cast()?;
unsafe { persist.Save(link_w.pcwstr(), true)? };
Ok(())
})();
unsafe { CoUninitialize() };
result.map_err(|e| format!("failed to create shortcut: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
appdata: Option<String>,
config_dir: Option<String>,
}
impl EnvGuard {
fn capture() -> Self {
Self {
appdata: std::env::var("APPDATA").ok(),
config_dir: std::env::var("FLOW_CONFIG_DIR").ok(),
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.appdata.take() {
Some(v) => unsafe { std::env::set_var("APPDATA", v) },
None => unsafe { std::env::remove_var("APPDATA") },
}
match self.config_dir.take() {
Some(v) => unsafe { std::env::set_var("FLOW_CONFIG_DIR", v) },
None => unsafe { std::env::remove_var("FLOW_CONFIG_DIR") },
}
}
}
fn make_startup_tree() -> (tempfile::TempDir, PathBuf, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let appdata = tmp.path().join("AppData");
let startup = appdata
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs")
.join("Startup");
fs::create_dir_all(&startup).unwrap();
let cfg = tmp.path().join("config").join("flow");
fs::create_dir_all(&cfg).unwrap();
unsafe { std::env::set_var("APPDATA", &appdata) };
unsafe { std::env::set_var("FLOW_CONFIG_DIR", &cfg) };
(tmp, startup, cfg)
}
#[test]
fn startup_dir_ends_with_startup_when_appdata_set() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
unsafe { std::env::set_var("APPDATA", "C:\\fake_roaming") };
let dir = startup_dir().expect("APPDATA is set");
assert!(dir.ends_with("Startup"), "dir was: {dir:?}");
assert!(
dir.to_string_lossy().contains("Start Menu"),
"dir was: {dir:?}"
);
}
#[test]
fn startup_dir_errors_without_appdata() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
unsafe { std::env::remove_var("APPDATA") };
let result = startup_dir();
assert!(result.is_err(), "should error when APPDATA is unset");
}
#[test]
fn ahk_pidfile_path_is_stable_name_in_config_dir() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
let (_tmp, _startup, cfg) = make_startup_tree();
let path = ahk_pidfile_path();
assert_eq!(path, cfg.join("flow.ahk.pid"));
}
#[test]
fn flow_ahk_path_points_at_config_dir_script() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
let (_tmp, _startup, cfg) = make_startup_tree();
let path = flow_ahk_path();
assert_eq!(path, cfg.join("flow.ahk"));
}
#[test]
fn shortcut_filename_is_stable() {
assert_eq!(FLOW_LNK, "flow.lnk");
}
#[test]
fn disable_autostart_no_op_when_shortcut_missing() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
let (_tmp, startup, _cfg) = make_startup_tree();
let report = disable_autostart().expect("missing shortcut is a no-op");
assert_eq!(report.shortcut, startup.join(FLOW_LNK));
assert!(!report.shortcut.exists());
}
#[test]
fn disable_autostart_removes_existing_shortcut() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
let (_tmp, startup, _cfg) = make_startup_tree();
let link = startup.join(FLOW_LNK);
fs::write(&link, b"placeholder").unwrap();
assert!(link.exists());
let report = disable_autostart().expect("removing existing shortcut");
assert!(!link.exists(), "shortcut should be removed");
assert_eq!(report.shortcut, link);
}
#[test]
fn enable_autostart_with_ahk_errors_when_script_missing() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
let (_tmp, startup, _cfg) = make_startup_tree();
let result = enable_autostart(true);
let err = result.expect_err("should error when flow.ahk missing");
assert!(
err.contains("flow.ahk not found"),
"error should mention flow.ahk: {err}"
);
assert!(
err.contains("flow config init --ahk"),
"error should suggest the fix: {err}"
);
assert!(
!startup.join(FLOW_LNK).exists(),
"no shortcut should be left behind"
);
}
#[test]
fn stop_ahk_script_returns_false_when_pidfile_missing() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
let (_tmp, _startup, _cfg) = make_startup_tree();
let result = stop_ahk_script().expect("missing pidfile is a no-op");
assert!(!result, "should report nothing was stopped");
}
#[test]
fn stop_ahk_script_handles_corrupt_pidfile() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
let (_tmp, _startup, cfg) = make_startup_tree();
let pidfile = cfg.join("flow.ahk.pid");
fs::write(&pidfile, "not-a-number").unwrap();
let result = stop_ahk_script().expect("corrupt pidfile is a no-op");
assert!(!result, "should report nothing was stopped");
assert!(!pidfile.exists(), "corrupt pidfile should be removed");
}
#[test]
fn stop_ahk_script_errors_when_pidfile_unreadable() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
let (_tmp, _startup, cfg) = make_startup_tree();
let pidfile = cfg.join("flow.ahk.pid");
fs::create_dir(&pidfile).expect("create dir as pidfile");
let result = stop_ahk_script();
assert!(
result.is_err(),
"unreadable pidfile should surface an error, got: {result:?}"
);
let err = result.unwrap_err();
assert!(
err.contains("flow.ahk.pid"),
"error should reference the pidfile path: {err}"
);
}
#[test]
fn disable_autostart_errors_when_appdata_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
unsafe { std::env::remove_var("APPDATA") };
let result = disable_autostart();
let err = result.expect_err("should error when APPDATA is unset");
assert!(
err.contains("%APPDATA%"),
"error should mention %APPDATA%: {err}"
);
}
#[test]
fn enable_autostart_without_ahk_errors_when_appdata_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::capture();
unsafe { std::env::remove_var("APPDATA") };
let result = enable_autostart(false);
let err = result.expect_err("should error when APPDATA is unset");
assert!(
err.contains("%APPDATA%"),
"error should mention %APPDATA%: {err}"
);
}
#[test]
fn wide_from_str_ascii_encodes_with_null_terminator() {
let w = Wide::from_str("ABC");
assert_eq!(w.buf, vec![0x41, 0x42, 0x43, 0x00]);
}
#[test]
fn wide_from_str_empty_yields_only_null_terminator() {
let w = Wide::from_str("");
assert_eq!(w.buf, vec![0x00]);
}
#[test]
fn wide_from_str_unicode_bmp_encodes_as_single_code_unit() {
let w = Wide::from_str("café");
assert_eq!(w.buf, vec![0x63, 0x61, 0x66, 0xE9, 0x00]);
}
#[test]
fn wide_from_str_supplemental_plane_encodes_as_surrogate_pair() {
let w = Wide::from_str("\u{1F600}");
assert_eq!(w.buf, vec![0xD83D, 0xDE00, 0x00]);
}
#[test]
fn wide_from_os_matches_from_str_for_ascii() {
let s = "hello";
let from_os = Wide::from_os(OsStr::new(s));
let from_str = Wide::from_str(s);
assert_eq!(from_os.buf, from_str.buf);
}
#[test]
fn wide_pcwstr_points_into_buffer_with_null_terminator() {
let w = Wide::from_str("hi");
let ptr = w.pcwstr();
assert!(!ptr.is_null(), "PCWSTR must not be null");
let round_trip = unsafe { ptr.to_string() }.expect("valid UTF-16");
assert_eq!(round_trip, "hi", "PCWSTR should round-trip the input");
}
}