use crate::{AutoLaunch, Error, MacOSLaunchMode, Result};
use plist::{Dictionary, Value};
use smappservice_rs::{AppService, ServiceStatus, ServiceType};
use std::{
fs,
path::{Path, PathBuf},
process::{Command, Output},
};
impl AutoLaunch {
pub fn new(
app_name: &str,
app_path: &str,
launch_mode: MacOSLaunchMode,
args: &[impl AsRef<str>],
bundle_identifiers: &[impl AsRef<str>],
agent_extra_config: &str,
) -> AutoLaunch {
let mut name = app_name;
if launch_mode == MacOSLaunchMode::AppleScript {
let end = if app_path.ends_with(".app") { 4 } else { 0 };
let end = app_path.len() - end;
let begin = match app_path.rfind('/') {
Some(i) => i + 1,
None => 0,
};
name = &app_path[begin..end];
}
AutoLaunch {
app_name: name.into(),
app_path: app_path.into(),
launch_mode,
args: args.iter().map(|s| s.as_ref().to_string()).collect(),
bundle_identifiers: bundle_identifiers
.iter()
.map(|s| s.as_ref().to_string())
.collect(),
agent_extra_config: agent_extra_config.into(),
}
}
pub fn enable(&self) -> Result<()> {
if self.launch_mode == MacOSLaunchMode::SMAppService {
let app_service = AppService::new(ServiceType::MainApp);
match app_service.register() {
Ok(()) => return Ok(()),
Err(e) => return Err(Error::SMAppServiceRegistrationFailed(e.code())),
}
}
let path = Path::new(&self.app_path);
if !path.exists() {
return Err(Error::AppPathDoesntExist(path.to_path_buf()));
}
if !path.is_absolute() {
return Err(Error::AppPathIsNotAbsolute(path.to_path_buf()));
}
match self.launch_mode {
MacOSLaunchMode::LaunchAgentUser | MacOSLaunchMode::LaunchAgentSystem => self
.write_plist(build_launch_agent_plist(
&self.app_name,
&self.app_path,
&self.args,
&self.bundle_identifiers,
)),
MacOSLaunchMode::LaunchDaemonSystem => self.write_plist(build_launch_daemon_plist(
&self.app_name,
&self.app_path,
&self.args,
)),
MacOSLaunchMode::AppleScript => self.enable_applescript(),
MacOSLaunchMode::SMAppService => unreachable!("SMAppService mode handled above"),
}
}
fn write_plist(&self, dict: Dictionary) -> Result<()> {
let dir = get_dir(self.launch_mode)?;
if !dir.exists() {
fs::create_dir_all(&dir)?;
}
let file = self.get_file()?;
let f = fs::File::create(file)?;
plist::to_writer_xml(f, &Value::Dictionary(dict)).map_err(std::io::Error::other)?;
Ok(())
}
fn enable_applescript(&self) -> Result<()> {
let hidden = self
.args
.iter()
.find(|arg| *arg == "--hidden" || *arg == "--minimized");
let props = format!(
"{{name:\"{}\",path:\"{}\",hidden:{}}}",
self.app_name,
self.app_path,
hidden.is_some()
);
let command = format!("make login item at end with properties {props}");
let output = exec_apple_script(&command)?;
if !output.status.success() {
return Err(Error::AppleScriptFailed(output.status.code().unwrap_or(1)));
}
Ok(())
}
pub fn disable(&self) -> Result<()> {
match self.launch_mode {
MacOSLaunchMode::LaunchAgentUser
| MacOSLaunchMode::LaunchAgentSystem
| MacOSLaunchMode::LaunchDaemonSystem => self.disable_plist(),
MacOSLaunchMode::AppleScript => self.disable_applescript(),
MacOSLaunchMode::SMAppService => self.disable_smappservice(),
}
}
fn disable_smappservice(&self) -> Result<()> {
let app_service = AppService::new(ServiceType::MainApp);
match app_service.unregister() {
Ok(()) => Ok(()),
Err(e) => Err(Error::SMAppServiceUnregistrationFailed(e.code())),
}
}
fn disable_plist(&self) -> Result<()> {
let file = self.get_file()?;
if file.exists() {
fs::remove_file(file)?;
}
Ok(())
}
fn disable_applescript(&self) -> Result<()> {
let command = format!("delete login item \"{}\"", self.app_name);
let output = exec_apple_script(&command)?;
if !output.status.success() {
return Err(Error::AppleScriptFailed(output.status.code().unwrap_or(1)));
}
Ok(())
}
pub fn is_enabled(&self) -> Result<bool> {
match self.launch_mode {
MacOSLaunchMode::LaunchAgentUser
| MacOSLaunchMode::LaunchAgentSystem
| MacOSLaunchMode::LaunchDaemonSystem => Ok(self.get_file()?.exists()),
MacOSLaunchMode::AppleScript => self.is_applescript_enabled(),
MacOSLaunchMode::SMAppService => self.is_smappservice_enabled(),
}
}
fn is_smappservice_enabled(&self) -> Result<bool> {
let app_service = AppService::new(ServiceType::MainApp);
Ok(app_service.status() == ServiceStatus::Enabled)
}
fn is_applescript_enabled(&self) -> Result<bool> {
let command = "get the name of every login item";
let output = exec_apple_script(command)?;
let enable = if output.status.success() {
let stdout = std::str::from_utf8(&output.stdout).unwrap_or("");
stdout
.split(',')
.map(|x| x.trim())
.any(|x| x == self.app_name)
} else {
false
};
Ok(enable)
}
pub fn get_registered_app_path(&self) -> Result<Option<String>> {
match self.launch_mode {
MacOSLaunchMode::LaunchAgentUser
| MacOSLaunchMode::LaunchAgentSystem
| MacOSLaunchMode::LaunchDaemonSystem => {
let file = self.get_file()?;
if !file.exists() {
return Ok(None);
}
let value: Value = plist::from_file(&file).map_err(std::io::Error::other)?;
let path = value
.as_dictionary()
.and_then(|d| d.get("ProgramArguments"))
.and_then(|v| v.as_array())
.and_then(|args| args.first())
.and_then(|v| v.as_string())
.map(|s| s.to_string());
Ok(path)
}
MacOSLaunchMode::AppleScript | MacOSLaunchMode::SMAppService => Ok(None),
}
}
fn get_file(&self) -> Result<PathBuf> {
Ok(get_dir(self.launch_mode)?.join(format!("{}.plist", self.app_name)))
}
}
fn get_dir(mode: MacOSLaunchMode) -> Result<PathBuf> {
match mode {
MacOSLaunchMode::LaunchAgentUser => {
let home_dir = dirs::home_dir().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Failed to find home directory",
)
})?;
Ok(home_dir.join("Library").join("LaunchAgents"))
}
MacOSLaunchMode::LaunchAgentSystem => Ok(PathBuf::from("/Library/LaunchAgents")),
MacOSLaunchMode::LaunchDaemonSystem => Ok(PathBuf::from("/Library/LaunchDaemons")),
MacOSLaunchMode::AppleScript | MacOSLaunchMode::SMAppService => {
unreachable!("AppleScript/SMAppService do not use a plist directory")
}
}
}
fn exec_apple_script(cmd_suffix: &str) -> Result<Output> {
let command = format!("tell application \"System Events\" to {cmd_suffix}");
let output = Command::new("osascript")
.args(vec!["-e", &command])
.output()?;
Ok(output)
}
fn build_launch_agent_plist(
app_name: &str,
app_path: &str,
args: &[String],
bundle_identifiers: &[String],
) -> Dictionary {
let mut program_args: Vec<Value> = vec![Value::String(app_path.into())];
program_args.extend(args.iter().map(|a| Value::String(a.clone())));
let mut dict = Dictionary::new();
dict.insert("Label".into(), Value::String(app_name.into()));
if !bundle_identifiers.is_empty() {
let ids: Vec<Value> = bundle_identifiers
.iter()
.map(|id| Value::String(id.clone()))
.collect();
dict.insert("AssociatedBundleIdentifiers".into(), Value::Array(ids));
}
dict.insert("ProgramArguments".into(), Value::Array(program_args));
dict.insert("RunAtLoad".into(), Value::Boolean(true));
dict
}
fn build_launch_daemon_plist(app_name: &str, app_path: &str, args: &[String]) -> Dictionary {
let mut program_args: Vec<Value> = vec![Value::String(app_path.into())];
program_args.extend(args.iter().map(|a| Value::String(a.clone())));
let mut dict = Dictionary::new();
dict.insert("Label".into(), Value::String(app_name.into()));
dict.insert("ProgramArguments".into(), Value::Array(program_args));
dict.insert("RunAtLoad".into(), Value::Boolean(true));
dict.insert("SessionCreate".into(), Value::Boolean(true));
dict
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_launch_agent_plist() {
let dict = build_launch_agent_plist(
"TestApp",
"/Applications/TestApp.app",
&["--flag".into()],
&["com.example.testapp".into()],
);
let mut buf = Vec::new();
plist::to_writer_xml(&mut buf, &Value::Dictionary(dict)).unwrap();
let xml = String::from_utf8(buf).unwrap();
assert!(xml.contains("<string>TestApp</string>"));
assert!(xml.contains("AssociatedBundleIdentifiers"));
assert!(xml.contains("<string>com.example.testapp</string>"));
assert!(xml.contains("<string>/Applications/TestApp.app</string>"));
assert!(xml.contains("<string>--flag</string>"));
assert!(xml.contains("RunAtLoad"));
assert!(xml.contains("<true/>"));
assert!(!xml.contains("SessionCreate"));
}
#[test]
fn test_build_launch_daemon_plist() {
let dict = build_launch_daemon_plist(
"TestDaemon",
"/usr/local/bin/test-daemon",
&["--flag".into()],
);
let mut buf = Vec::new();
plist::to_writer_xml(&mut buf, &Value::Dictionary(dict)).unwrap();
let xml = String::from_utf8(buf).unwrap();
assert!(xml.contains("<string>TestDaemon</string>"));
assert!(xml.contains("<string>/usr/local/bin/test-daemon</string>"));
assert!(xml.contains("<string>--flag</string>"));
assert!(xml.contains("RunAtLoad"));
assert!(xml.contains("<true/>"));
assert!(!xml.contains("AssociatedBundleIdentifiers"));
assert!(xml.contains("SessionCreate"));
}
}