use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LoginItemStatus {
NotRegistered,
Enabled,
RequiresApproval,
NotFound,
Unknown(i64),
}
impl LoginItemStatus {
pub fn is_enabled(self) -> bool {
self == Self::Enabled
}
pub fn needs_approval(self) -> bool {
self == Self::RequiresApproval
}
}
pub fn login_item_status_from_raw(raw: i64) -> LoginItemStatus {
match raw {
0 => LoginItemStatus::NotRegistered,
1 => LoginItemStatus::Enabled,
2 => LoginItemStatus::RequiresApproval,
3 => LoginItemStatus::NotFound,
other => LoginItemStatus::Unknown(other),
}
}
pub fn executable_in_app_bundle(exe: &std::path::Path) -> bool {
exe.to_string_lossy().contains(".app/Contents/MacOS/")
}
pub fn service_management_selected(
host_wants_it: bool,
class_available: bool,
in_bundle: bool,
redirected: bool,
) -> bool {
host_wants_it && class_available && in_bundle && !redirected
}
pub fn register_result(
call: Result<(), String>,
status_after: LoginItemStatus,
) -> Result<LoginItemStatus, String> {
match (call, status_after) {
(Ok(()), s) => Ok(s),
(Err(_), s @ (LoginItemStatus::Enabled | LoginItemStatus::RequiresApproval)) => Ok(s),
(Err(e), _) => Err(e),
}
}
pub fn unregister_result(
call: Result<(), String>,
status_after: LoginItemStatus,
) -> Result<LoginItemStatus, String> {
match (call, status_after) {
(Ok(()), s) => Ok(s),
(Err(_), s @ (LoginItemStatus::NotRegistered | LoginItemStatus::NotFound)) => Ok(s),
(Err(e), _) => Err(e),
}
}
#[cfg(target_os = "macos")]
mod mac {
use objc2::msg_send;
use objc2::rc::{autoreleasepool, Retained};
use objc2::runtime::{AnyClass, AnyObject, Bool};
use objc2_foundation::NSError;
use super::{login_item_status_from_raw, LoginItemStatus};
#[link(name = "ServiceManagement", kind = "framework")]
extern "C" {}
extern "C" {
fn setiopolicy_np(iotype: i32, scope: i32, policy: i32) -> i32;
}
const IOPOL_TYPE_DISK: i32 = 0;
const IOPOL_SCOPE_PROCESS: i32 = 0;
const IOPOL_DEFAULT: i32 = 0;
const IOPOL_THROTTLE: i32 = 3;
fn class() -> Option<&'static AnyClass> {
AnyClass::get(c"SMAppService")
}
pub fn available() -> bool {
class().is_some()
}
fn main_app() -> Result<Retained<AnyObject>, String> {
let cls = class().ok_or_else(|| "SMAppService needs macOS 13 or later".to_owned())?;
let svc: Option<Retained<AnyObject>> = unsafe { msg_send![cls, mainApp] };
svc.ok_or_else(|| "SMAppService.mainApp returned nil".to_owned())
}
pub fn status() -> Result<LoginItemStatus, String> {
let svc = main_app()?;
let raw: isize = unsafe { msg_send![&*svc, status] };
Ok(login_item_status_from_raw(raw as i64))
}
fn call_with_error(selector: &str, svc: &AnyObject) -> Result<(), String> {
let mut err: *mut NSError = std::ptr::null_mut();
let ok: Bool = unsafe {
match selector {
"register" => msg_send![svc, registerAndReturnError: &mut err],
_ => msg_send![svc, unregisterAndReturnError: &mut err],
}
};
if ok.as_bool() {
return Ok(());
}
autoreleasepool(|_| {
Err(match unsafe { err.as_ref() } {
Some(e) => format!("SMAppService {selector}: {}", e.localizedDescription()),
None => format!("SMAppService {selector} failed"),
})
})
}
pub fn register() -> Result<Result<(), String>, String> {
let svc = main_app()?;
Ok(call_with_error("register", &svc))
}
pub fn unregister() -> Result<Result<(), String>, String> {
let svc = main_app()?;
Ok(call_with_error("unregister", &svc))
}
pub fn open_settings() -> Result<(), String> {
let cls = class().ok_or_else(|| "SMAppService needs macOS 13 or later".to_owned())?;
unsafe {
let _: () = msg_send![cls, openSystemSettingsLoginItems];
}
Ok(())
}
pub fn set_background_io(on: bool) -> Result<(), String> {
let policy = if on { IOPOL_THROTTLE } else { IOPOL_DEFAULT };
let rc = unsafe { setiopolicy_np(IOPOL_TYPE_DISK, IOPOL_SCOPE_PROCESS, policy) };
if rc == 0 {
Ok(())
} else {
Err(format!(
"setiopolicy_np: {}",
std::io::Error::last_os_error()
))
}
}
}
pub fn available() -> bool {
#[cfg(target_os = "macos")]
{
mac::available()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
pub fn status() -> Result<LoginItemStatus, String> {
#[cfg(target_os = "macos")]
{
mac::status()
}
#[cfg(not(target_os = "macos"))]
{
Err("SMAppService is macOS only".into())
}
}
pub fn register() -> Result<LoginItemStatus, String> {
#[cfg(target_os = "macos")]
{
let call = mac::register()?;
register_result(call, mac::status()?)
}
#[cfg(not(target_os = "macos"))]
{
Err("SMAppService is macOS only".into())
}
}
pub fn unregister() -> Result<LoginItemStatus, String> {
#[cfg(target_os = "macos")]
{
let call = mac::unregister()?;
unregister_result(call, mac::status()?)
}
#[cfg(not(target_os = "macos"))]
{
Err("SMAppService is macOS only".into())
}
}
pub fn open_login_items_settings() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
mac::open_settings()
}
#[cfg(not(target_os = "macos"))]
{
Err("SMAppService is macOS only".into())
}
}
pub fn set_background_io(on: bool) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
mac::set_background_io(on)
}
#[cfg(not(target_os = "macos"))]
{
let _ = on;
Err("throttled disk I/O policy is macOS only".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn raw_values_map_to_the_four_apple_states() {
assert_eq!(
login_item_status_from_raw(0),
LoginItemStatus::NotRegistered
);
assert_eq!(login_item_status_from_raw(1), LoginItemStatus::Enabled);
assert_eq!(
login_item_status_from_raw(2),
LoginItemStatus::RequiresApproval
);
assert_eq!(login_item_status_from_raw(3), LoginItemStatus::NotFound);
}
#[test]
fn an_unknown_raw_value_is_carried_and_never_enabled() {
let s = login_item_status_from_raw(7);
assert_eq!(s, LoginItemStatus::Unknown(7));
assert!(!s.is_enabled());
assert!(!s.needs_approval());
assert_eq!(login_item_status_from_raw(-1), LoginItemStatus::Unknown(-1));
}
#[test]
fn only_enabled_is_enabled_and_only_requires_approval_needs_approval() {
use LoginItemStatus::*;
for s in [
NotRegistered,
Enabled,
RequiresApproval,
NotFound,
Unknown(9),
] {
assert_eq!(s.is_enabled(), s == Enabled);
assert_eq!(s.needs_approval(), s == RequiresApproval);
}
}
#[test]
fn status_serialises_camel_case() {
assert_eq!(
serde_json::to_string(&LoginItemStatus::RequiresApproval).unwrap(),
"\"requiresApproval\""
);
assert_eq!(
serde_json::to_string(&LoginItemStatus::NotRegistered).unwrap(),
"\"notRegistered\""
);
}
#[test]
fn bundle_detection() {
assert!(executable_in_app_bundle(Path::new(
"/Applications/Demo.app/Contents/MacOS/Demo"
)));
assert!(!executable_in_app_bundle(Path::new(
"/Users/me/target/debug/demo"
)));
}
#[test]
fn service_management_needs_all_four_conditions() {
assert!(service_management_selected(true, true, true, false));
assert!(!service_management_selected(false, true, true, false));
assert!(!service_management_selected(true, false, true, false));
assert!(!service_management_selected(true, true, false, false));
assert!(!service_management_selected(true, true, true, true));
}
#[test]
fn register_failure_that_left_the_item_registered_is_a_success() {
let err = || Err("already registered".to_owned());
assert_eq!(
register_result(Ok(()), LoginItemStatus::RequiresApproval),
Ok(LoginItemStatus::RequiresApproval)
);
assert_eq!(
register_result(err(), LoginItemStatus::Enabled),
Ok(LoginItemStatus::Enabled)
);
assert_eq!(
register_result(err(), LoginItemStatus::RequiresApproval),
Ok(LoginItemStatus::RequiresApproval)
);
assert!(register_result(err(), LoginItemStatus::NotRegistered).is_err());
assert!(register_result(err(), LoginItemStatus::NotFound).is_err());
}
#[test]
fn unregister_failure_that_left_the_item_gone_is_a_success() {
let err = || Err("not registered".to_owned());
assert_eq!(
unregister_result(err(), LoginItemStatus::NotRegistered),
Ok(LoginItemStatus::NotRegistered)
);
assert_eq!(
unregister_result(err(), LoginItemStatus::NotFound),
Ok(LoginItemStatus::NotFound)
);
assert!(unregister_result(err(), LoginItemStatus::Enabled).is_err());
assert!(unregister_result(err(), LoginItemStatus::RequiresApproval).is_err());
}
}