use std::path::Path;
use anyhow::Result;
const TRACE_TARGET: &str = "studio_worker::autostart";
pub const ENTRY_NAME: &str = "studio-worker-ui";
pub fn render_desktop_entry(exe: &str) -> String {
format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=studio-worker\n\
Comment=Pull-based generation worker for the minis.gg studio\n\
Exec={exe} ui\n\
Terminal=false\n\
X-GNOME-Autostart-enabled=true\n"
)
}
pub fn render_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\
<key>Label</key>\n\
<string>gg.minis.studio-worker-ui</string>\n\
<key>ProgramArguments</key>\n\
<array>\n\
<string>{exe}</string>\n\
<string>ui</string>\n\
</array>\n\
<key>RunAtLoad</key>\n\
<true/>\n\
</dict>\n\
</plist>\n"
)
}
pub fn autostart_command(exe: &Path) -> String {
format!("\"{}\" ui", exe.display())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutostartSync {
Enable,
Disable,
Noop,
}
pub fn launch_sync_action(auto_start: bool, currently_enabled: bool) -> AutostartSync {
match (auto_start, currently_enabled) {
(true, false) => AutostartSync::Enable,
(false, true) => AutostartSync::Disable,
_ => AutostartSync::Noop,
}
}
pub fn is_enabled() -> bool {
backend::is_enabled()
}
pub fn enable(exe: &Path) -> Result<()> {
backend::enable(exe)
}
pub fn disable() -> Result<()> {
backend::disable()
}
#[cfg(not(target_os = "windows"))]
mod backend {
use super::{ENTRY_NAME, TRACE_TARGET};
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
pub fn is_enabled() -> bool {
is_enabled_at(autostart_path().ok().as_deref())
}
pub fn enable(exe: &Path) -> Result<()> {
enable_at(&autostart_path()?, exe)
}
pub fn disable() -> Result<()> {
disable_at(&autostart_path()?)
}
#[cfg(target_os = "linux")]
pub(super) fn autostart_path() -> Result<PathBuf> {
let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("HOME not set"))?;
Ok(PathBuf::from(home)
.join(".config")
.join("autostart")
.join(format!("{ENTRY_NAME}.desktop")))
}
#[cfg(target_os = "macos")]
pub(super) fn autostart_path() -> Result<PathBuf> {
let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("HOME not set"))?;
Ok(PathBuf::from(home)
.join("Library")
.join("LaunchAgents")
.join(format!("gg.minis.{ENTRY_NAME}.plist")))
}
pub(super) fn is_enabled_at(path: Option<&Path>) -> bool {
path.map(|p| p.exists()).unwrap_or(false)
}
pub(super) fn enable_at(path: &Path, exe: &Path) -> Result<()> {
write_entry(path, &render_artefact(exe))
}
pub(super) fn disable_at(path: &Path) -> Result<()> {
remove_entry(path)
}
pub(super) fn render_artefact(exe: &Path) -> String {
let exe_str = exe.to_string_lossy().to_string();
#[cfg(target_os = "linux")]
{
super::render_desktop_entry(&exe_str)
}
#[cfg(target_os = "macos")]
{
super::render_launch_agent(&exe_str)
}
}
pub(super) fn write_entry(path: &Path, body: &str) -> Result<()> {
let result = (|| {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(path, body).with_context(|| format!("writing {}", path.display()))
})();
match &result {
Ok(()) => info!(
target: TRACE_TARGET,
op = "enable",
path = %path.display(),
bytes = body.len(),
"autostart-on-login enabled"
),
Err(e) => warn!(
target: TRACE_TARGET,
op = "enable",
path = %path.display(),
error = %e,
"failed to enable autostart-on-login"
),
}
result
}
pub(super) fn remove_entry(path: &Path) -> Result<()> {
if !path.exists() {
info!(
target: TRACE_TARGET,
op = "disable",
path = %path.display(),
"autostart-on-login already disabled"
);
return Ok(());
}
let result =
std::fs::remove_file(path).with_context(|| format!("removing {}", path.display()));
match &result {
Ok(()) => info!(
target: TRACE_TARGET,
op = "disable",
path = %path.display(),
"autostart-on-login disabled"
),
Err(e) => warn!(
target: TRACE_TARGET,
op = "disable",
path = %path.display(),
error = %e,
"failed to disable autostart-on-login"
),
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::capture;
use tempfile::tempdir;
#[test]
fn write_entry_creates_file_and_emits_enable_event() {
let dir = tempdir().unwrap();
let path = dir.path().join("autostart").join("entry.desktop");
let path_for_closure = path.clone();
let logs = capture(move || {
write_entry(&path_for_closure, "BODY").unwrap();
});
assert!(path.exists(), "entry should be written");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "BODY");
assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
assert!(
logs.contains("studio_worker::autostart"),
"expected autostart target, got: {logs}"
);
assert!(logs.contains("op=\"enable\""), "expected op field: {logs}");
assert!(
logs.contains("autostart-on-login enabled"),
"expected enable message: {logs}"
);
}
#[test]
fn remove_entry_deletes_file_and_emits_disable_event() {
let dir = tempdir().unwrap();
let path = dir.path().join("entry.desktop");
std::fs::write(&path, "BODY").unwrap();
let path_for_closure = path.clone();
let logs = capture(move || {
remove_entry(&path_for_closure).unwrap();
});
assert!(!path.exists(), "entry should be removed");
assert!(logs.contains("op=\"disable\""), "expected op field: {logs}");
assert!(
logs.contains("autostart-on-login disabled"),
"expected disable message: {logs}"
);
}
#[test]
fn remove_entry_is_idempotent_and_logs_already_disabled() {
let dir = tempdir().unwrap();
let path = dir.path().join("missing.desktop");
let path_for_closure = path.clone();
let logs = capture(move || {
remove_entry(&path_for_closure).unwrap();
});
assert!(
logs.contains("already disabled"),
"expected already-disabled message: {logs}"
);
}
#[test]
fn write_entry_failure_surfaces_error_and_emits_warn() {
let dir = tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, "x").unwrap();
let path = blocker.join("sub").join("entry.desktop");
let path_for_closure = path.clone();
let logs = capture(move || {
let err = write_entry(&path_for_closure, "BODY")
.expect_err("writing under a file should fail");
assert!(
err.to_string().contains("creating") || err.to_string().contains("writing"),
"unexpected error: {err}"
);
});
assert!(logs.contains("WARN"), "expected WARN event, got: {logs}");
assert!(
logs.contains("failed to enable autostart-on-login"),
"expected failure message: {logs}"
);
}
#[test]
fn enable_at_persists_rendered_artefact_and_disable_at_removes_it() {
let dir = tempdir().unwrap();
let path = dir
.path()
.join("autostart")
.join(format!("{ENTRY_NAME}.desktop"));
let exe = Path::new("/opt/studio-worker/studio-worker");
assert!(
!is_enabled_at(Some(&path)),
"a fresh tempdir must report autostart disabled"
);
enable_at(&path, exe).unwrap();
assert!(
is_enabled_at(Some(&path)),
"enable_at must create the artefact so is_enabled_at sees it"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
render_artefact(exe),
"enable_at must persist the platform artefact verbatim"
);
disable_at(&path).unwrap();
assert!(
!is_enabled_at(Some(&path)),
"disable_at must remove the artefact"
);
}
#[test]
fn is_enabled_at_reports_disabled_when_path_unresolved() {
assert!(!is_enabled_at(None));
}
#[test]
fn render_artefact_selects_the_platform_template() {
let exe = Path::new("/opt/studio-worker/studio-worker");
let body = render_artefact(exe);
#[cfg(target_os = "linux")]
assert_eq!(
body,
crate::autostart::render_desktop_entry("/opt/studio-worker/studio-worker")
);
#[cfg(target_os = "macos")]
assert_eq!(
body,
crate::autostart::render_launch_agent("/opt/studio-worker/studio-worker")
);
}
#[cfg(target_os = "linux")]
#[test]
fn autostart_path_targets_xdg_autostart_and_is_enabled_mirrors_it() {
let path = autostart_path().expect("HOME should be set in the test environment");
assert!(
path.ends_with(format!("{ENTRY_NAME}.desktop")),
"unexpected file name: {}",
path.display()
);
let parent = path.parent().expect("autostart path must have a parent");
assert!(
parent.ends_with(".config/autostart"),
"unexpected parent dir: {}",
parent.display()
);
assert_eq!(super::is_enabled(), path.exists());
}
}
}
#[cfg(target_os = "windows")]
mod backend {
use super::{autostart_command, ENTRY_NAME, TRACE_TARGET};
use anyhow::{Context, Result};
use std::path::Path;
use tracing::{info, warn};
use winreg::enums::{HKEY_CURRENT_USER, KEY_WRITE};
use winreg::RegKey;
const RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
pub fn is_enabled() -> bool {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
hkcu.open_subkey(RUN_KEY)
.and_then(|k| k.get_value::<String, _>(ENTRY_NAME))
.is_ok()
}
pub fn enable(exe: &Path) -> Result<()> {
let command = autostart_command(exe);
let result = (|| -> std::io::Result<()> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (run, _) = hkcu.create_subkey(RUN_KEY)?;
run.set_value(ENTRY_NAME, &command)
})();
match &result {
Ok(()) => info!(
target: TRACE_TARGET,
op = "enable",
value = ENTRY_NAME,
command = %command,
"autostart-on-login enabled (HKCU Run)"
),
Err(e) => warn!(
target: TRACE_TARGET,
op = "enable",
value = ENTRY_NAME,
error = %e,
"failed to enable autostart-on-login"
),
}
result.with_context(|| format!("writing HKCU Run value {ENTRY_NAME}"))
}
pub fn disable() -> Result<()> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let run = match hkcu.open_subkey_with_flags(RUN_KEY, KEY_WRITE) {
Ok(run) => run,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
info!(
target: TRACE_TARGET,
op = "disable",
"autostart-on-login already disabled (no Run key)"
);
return Ok(());
}
Err(e) => {
warn!(
target: TRACE_TARGET,
op = "disable",
error = %e,
"failed to open HKCU Run key"
);
return Err(e).context("opening HKCU Run key");
}
};
match run.delete_value(ENTRY_NAME) {
Ok(()) => {
info!(
target: TRACE_TARGET,
op = "disable",
value = ENTRY_NAME,
"autostart-on-login disabled (HKCU Run)"
);
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
info!(
target: TRACE_TARGET,
op = "disable",
value = ENTRY_NAME,
"autostart-on-login already disabled"
);
Ok(())
}
Err(e) => {
warn!(
target: TRACE_TARGET,
op = "disable",
value = ENTRY_NAME,
error = %e,
"failed to delete HKCU Run value"
);
Err(e).context("deleting HKCU Run value")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn enable_then_disable_round_trips_through_hkcu_run() {
let was_enabled = is_enabled();
let exe = Path::new(r"C:\Program Files\studio-worker\studio-worker.exe");
enable(exe).expect("enable should write the Run value");
assert!(is_enabled(), "enable must make is_enabled() true");
disable().expect("disable should remove the Run value");
assert!(!is_enabled(), "disable must make is_enabled() false");
disable().expect("second disable should be a no-op");
assert!(!was_enabled || !is_enabled());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_entry_contains_exec_and_name() {
let s = render_desktop_entry("/usr/local/bin/studio-worker");
assert!(s.contains("Exec=/usr/local/bin/studio-worker ui"));
assert!(s.contains("Name=studio-worker"));
assert!(s.contains("Type=Application"));
}
#[test]
fn launch_agent_is_valid_plist_with_args() {
let s = render_launch_agent("/usr/local/bin/studio-worker");
assert!(s.contains("<?xml"));
assert!(s.contains("<string>/usr/local/bin/studio-worker</string>"));
assert!(s.contains("<string>ui</string>"));
assert!(s.contains("gg.minis.studio-worker-ui"));
}
#[test]
fn autostart_command_quotes_exe_and_appends_ui() {
let cmd = autostart_command(Path::new("/opt/studio worker/studio-worker"));
assert_eq!(cmd, "\"/opt/studio worker/studio-worker\" ui");
}
#[test]
fn launch_sync_action_covers_every_combination() {
assert_eq!(launch_sync_action(true, false), AutostartSync::Enable);
assert_eq!(launch_sync_action(false, true), AutostartSync::Disable);
assert_eq!(launch_sync_action(true, true), AutostartSync::Noop);
assert_eq!(launch_sync_action(false, false), AutostartSync::Noop);
}
}